06 — Packs & assets
Pack Studio CRUD, asset blobs, import/export, and the asset library.
Scopes: pack:read for GETs, pack:write for mutations. Everything is
owner-scoped — another user's draft pack answers 404.
Packs — /packs
| Method | Path | Notes |
|---|---|---|
GET | /packs | List the caller's packs |
POST | /packs | Create |
GET | /packs/:id | Pack manifest incl. assets[] |
PATCH | /packs/:id | Update manifest (name, visibility, cover art, …) |
DELETE | /packs/:id | Delete |
POST | /packs/:packId/duplicate | Fork (reader-permission, result is always draft) |
POST | /packs/import | Import a .gas-pack zip (verify-before-write) |
GET | /packs/:packId/export | Export as .gas-pack zip |
GET | /packs/:id/download | Not implemented — always 501 { error: 'deferred_to_spec_5' } (the marketplace CDN is deferred). Use GET /packs/:packId/export for the bundle |
Pack assets
| Method | Path | Notes |
|---|---|---|
GET | /packs/:packId/assets | List assets |
POST | /packs/:packId/assets | Multipart upload (FormData) — 50 MB cap, sha-keyed dedup |
POST | /packs/:packId/assets/generate | Generate an sfx/music asset via Vault7, then persist it like an upload (see below) |
PATCH | /packs/:packId/assets/:assetId | Rename / metadata |
DELETE | /packs/:packId/assets/:assetId | Delete (row + blob when unreferenced) |
GET | /packs/:packId/assets/:assetId/file | Download the blob |
Blobs dedup by sha internally; each pack keeps its own row (snapshot semantics), so importing the same pack twice works.
Generate audio assets — POST /packs/:packId/assets/generate
JSON body (owner-only, exactly the upload's authz — bundled packs 403):
{
"kind": "sfx", // required: "sfx" | "music"
"prompt": "heavy wooden door creak", // required, 1–1000 chars
"name": "door-creak", // optional, same [a-z0-9-] shape as upload;
// default "sfx-<id>" / "music-<id>"
"model": "stable-audio-3-sfx", // optional
"duration": 8 // optional seconds: sfx 1–30, music 5–30
}
Models — sfx: audioldm2, audioLDM3, stable-audio-3-sfx;
music: musicgen, ace-step. Omit model and upstream's default applies.
The upstream (gamevault Vault7) is async: this route submits, polls every
~2 s up to 60 s, downloads and persists in the request — no job is stored
here. On success 201 returns the normal asset row plus provenance:
{
"id": "pa_…", "packId": "…", "kind": "sfx", "name": "sfx-ab12cd",
"sha256": "…", "bytes": 43210, "fileUrl": "/api/packs/…/file",
// …same fields as an upload…
"generation": { "jobId": "…", "provider": "gamevault",
"model": "stable-audio-3-sfx", "durationSeconds": 8,
"creditCost": 2 }
}
Output is WAV 44.1 kHz stereo stored as-is (50 MB cap, sha dedup) — the
bytes are served through the same …/assets/:assetId/file endpoint.
Status codes (all {error, reason} JSON):
| Status | error / reason | Meaning |
|---|---|---|
201 | — | Generated and persisted |
400 | invalid / invalid_body|invalid_name|invalid_model|invalid_duration | Body failed validation (nothing submitted) |
402 | budget_exhausted / gamevault_daily_ceiling | GAMEVAULT_DAILY_MAX_USD ceiling hit (pre-flight) |
402 | insufficient_credits | The Vault7 account balance is gone (upstream 402) |
403 | not_pack_owner / bundled_pack_immutable / unresolved_caller | Authz (same as upload) |
404 | not_found / pack_missing | Pack does not exist |
409 | conflict / name_exists | Per-pack name collision |
501 | ai_not_available / gamevault_not_configured | GAMEVAULT_API_KEY unset — route inert |
502 | generation_failed / upstream_failed|upstream_unauthorized|upstream_forbidden|upstream_error|download_failed|no_output_files | Upstream refused, failed, or never delivered bytes |
504 | generation_timeout | The bounded wait elapsed while the job was still queued |
Credit costs: sfx 2, music 5 (100 credits = $1; GET /api/v1/generate/pricing on Vault7 is the source of truth). Audio
generation needs server-side env — the key, the daily ceiling and the
token-minting guide live in
docs/agent-pack/gamevault-audio.md.
API-key callers need the pack:write scope (the central gate covers this
path automatically).
Asset library — /assets
| Method | Path | Notes |
|---|---|---|
GET | /assets | List — query kind, packId, search (substring of name), limit (1–200, default 50), offset (default 0) → { items, limit, offset } |
GET | /assets/:id | Fetch metadata; 404 { error: 'not_found' } |
POST | /assets/upload | Stub — always 403 { error: 'marketplace_owned' }. Upload goes through POST /packs/:packId/assets |
PATCH | /assets/:id | Rename — body { name? } (1–120 chars) → { ok: true } |
GET /assets only lists assets of published packs or packs the caller
owns; another user's draft pack never surfaces (authz audit M2).
GET /assets/:id has a side effect: when the row has no palette yet and
its fileUrl is an https: URL, the first read fetches the file, computes
format, transparencyRatio and palette, and persists them. Any fetch,
decode or size failure falls back to the raw row (never a 500).
PATCH /assets/:id errors: 403 { error: 'forbidden', reason: 'unresolved_owner' } (no user behind the caller), 403 with
reason: 'bundled_pack_readonly' (bundled pack) or another reason from the
owner check (a pack that is not yours), 404 { error: 'not_found' } (asset
or its pack missing).
There is no DELETE /assets/:id, GET /assets/:id/download,
POST /assets or POST /packs/upload. Assets are created, deleted and
downloaded through the pack-scoped routes above
(POST / DELETE /packs/:packId/assets…, GET …/assets/:assetId/file,
POST /packs/import).
Draft assets of other users are hidden (authz audit M2).
World art — /worlds/:id/art
Stored 2D art (SVG, PNG, WEBP) attached to one world (docs/02 §9). Distinct
from the pack asset library above: rows belong to the world, bytes go to the
sha256-keyed blob store the designer meshes use (identical bytes are shared,
so a delete leaves the blob in place). Scopes: world:read for GETs and for
/art/vet, world:write for the other mutations.
| Method | Path | Role | Notes |
|---|---|---|---|
GET | /worlds/:id/art | any member | List, newest first → { items: StoredArt[] } |
GET | /worlds/:id/art/:artId/file | any member | The stored bytes (:artId is the artId you chose, not the row id) |
POST | /worlds/:id/art | owner or GM | Import and store (multipart) → 201 |
DELETE | /worlds/:id/art/:artId | owner or GM | Remove the row → 200 { ok: true } |
POST | /worlds/:id/art/vet | owner or GM | Dry run of the import: stores nothing |
A non-member, or a world that does not exist, gets 404 { error: 'not_found' } on all five. A plain member gets 403 { error: 'forbidden', reason: 'gm_only' } on the three GM routes. No user behind the caller:
403 { error: 'forbidden', reason: 'unresolved_caller' }.
Multipart fields (POST /art and POST /art/vet):
| Field | Type | Notes |
|---|---|---|
file | file | required. Format is sniffed from the bytes (PNG signature, RIFF/WEBP, else SVG) — never the filename or content-type |
id | string | required for store; for vet required except for a Tiled tileset (each tile names itself) |
kind | terrain | object | decoration | required (for a Tiled tileset it is the default kind) |
variant | string | optional |
tags | string | optional, comma-separated |
anchor | center | bottom-center | top-center | top-left | bottom-left | optional |
Size limits: SVG 2 MiB, raster 8 MiB (/art accepts the larger of the two);
/art/vet also accepts a Tiled JSON tileset up to 4 MiB, so its cap is 8 MiB.
At most 500 stored art rows per world.
Errors (400 { error: 'invalid', reason }): missing_field,
invalid_kind, invalid_anchor, too_large, too_many (store only),
rejected (the importer refused it — the body adds errors: string[];
importSvg rejects scripts and external references rather than stripping
them). POST /art also returns 409 { error: 'conflict', reason: 'art_exists' } for a duplicate artId in the same world.
POST /art response (201): the stored row plus warnings: string[]:
{ "id": "art_…", "artId": "oak", "kind": "object", "format": "svg",
"sha256": "…", "bytes": 1234, "width": 64, "height": 64,
"entry": { /* the normalised docs/02 §3.1 entry */ },
"uploadedByUserId": "…", "uploadedAt": 1730000000000, "warnings": [] }
For SVG the stored bytes are the importer's normalised text, for rasters
the original bytes. Each store/delete writes an audit row
(world_art:upload / world_art:delete).
GET …/file returns the bytes with Content-Type from the stored format,
ETag: "<sha256>", X-Content-Type-Options: nosniff, a sandboxing
Content-Security-Policy (an SVG opened directly cannot run script) and
Cache-Control: private, max-age=31536000, immutable. 404 for an unknown
artId; 410 { error: 'blob_missing' } when the row exists but the blob is
gone.
POST /art/vet (stateless — no row, no blob) returns 200 with
{ format, entry, size: {width, height}, bytes, warnings }, or for a Tiled
tileset { format: 'tiled', name, tileSize, tiles: [{ tileId, id, kind, path }], bytes, warnings }. Same 400 reasons as above (except too_many). Because
it stores nothing, an API key needs only world:read for it.
Not built: a Tiled tileset cannot be stored (vet only), and nothing in packs/exports consumes these rows yet (per the route's header comment — not re-checked against the export package).