WorldForge Studio

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

MethodPathNotes
GET/packsList the caller's packs
POST/packsCreate
GET/packs/:idPack manifest incl. assets[]
PATCH/packs/:idUpdate manifest (name, visibility, cover art, …)
DELETE/packs/:idDelete
POST/packs/:packId/duplicateFork (reader-permission, result is always draft)
POST/packs/importImport a .gas-pack zip (verify-before-write)
GET/packs/:packId/exportExport as .gas-pack zip
GET/packs/:id/downloadNot implemented — always 501 { error: 'deferred_to_spec_5' } (the marketplace CDN is deferred). Use GET /packs/:packId/export for the bundle

Pack assets

MethodPathNotes
GET/packs/:packId/assetsList assets
POST/packs/:packId/assetsMultipart upload (FormData) — 50 MB cap, sha-keyed dedup
POST/packs/:packId/assets/generateGenerate an sfx/music asset via Vault7, then persist it like an upload (see below)
PATCH/packs/:packId/assets/:assetIdRename / metadata
DELETE/packs/:packId/assets/:assetIdDelete (row + blob when unreferenced)
GET/packs/:packId/assets/:assetId/fileDownload 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):

Statuserror / reasonMeaning
201—Generated and persisted
400invalid / invalid_body|invalid_name|invalid_model|invalid_durationBody failed validation (nothing submitted)
402budget_exhausted / gamevault_daily_ceilingGAMEVAULT_DAILY_MAX_USD ceiling hit (pre-flight)
402insufficient_creditsThe Vault7 account balance is gone (upstream 402)
403not_pack_owner / bundled_pack_immutable / unresolved_callerAuthz (same as upload)
404not_found / pack_missingPack does not exist
409conflict / name_existsPer-pack name collision
501ai_not_available / gamevault_not_configuredGAMEVAULT_API_KEY unset — route inert
502generation_failed / upstream_failed|upstream_unauthorized|upstream_forbidden|upstream_error|download_failed|no_output_filesUpstream refused, failed, or never delivered bytes
504generation_timeoutThe 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

MethodPathNotes
GET/assetsList — query kind, packId, search (substring of name), limit (1–200, default 50), offset (default 0) → { items, limit, offset }
GET/assets/:idFetch metadata; 404 { error: 'not_found' }
POST/assets/uploadStub — always 403 { error: 'marketplace_owned' }. Upload goes through POST /packs/:packId/assets
PATCH/assets/:idRename — 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.

MethodPathRoleNotes
GET/worlds/:id/artany memberList, newest first → { items: StoredArt[] }
GET/worlds/:id/art/:artId/fileany memberThe stored bytes (:artId is the artId you chose, not the row id)
POST/worlds/:id/artowner or GMImport and store (multipart) → 201
DELETE/worlds/:id/art/:artIdowner or GMRemove the row → 200 { ok: true }
POST/worlds/:id/art/vetowner or GMDry 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):

FieldTypeNotes
filefilerequired. Format is sniffed from the bytes (PNG signature, RIFF/WEBP, else SVG) — never the filename or content-type
idstringrequired for store; for vet required except for a Tiled tileset (each tile names itself)
kindterrain | object | decorationrequired (for a Tiled tileset it is the default kind)
variantstringoptional
tagsstringoptional, comma-separated
anchorcenter | bottom-center | top-center | top-left | bottom-leftoptional

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).