{
  "openapi": "3.0.3",
  "info": {
    "title": "TRaX Public API",
    "version": "1.20.0",
    "description": "The versioned public developer API for TRaX. Authenticate one of two ways: (1) a user-scoped API key — `Authorization: Bearer sk_live_...` (or the `X-API-Key` alias) — for server-to-server integrations; or (2) an interactive-login user JWT — `Authorization: Bearer <oidc-jwt>` — obtained from an OAuth Authorization-Code + PKCE login against the TRaX IdP, the path for native iOS/Android apps. A user JWT acts as that user with their full access; per-tenant ownership is always enforced. Every response carries an `X-Request-Id`. Errors share one envelope: `{ \"error\": { \"code\", \"message\", \"requestId\" } }`. List endpoints are cursor-paginated: `{ \"data\": [ ... ], \"nextCursor\": \"...\" }`. Phases 1-3 are LIVE: reads + gated go-live (Phase 1), studio/source/destination CRUD + connections (Phase 2), and chat + the media library (Phase 3) — including the full upload lifecycle, large files in presigned parts, and attaching a library asset to a studio by id. The audio mixer and the CANVAS are live too: read the whole canvas in one conditional call, move a tile, pick a preset, recall a scene, or hand the arrangement back to the auto planner. Phase 8 (#597) adds the phone go-live path: `POST /v1/studios/{id}/contribute` provisions a device's input and its publish credential in one idempotent call, and `POST /v1/studios/{id}/monitor` mints the return feed. The reads a client polls — stream-status, the source list, and a single source — support `ETag` / `If-None-Match` and answer 304, which matters on a cellular link. Mutating endpoints are gated behind PUBLIC_API_WRITE_ENABLED and return 503 write_disabled while off. The ACCOUNT surface (#666) is live: read/update your own profile (the @username identity), manage your developer API keys, and delete the account itself (DELETE /v1/account — immediate and irreversible) — key management and account deletion are JWT-bearer-only (an API key is refused with 403 interactive_auth_required regardless of scopes). See trax-dev/docs/public-api-platform-design.md."
  },
  "servers": [
    { "url": "/", "description": "relative to the gateway host (api-dev.traxstreaming.live)" }
  ],
  "tags": [
    { "name": "meta", "description": "Health + spec (unauthenticated)" },
    { "name": "studios", "description": "Studios, sources, destinations, stream status" },
    { "name": "stream", "description": "Go-live / go-offline lifecycle" },
    { "name": "chat", "description": "Unified live-chat messages" },
    { "name": "connections", "description": "Linked platform accounts" },
    { "name": "media", "description": "Media library assets + uploads" },
    { "name": "canvas", "description": "The studio canvas: tile placement, presets, scenes" },
    { "name": "account", "description": "The caller's own account: profile (@username), developer API keys, deletion request" }
  ],
  "components": {
    "securitySchemes": {
      "ApiKeyBearer": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "sk_live",
        "description": "A user-scoped API key minted from the account 'Developer / API Keys' screen. Also accepted via the `X-API-Key` header."
      },
      "UserJWT": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "JWT",
        "description": "An interactive-login OIDC access token from an OAuth Authorization-Code + PKCE flow against the TRaX IdP (Zitadel). This is the native mobile-app path: log the user in, then call /v1 with `Authorization: Bearer <jwt>`. The token acts as that user with their full access; per-tenant ownership is enforced downstream."
      }
    },
    "schemas": {
      "Account": {
        "type": "object",
        "additionalProperties": false,
        "required": ["userId", "username", "usernameSet"],
        "description": "The caller's /v1 account profile: the @username identity as TRaX's account service records it. Deliberately WITHOUT display name or email — those are OIDC claims that belong to the IdP token a signed-in client already holds, and an API key has no claims at all.",
        "properties": {
          "userId": { "type": "string", "description": "The acting subject (the key's owner, or the JWT's sub) — the stable user id other resources reference." },
          "username": { "type": "string", "description": "The claimed @handle in canonical (lowercase) form, or \"\" when unclaimed. Check usernameSet rather than comparing with the empty string." },
          "usernameSet": { "type": "boolean", "description": "Whether a handle has been claimed." },
          "createdAt": { "type": "string", "description": "When the profile row was first written (RFC 3339). Absent when the user has no profile row yet." },
          "updatedAt": { "type": "string", "description": "When the profile row last changed (RFC 3339). Absent when the user has no profile row yet." },
          "usernameChangedAt": { "type": "string", "description": "When the handle last CHANGED (RFC 3339); absent when never changed. The once-per-cooldown change limit counts from this." }
        }
      },
      "UpdateAccountRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": ["username"],
        "description": "PATCH /v1/account body. Today the only updatable field is the username.",
        "properties": {
          "username": { "type": "string", "description": "The requested @handle. Mixed case and a leading @ are accepted and canonicalized (lowercased). Rules: 3-20 chars of [a-z0-9_], no leading/trailing/double underscore, not on the reserved blocklist. Refusals: 422 invalid_username (with the format reason), 409 username_taken / username_reserved, 429 username_cooldown (with Retry-After)." }
        }
      },
      "AccountApiKey": {
        "type": "object",
        "additionalProperties": false,
        "required": ["id", "name", "prefix", "scopes", "createdAt"],
        "description": "METADATA of one developer API key. Never carries the secret — the only secret-derived field is the display prefix, and the full key is unrecoverable after creation.",
        "properties": {
          "id": { "type": "string", "description": "Opaque key id — what DELETE /v1/account/api-keys/{keyId} takes." },
          "name": { "type": "string", "description": "The user-chosen label." },
          "prefix": { "type": "string", "description": "The first characters of the secret (e.g. sk_live_7Qm2xB) so a UI can say WHICH key this is." },
          "scopes": { "type": "array", "items": { "type": "string" }, "description": "The key's scope ceiling, from the canonical /v1 scope vocabulary." },
          "createdAt": { "type": "string", "description": "Mint time, RFC 3339." },
          "lastUsedAt": { "type": "string", "description": "Last successful verification, RFC 3339 (bumped at most once a minute). Absent = never used." },
          "expiresAt": { "type": "string", "description": "Expiry, RFC 3339. Absent = never expires." },
          "revokedAt": { "type": "string", "description": "Revocation time, RFC 3339. Absent = active." }
        }
      },
      "AccountApiKeyList": {
        "type": "object",
        "additionalProperties": false,
        "required": ["data"],
        "properties": {
          "data": { "type": "array", "items": { "$ref": "#/components/schemas/AccountApiKey" }, "description": "The caller's keys, newest first, including revoked ones." }
        }
      },
      "CreateAccountApiKeyRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": ["name"],
        "description": "POST /v1/account/api-keys body.",
        "properties": {
          "name": { "type": "string", "description": "A label for the key (\"CI uploader\", \"my phone\"). Required." },
          "scopes": { "type": "array", "items": { "type": "string" }, "description": "Requested scope ceiling. Omit for the safe read-only default set. An unknown scope refuses the WHOLE request with 400 — never a key silently missing a grant." },
          "expiresAt": { "type": "string", "description": "Optional expiry, RFC 3339. Omit for a key that never expires." }
        }
      },
      "AccountApiKeyCreated": {
        "type": "object",
        "additionalProperties": false,
        "required": ["id", "name", "prefix", "scopes", "createdAt", "key"],
        "description": "The minted key. `key` is the FULL plaintext secret and appears EXACTLY ONCE, in this response — it is never stored and can never be shown again. Store it now.",
        "properties": {
          "id": { "type": "string" },
          "name": { "type": "string" },
          "prefix": { "type": "string" },
          "scopes": { "type": "array", "items": { "type": "string" } },
          "createdAt": { "type": "string" },
          "lastUsedAt": { "type": "string" },
          "expiresAt": { "type": "string" },
          "revokedAt": { "type": "string" },
          "key": { "type": "string", "description": "The one-time full sk_live_ secret. SHOWN ONLY HERE." }
        }
      },
      "AccountDeletionRequestBody": {
        "type": "object",
        "additionalProperties": false,
        "description": "DELETE /v1/account body. May be omitted entirely.",
        "properties": {
          "reason": { "type": "string", "description": "Optionally, why — recorded on the legal deletion record. Never required and never changes what is erased." }
        }
      },
      "AccountDeletionResult": {
        "type": "object",
        "additionalProperties": false,
        "required": ["requestId", "alreadyDeleted", "ok", "confirmationEmailSent"],
        "description": "Acknowledgment of an EXECUTED account erasure — the caller's half of the append-only legal record. Surface requestId to the user: it is their receipt reference, and no email is sent on this path.",
        "properties": {
          "requestId": { "type": "string", "description": "The deletion record's id. On a repeat request this is the ORIGINAL record's id." },
          "alreadyDeleted": { "type": "boolean", "description": "True when a completed erasure was already on file; nothing ran again." },
          "ok": { "type": "boolean", "description": "False when a data step failed (operators are alerted and finish by hand); the identity itself is gone either way." },
          "retained": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Each retained-data category mapped to the legal record's own wording for why it is kept (billing records, the deletion log itself)." },
          "confirmationEmailSent": { "type": "boolean", "description": "Always false on this path today — the API hop carries no email address." }
        }
      },
      "UpdateBroadcastRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": ["broadcastId"],
        "description": "Edit of a destination's picked YouTube broadcast (#650). `broadcastId` is required; every other field is optional and applies only when present. The optional booleans and `streamDelayMs` are tri-state: omit to leave unchanged. Ignored by non-YouTube platforms.",
        "properties": {
          "broadcastId": { "type": "string", "description": "The YouTube broadcast to edit. Required so the change never lands on the wrong stream." },
          "title": { "type": "string", "description": "snippet.title. Applied to the picked broadcast." },
          "description": { "type": "string", "description": "snippet.description. Applied to the picked broadcast." },
          "privacy": { "type": "string", "enum": ["public", "unlisted", "private"], "description": "status.privacyStatus." },
          "madeForKids": { "type": "boolean", "description": "status.selfDeclaredMadeForKids (COPPA)." },
          "categoryId": { "type": "string", "description": "snippet.categoryId." },
          "latency": { "type": "string", "enum": ["normal", "low", "ultraLow"], "description": "contentDetails.latencyPreference. Immutable once testing/live." },
          "enableAutoStart": { "type": "boolean", "description": "contentDetails.enableAutoStart." },
          "enableAutoStop": { "type": "boolean", "description": "contentDetails.enableAutoStop." },
          "enableDvr": { "type": "boolean", "description": "contentDetails.enableDvr. Immutable once testing/live." },
          "enableEmbed": { "type": "boolean", "description": "contentDetails.enableEmbed. Immutable once testing/live." },
          "recordFromStart": { "type": "boolean", "description": "contentDetails.recordFromStart. Immutable once testing/live." },
          "streamDelayMs": { "type": "integer", "format": "int32", "description": "contentDetails.broadcastStreamDelayMs. Immutable once testing/live." },
          "projection": { "type": "string", "enum": ["rectangular", "360"], "description": "contentDetails.projection. Immutable once testing/live." },
          "enableMonitorStream": { "type": "boolean", "description": "contentDetails.monitorStream.enableMonitorStream. Immutable once testing/live." }
        }
      },
      "BroadcastEditResult": {
        "type": "object",
        "additionalProperties": false,
        "required": ["rejectedFields"],
        "description": "The outcome of a broadcast edit.",
        "properties": {
          "rejectedFields": {
            "type": "array",
            "items": { "type": "string" },
            "description": "The fields YouTube refused because the broadcast is already testing/live (contentDetails freezes then). Empty when everything applied."
          },
          "lifecycleStatus": { "type": "string", "description": "The broadcast's lifecycle status after the edit (created | ready | testing | live | complete | …), as YouTube reported it. Absent if the platform did not return one." }
        }
      },
      "ThumbnailResult": {
        "type": "object",
        "additionalProperties": false,
        "required": ["thumbnailUrl"],
        "properties": {
          "thumbnailUrl": { "type": "string", "description": "The thumbnail URL YouTube now serves for the broadcast." }
        }
      },
      "BroadcastTemplate": {
        "type": "object",
        "additionalProperties": false,
        "required": ["id", "studioId", "name", "config", "isDefault"],
        "description": "A saved broadcast template — a named snapshot of the create-mode broadcast settings that prefills the create form. It creates nothing on YouTube.",
        "properties": {
          "id": { "type": "string" },
          "studioId": { "type": "string" },
          "name": { "type": "string" },
          "config": { "type": "object", "additionalProperties": true, "description": "The BroadcastConfig snapshot, stored verbatim. The client owns the shape." },
          "isDefault": { "type": "boolean", "description": "Whether this is the studio's pinned default (at most one per studio)." },
          "createdAt": { "type": "string", "format": "date-time" },
          "updatedAt": { "type": "string", "format": "date-time" }
        }
      },
      "BroadcastTemplateList": {
        "type": "object",
        "additionalProperties": false,
        "required": ["data"],
        "properties": {
          "data": { "type": "array", "items": { "$ref": "#/components/schemas/BroadcastTemplate" } },
          "nextCursor": { "type": "string" }
        }
      },
      "Broadcast": {
        "type": "object",
        "additionalProperties": false,
        "required": ["id", "title", "status"],
        "properties": {
          "id": { "type": "string", "description": "The platform's broadcast id. On YouTube this is also the video id — the value the pick and edit endpoints take." },
          "title": { "type": "string" },
          "description": { "type": "string" },
          "status": { "type": "string", "description": "Lifecycle status as the platform reports it: created | ready | testing | live | complete | ... (Facebook uses its own vocabulary). Only broadcasts not yet complete/revoked are worth picking." },
          "scheduledStartTime": { "type": "string", "description": "The platform-side planned start, RFC 3339. Absent when none is set." },
          "thumbnailUrl": { "type": "string" },
          "privacy": { "type": "string", "description": "public | unlisted | private (YouTube). Live current-state so an editor can seed from actual values; absent on platforms that don't report it." },
          "madeForKids": { "type": "boolean", "description": "An absent boolean here and below means \"false or not reported\", never a guarantee." },
          "latency": { "type": "string", "description": "normal | low | ultraLow (YouTube)." },
          "enableDvr": { "type": "boolean" },
          "enableAutoStart": { "type": "boolean" },
          "enableAutoStop": { "type": "boolean" },
          "enableEmbed": { "type": "boolean" },
          "recordFromStart": { "type": "boolean" },
          "enableMonitorStream": { "type": "boolean" },
          "projection": { "type": "string", "description": "rectangular | 360 (YouTube)." }
        }
      },
      "BroadcastList": {
        "type": "object",
        "additionalProperties": false,
        "required": ["data"],
        "properties": {
          "data": { "type": "array", "items": { "$ref": "#/components/schemas/Broadcast" } },
          "nextCursor": { "type": "string" }
        }
      },
      "CreateBroadcastRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": ["title"],
        "properties": {
          "title": { "type": "string", "description": "Title for the created broadcast. Required — YouTube rejects untitled broadcasts. Overrides the saved broadcastConfig's title." },
          "description": { "type": "string", "description": "Description body. Optional." },
          "scheduledStartTime": { "type": "string", "description": "RFC 3339 planned start. Absent = CREATE NOW on YouTube (a near-immediate start is stamped); Facebook's scheduled path requires a future time at least ~10 minutes out." }
        }
      },
      "PickBroadcastRequest": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "broadcastId": { "type": "string", "nullable": true, "description": "The platform broadcast id to pick, from the broadcasts list. null or empty CLEARS the pick and returns the destination to create mode (auto-create at go-live)." },
          "title": { "type": "string", "description": "Human title stored on the pick pointer so status rows can label it without a platform round trip. Optional; ignored when clearing." }
        }
      },
      "BroadcastPickResult": {
        "type": "object",
        "additionalProperties": false,
        "required": ["mode"],
        "properties": {
          "mode": { "type": "string", "description": "\"pick\" when a broadcast is now picked; \"create\" after a clear." },
          "broadcastId": { "type": "string", "description": "The picked broadcast id; absent after a clear." },
          "title": { "type": "string", "description": "The stored pick title; absent after a clear." }
        }
      },
      "CreateManualConnectionRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": ["label", "rtmpUrl", "streamKey"],
        "properties": {
          "platform": { "type": "string", "description": "Free-form: facebook | instagram | rumble | twitter | tiktok | custom | ... Empty defaults to custom." },
          "label": { "type": "string", "description": "The name shown in destination pickers, e.g. \"My Facebook Page\". Required." },
          "rtmpUrl": { "type": "string", "description": "RTMP(S) push URL from the platform's stream dashboard, e.g. rtmps://live-api-s.facebook.com:443/rtmp. Required." },
          "streamKey": { "type": "string", "description": "The stream key. WRITE-ONLY: stored encrypted and never returned by any read on this API. Required." }
        }
      },
      "UpdateManualConnectionRequest": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "label": { "type": "string", "description": "New label. Absent = keep; empty string is rejected (a connection always has a label)." },
          "rtmpUrl": { "type": "string", "description": "New RTMP(S) push URL. Absent = keep; empty string is rejected." },
          "streamKey": { "type": "string", "description": "New stream key. Absent OR empty = keep the stored key — there is no key-clear; rotate the key on the platform and PATCH it here, or delete the connection." }
        }
      },
      "CreateBroadcastTemplateRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": ["name", "config"],
        "properties": {
          "name": { "type": "string", "description": "Human label. Required." },
          "config": { "type": "object", "additionalProperties": true, "description": "The BroadcastConfig snapshot as a JSON object. Required; a scalar/array is rejected." },
          "isDefault": { "type": "boolean", "description": "Pin the new template as the studio's default in the same transaction. Default false." }
        }
      },
      "UpdateBroadcastTemplateRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": ["name", "config"],
        "properties": {
          "name": { "type": "string", "description": "New human label. Required." },
          "config": { "type": "object", "additionalProperties": true, "description": "New BroadcastConfig snapshot as a JSON object. Required. The default pin is left untouched." }
        }
      },
      "AudioMaster": {
        "type": "object",
        "additionalProperties": false,
        "required": ["muted", "volume"],
        "description": "The studio's master strip: the level and gate the whole broadcast mix passes through on its way to every destination. A fader and a mute is all it has.",
        "properties": {
          "muted": {
            "type": "boolean",
            "description": "The master GATE. True silences the program for every viewer on every destination. INDEPENDENT of volume — the fader keeps its position underneath, so unmuting returns to exactly the level it was at."
          },
          "volume": {
            "type": "number",
            "minimum": 0,
            "maximum": 10,
            "description": "The master fader, LINEAR: 0.0 silent, 1.0 unity (0 dB), above 1.0 amplifies. Not decibels."
          }
        }
      },
      "AudioSourceStrip": {
        "type": "object",
        "additionalProperties": false,
        "required": ["sourceId", "name", "type", "active", "muted", "volume", "pan", "soloed", "trim", "muteGroups"],
        "description": "One channel strip — the mixer state of a single source, with defaults resolved. A source nobody has touched reports the running defaults (volume 1.0, unmuted, centre, trim 1.0), not an absence.",
        "properties": {
          "sourceId": { "type": "string", "description": "The source this strip belongs to — the same id the sources endpoints use." },
          "name": { "type": "string", "description": "Display name, echoing the source's. Present so a fader can be labelled without a second call." },
          "type": { "type": "string", "description": "Source kind, echoing the source's. Every source row carries a strip, including ones with no audio track; read this to decide what to draw." },
          "active": {
            "type": "boolean",
            "description": "Whether the operator has put this source on the canvas. INTENT, and a different question from `muted`: an inactive source still has a strip. Deactivating a source auto-mutes it and reactivating restores the prior mute, so `active` is what explains a strip that reads muted because it was switched off."
          },
          "muted": {
            "type": "boolean",
            "description": "This channel's GATE, independent of `volume` — the fader keeps its position underneath."
          },
          "volume": { "type": "number", "minimum": 0, "maximum": 10, "description": "Channel fader, LINEAR: 0.0 silent, 1.0 unity (0 dB). Not decibels." },
          "pan": { "type": "number", "minimum": -1, "maximum": 1, "description": "Stereo position: -1.0 full left, 0.0 centre, +1.0 full right." },
          "soloed": {
            "type": "boolean",
            "description": "The operator's solo latch, synced across every operator in the studio. A mixing-desk intent flag — it does NOT by itself mute the other channels, so do not read it as \"everything else is off\"."
          },
          "trim": { "type": "number", "minimum": 0.1, "maximum": 16, "description": "Pre-fader input gain, where a hot or quiet input is brought to a nominal level before the fader. LINEAR, 1.0 nominal (-20 dB … +24 dB)." },
          "muteGroups": {
            "type": "array",
            "items": { "type": "integer" },
            "description": "Mute-group ids (1..4) this channel belongs to. READ-ONLY on this API: the group MASTER button that gives membership its meaning lives in the studio's mixer panel. Always an array — an empty membership is [], never null."
          }
        }
      },
      "StudioAudio": {
        "type": "object",
        "additionalProperties": false,
        "required": ["master", "sources"],
        "description": "The whole mix: master plus one strip per source.",
        "properties": {
          "master": { "$ref": "#/components/schemas/AudioMaster" },
          "sources": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/AudioSourceStrip" },
            "description": "One strip per source row, in the same order the sources list returns them. Empty when the studio has no sources — an empty mix, not an error."
          }
        }
      },
      "UpdateAudioSourceRequest": {
        "type": "object",
        "additionalProperties": false,
        "description": "A PATCH: every field is optional and an omitted field is left exactly as it was. `muteGroups` is deliberately not settable here.",
        "properties": {
          "muted": { "type": "boolean", "description": "New gate state. Set THIS to mute someone; writing volume 0.0 instead loses their fader position." },
          "volume": { "type": "number", "minimum": 0, "maximum": 10, "description": "New fader position, LINEAR. Outside 0.0–10.0 is a 400, not a clamp." },
          "pan": { "type": "number", "minimum": -1, "maximum": 1, "description": "New stereo position. Outside -1.0–1.0 is a 400." },
          "soloed": { "type": "boolean", "description": "New solo latch." },
          "trim": { "type": "number", "minimum": 0.1, "maximum": 16, "description": "New pre-fader input gain, LINEAR. Outside 0.1–16.0 is a 400." }
        }
      },
      "UpdateAudioMasterRequest": {
        "type": "object",
        "additionalProperties": false,
        "description": "A PATCH on the master strip: omitted fields are left unchanged.",
        "properties": {
          "muted": { "type": "boolean", "description": "New master gate state. True silences the program on every destination." },
          "volume": { "type": "number", "minimum": 0, "maximum": 10, "description": "New master fader position, LINEAR. Outside 0.0–10.0 is a 400." }
        }
      },
      "CanvasRect": {
        "type": "object",
        "additionalProperties": false,
        "required": ["x", "y", "width", "height"],
        "description": "One tile's destination rectangle, in CANVAS units — the canvasWidth x canvasHeight space the canvas reports, not output pixels and not fractions. A rect may hang off an edge (negative x, or x+width past the canvas) as long as it still intersects; a rect entirely outside describes a tile nobody can see and is a 400.",
        "properties": {
          "x": { "type": "integer", "description": "Left edge in canvas units. May be negative — a tile can hang off the left." },
          "y": { "type": "integer", "description": "Top edge in canvas units. May be negative." },
          "width": { "type": "integer", "minimum": 1, "description": "Width in canvas units. Must be positive and no larger than the canvas width." },
          "height": { "type": "integer", "minimum": 1, "description": "Height in canvas units. Must be positive and no larger than the canvas height." }
        }
      },
      "CanvasSlotRect": {
        "type": "object",
        "additionalProperties": false,
        "required": ["x", "y", "width", "height"],
        "description": "A preset SEAT in canvas units — the normalized slot already multiplied out and rounded the way the server rounds, so it compares EQUAL to the placement rect the server resolves for that slot rather than being off by one. Every client was doing this arithmetic by hand, which is how two of them end up disagreeing about whether a tile is in its slot.\n\nSeparate from CanvasRect deliberately: CanvasRect is a rectangle you WRITE, so it requires positive dimensions. This one is DERIVED, and reads all zeroes on a degraded capabilities read (empty presets + empty libraryVersion) — a canvas size we could not read is not a number to invent.",
        "properties": {
          "x": { "type": "integer", "minimum": 0, "description": "Left edge in canvas units." },
          "y": { "type": "integer", "minimum": 0, "description": "Top edge in canvas units." },
          "width": { "type": "integer", "minimum": 0, "description": "Width in canvas units. 0 only on a degraded read." },
          "height": { "type": "integer", "minimum": 0, "description": "Height in canvas units. 0 only on a degraded read." }
        }
      },
      "CanvasCrop": {
        "type": "object",
        "additionalProperties": false,
        "required": ["zoom", "panX", "panY"],
        "description": "One tile's content punch-in: which part of the SOURCE frame is shown inside its rectangle. Independent of the rectangle — cropping zooms into the picture, it does not move or resize the tile. Absent when the tile is uncropped.",
        "properties": {
          "zoom": { "type": "number", "minimum": 1, "maximum": 10, "description": "Punch-in factor. 1.0 is the whole frame; above 1.0 is zoomed in." },
          "panX": { "type": "number", "minimum": 0, "maximum": 1, "description": "Horizontal centre of the visible rect, 0.0 (left edge) to 1.0 (right edge) of the uncropped frame. 0.5 is centred. Meaningless at zoom 1.0." },
          "panY": { "type": "number", "minimum": 0, "maximum": 1, "description": "Vertical centre of the visible rect, 0.0 (top) to 1.0 (bottom)." }
        }
      },
      "CanvasPlacement": {
        "type": "object",
        "additionalProperties": false,
        "required": ["sourceId", "name", "type", "active", "onCanvas", "inTray", "zIndex", "alpha", "visible"],
        "description": "One source and where it sits — the join of a source row and the layout row. Every source in the studio gets one, seated or not, because 'what inputs exist and which are on air' is a single question and answering it from two lists taken at two moments is how a picker draws a tile that was deleted.",
        "properties": {
          "sourceId": { "type": "string", "description": "The source this tile is — the same id the sources endpoints use." },
          "name": { "type": "string", "description": "Display name, so a tile can be labelled without a second call." },
          "type": { "type": "string", "description": "Source kind (webcam, srt, rtmp, video-file, ...). Decides what to draw for a tile that has no picture yet." },
          "active": { "type": "boolean", "description": "Whether the source itself is enabled. An inactive source is off the canvas whatever its rectangle says." },
          "onCanvas": { "type": "boolean", "description": "True when the canvas is compositing this source RIGHT NOW. Resolved PER MODE, from the same state the encoder is driven from, so you never reimplement either rule.\n\nAUTO: the source holds a seat in the server planner's CURRENT resolution of activePreset. A source the preset displaced is false here AND carries no rect, even though the layout row still holds the rectangle it had under the previous preset.\n\nADVANCED: the source has a stored, visible, non-degenerate rectangle. traySourceIds is NOT consulted — nothing maintains that list in advanced mode, so a tile you dragged onto the canvas reads true here while still appearing in it.\n\nAn INACTIVE source is always false: deactivating removes it from the encoder canvas whatever geometry survives. See membershipBasis for how this answer was reached." },
          "inTray": { "type": "boolean", "description": "True when this is an ACTIVE, canvas-eligible input that did not get a seat — an input waiting, not an input on the program. The exact complement of onCanvas over the sources that could hold one.\n\nAn INACTIVE source is NEITHER on canvas nor in the tray: it is an input that exists. Calling it 'in the tray' would say it is one action from air when it is two." },
          "rect": { "$ref": "#/components/schemas/CanvasRect" },
          "zIndex": { "type": "integer", "description": "Stacking order. Higher draws in front; ties break on source id." },
          "alpha": { "type": "number", "minimum": 0, "maximum": 1, "description": "Opacity. 1.0 is fully opaque." },
          "visible": { "type": "boolean", "description": "Whether the tile is drawn. False hides it while keeping its rectangle, so showing it again does not make it jump." },
          "fitMode": { "type": "string", "enum": ["contain", "stretch"], "description": "Per-source fit inside the rectangle. Absent means unset — the encoder default, which is contain." },
          "crop": { "$ref": "#/components/schemas/CanvasCrop" },
          "ownerSub": { "type": "string", "description": "The person a DEVICE source belongs to (browser camera, shared screen, phone leg); absent for every other kind. It is what lets a client fold one person's several legs into one tile the way the studio's own seating does. Absent on a shared input, whose sharer's identity is stripped upstream." },
          "broughtIn": { "type": "boolean", "description": "True for an EXTRA leg the operator pulled out of its owner's folded person-unit so it gets its own cell. Only meaningful under the person-grid presets." }
        }
      },
      "CanvasBackground": {
        "type": "object",
        "additionalProperties": false,
        "required": ["color"],
        "description": "The colour the compositor fills behind every tile.",
        "properties": {
          "color": { "type": "string", "description": "CSS colour string, e.g. \"#000000\"." }
        }
      },
      "VerticalOverlay": {
        "type": "object",
        "additionalProperties": false,
        "required": ["enabled", "xOffset", "width", "height"],
        "description": "The 9:16 safe-area GUIDE the studio draws over the canvas for operators cutting a vertical feed. A guide, not a crop — it changes nothing the encoder composites.",
        "properties": {
          "enabled": { "type": "boolean", "description": "Whether the guide is shown." },
          "xOffset": { "type": "integer", "description": "Horizontal offset of the guide, in canvas units." },
          "width": { "type": "integer", "description": "Guide width in canvas units." },
          "height": { "type": "integer", "description": "Guide height in canvas units." }
        }
      },
      "ProgramOutput": {
        "type": "object",
        "additionalProperties": false,
        "required": ["width", "height", "fpsNum", "fpsDen"],
        "description": "The broadcast format the encoder is configured to produce. NOT the space rectangles are expressed in — a 720p program still composites on a 1920x1080 canvas.",
        "properties": {
          "width": { "type": "integer", "description": "Output width in pixels." },
          "height": { "type": "integer", "description": "Output height in pixels." },
          "fpsNum": { "type": "integer", "description": "Frame-rate numerator, e.g. 60 — or 60000 for 59.94." },
          "fpsDen": { "type": "integer", "description": "Frame-rate denominator, e.g. 1 — or 1001 for 59.94. Rational rather than rounded, because 59.94 and 60 are different broadcasts." },
          "fps": { "type": "number", "format": "double", "description": "The decimal rendering of fpsNum/fpsDen, e.g. 59.94. DISPLAY ONLY, and present only where the server computed it (the output endpoint). Never send it back as a setting: 59.94 could be 60000/1001 or 5994/100, and the encoder treats those differently." }
        }
      },
      "Resolution": {
        "type": "object",
        "additionalProperties": false,
        "required": ["width", "height", "available", "status"],
        "description": "One rung of the supported resolution ladder. `available` is false when the rung is known but not offered yet (coming soon, 4K today); `status` is the wire form: available or coming_soon. A client renders an unavailable rung greyed and must not let it be selected.",
        "properties": {
          "width": { "type": "integer" },
          "height": { "type": "integer" },
          "available": { "type": "boolean", "description": "false = the rung is coming soon and cannot be selected yet." },
          "status": { "type": "string", "enum": ["available", "coming_soon"] }
        }
      },
      "FrameRate": {
        "type": "object",
        "additionalProperties": false,
        "required": ["num", "den", "fps"],
        "description": "One accepted frame rate, as an exact rational plus its decimal rendering.",
        "properties": {
          "num": { "type": "integer", "description": "Numerator, e.g. 60000." },
          "den": { "type": "integer", "description": "Denominator, e.g. 1001." },
          "fps": { "type": "number", "format": "double", "description": "num/den as a decimal, e.g. 59.94. DISPLAY ONLY." }
        }
      },
      "OutputCaps": {
        "type": "object",
        "additionalProperties": false,
        "required": ["maxWidth", "maxHeight", "cappedBy", "supported", "supportedFrameRates"],
        "description": "What limits this studio's output right now, and what may be selected.\n\nThe cap is a fact about TODAY'S enabled destination set, not a permanent rule. Enable a destination and it can tighten; disable one and it lifts.",
        "properties": {
          "maxWidth": { "type": "integer", "description": "The highest width every enabled destination will accept today." },
          "maxHeight": { "type": "integer", "description": "The matching height. Equals the top of the ladder (3840x2160) when nothing caps." },
          "cappedBy": { "type": "string", "description": "The platform whose ingest limit set the ceiling, e.g. \"twitch\". EMPTY STRING when nothing is capping — read this rather than inferring a cap from maxWidth, which equals the ladder top in the uncapped case." },
          "supported": { "type": "array", "items": { "$ref": "#/components/schemas/Resolution" }, "description": "The full supported ladder, ascending. This is the exact set PATCH accepts; offer these and nothing else." },
          "supportedFrameRates": { "type": "array", "items": { "$ref": "#/components/schemas/FrameRate" }, "description": "The frame rates PATCH accepts, as exact rationals." }
        }
      },
      "StudioOutput": {
        "type": "object",
        "additionalProperties": false,
        "required": ["studioId", "selected", "effective", "caps", "codec", "targetBitrateKbps", "appliesToNextGoLive"],
        "description": "The program output format in all three of its truths.\n\nThey are separate fields because collapsing them misleads. A studio can be SET to 2160p, will EFFECTIVELY air at 1080p because Twitch is enabled, and the cap lifts the moment Twitch is disabled. One number cannot say that.\n\nShow a user `effective` when answering \"what am I streaming at\". `selected` is their intent and can legitimately be higher.",
        "properties": {
          "studioId": { "type": "string", "format": "uuid" },
          "selected": { "$ref": "#/components/schemas/ProgramOutput", "description": "What the operator picked and what is persisted. Returned unchanged even when a destination cap means it is not what airs." },
          "effective": { "$ref": "#/components/schemas/ProgramOutput", "description": "What the NEXT go-live will actually composite and encode at, after the destination soft-cap and the frame-rate ceiling." },
          "caps": { "$ref": "#/components/schemas/OutputCaps" },
          "codec": { "type": "string", "description": "The video codec the effective format encodes with, e.g. \"h264\". Derived, not settable." },
          "targetBitrateKbps": { "type": "integer", "description": "The encode bitrate the effective format resolves to off the ladder, BEFORE any per-destination ceiling is applied at go-live. Derived, not settable." },
          "appliesToNextGoLive": { "type": "boolean", "description": "Always true, and stated here rather than only in prose: resolution and frame rate are SET-BEFORE-LIVE. The encoder does not renegotiate format mid-session, so a studio on air keeps what it started with until it goes offline and live again." }
        }
      },
      "UpdateStudioOutputRequest": {
        "type": "object",
        "additionalProperties": false,
        "description": "A PATCH: send only what you are changing.\n\nThe pairs are pairs. width and height move together; fpsNum and fpsDen move together. Sending half of either is a 400 naming the field, because half a resolution is not a resolution and a numerator with no denominator is not a frame rate.\n\nThere is no decimal `fps` to write. 59.94 is 60000/1001, and accepting a float would mean guessing which rational you meant.",
        "properties": {
          "width": { "type": "integer", "description": "Program width. Must pair with height and land on the ladder exactly: 1280, 1920, 2560 or 3840." },
          "height": { "type": "integer", "description": "Program height: 720, 1080, 1440 or 2160." },
          "fpsNum": { "type": "integer", "description": "Frame-rate numerator. Must pair with fpsDen." },
          "fpsDen": { "type": "integer", "description": "Frame-rate denominator. Must pair with fpsNum and must be positive — a zero here was silently accepted before this endpoint existed, and became 60/1 at the next go-live while the caller was told \"saved\"." }
        }
      },
      "CanvasState": {
        "type": "object",
        "additionalProperties": false,
        "required": ["layoutMode", "activePreset", "canvasWidth", "canvasHeight", "aspectRatio", "fitMode", "screenPriority", "spotlightId", "focusSourceId", "pinnedOwnerId", "autoEmphasisEnabled", "tileOrder", "traySourceIds", "broughtInSourceIds", "slotAssignments", "output", "layoutRev", "updatedAt", "placements", "membershipBasis"],
        "description": "The whole canvas in one shape. It is the GET response and it is also what EVERY canvas write returns, so a client never has to guess what its change did — the answer to 'move this tile' is the new picture, not an acknowledgement.\n\nThe field names are the studio's own layout-row names, which makes this a superset of the first-party BFF's studio layout read joined with the source list. The one field not carried across is the raw `sourceLayouts` map; `placements` replaces it and carries strictly more.",
        "properties": {
          "layoutMode": { "type": "string", "enum": ["auto", "advanced"], "description": "Who owns geometry. 'auto': the server's planner resolves every rectangle from the active preset and overrides what you wrote. 'advanced': each tile stays exactly where you last put it." },
          "activePreset": { "type": "string", "description": "The active preset id, e.g. \"2up\". Meaningful in auto mode; retained but not applied in advanced mode." },
          "activeSceneId": { "type": "string", "description": "The active scene's id, absent when no scene is active." },
          "canvasWidth": { "type": "integer", "description": "Width of the COORDINATE SPACE the rectangles live in — 1920, fixed for every studio, and NOT the broadcast width. Scale a miniature by this; label the stream by `output`." },
          "canvasHeight": { "type": "integer", "description": "Height of the coordinate space. 1080." },
          "aspectRatio": { "type": "string", "description": "The studio's aspect-ratio intent, e.g. \"16/9\"." },
          "fitMode": { "type": "string", "enum": ["contain", "stretch"], "description": "Frame-level fit for sources with no per-tile override." },
          "screenPriority": { "type": "boolean", "description": "Whether a shared screen outranks cameras when the planner seats people." },
          "spotlightId": { "type": "string", "description": "The spotlit source id, EMPTY when none. Always present, never omitted, so a client mirroring the studio row does not have to tell absent from unset. Legacy sibling of focusSourceId, carried because the studio row carries it and a client mirroring the row would otherwise lose it on a round trip." },
          "focusSourceId": { "type": "string", "description": "The source promoted to the focus slot. Auto-mode intent." },
          "pinnedOwnerId": { "type": "string", "description": "The person pinned as the permanent hero for this session, by owner key. Only meaningful under the person-grid presets." },
          "autoEmphasisEnabled": { "type": "boolean", "description": "Whether the encoder may hero the loudest seat automatically this session." },
          "tileOrder": { "type": "array", "items": { "type": "string" }, "description": "Slot-order intent, by source id. Auto-mode only." },
          "traySourceIds": { "type": "array", "items": { "type": "string" }, "description": "The overflow tray: active sources the current preset had no slot for." },
          "broughtInSourceIds": { "type": "array", "items": { "type": "string" }, "description": "Extra legs pulled out of their owners' folded person-units so each has its own cell. Kept disjoint from traySourceIds." },
          "slotAssignments": {
            "type": "object",
            "description": "Sticky seating, per preset: preset id -> slot index (as a string) -> source id.\n\nALWAYS PRESENT, never null. An EMPTY object means 'no sticky seats are assigned on this studio' — a real state, not a failed read and not an unpopulated field. Sparse within itself: a preset or slot with no entry has no sticky seat and the planner picks for it.\n\nRead this before POST /canvas/preset, whose slotAssignments is a WHOLE-SET replacement: take this map, change the entries you mean, send the whole thing back.",
            "additionalProperties": { "type": "object", "additionalProperties": { "type": "string" } }
          },
          "canvasBackground": { "$ref": "#/components/schemas/CanvasBackground" },
          "verticalOverlay": { "$ref": "#/components/schemas/VerticalOverlay" },
          "output": { "$ref": "#/components/schemas/ProgramOutput" },
          "layoutRev": { "type": "integer", "format": "int64", "description": "The layout revision. Monotonic per studio, bumped by every canvas write. Hold it and send it back as `baseRev` to make your next write refuse to clobber someone else's." },
          "updatedAt": { "type": "string", "description": "When the layout last changed, RFC 3339." },
          "membershipBasis": {
            "type": "string",
            "enum": ["resolved", "stored", ""],
            "description": "How onCanvas/inTray on every placement were reached.\n\n'resolved' — the normal, authoritative answer: membership came from the auto planner, or (in advanced mode) from the stored placements the encoder is driven from. Trust it.\n\n'stored' — the auto planner could NOT resolve this activePreset. The encoder declares the preset catalog and ships new presets independently, so a studio can legitimately hold one this server build does not know. Membership then falls back to the persisted tray column and the stored rectangles, both of which may be stale. Render the canvas, but do not conclude from it that a source is off air.\n\n'' (empty) — an older server. It makes NO claim; do not read it as either value."
          },
          "placements": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/CanvasPlacement" },
            "description": "One row per source in the studio, seated or not. Ordered for painting: placed tiles first in draw order (z ascending, source id breaking ties), then the unplaced ones by id."
          }
        }
      },
      "UpdateCanvasRequest": {
        "type": "object",
        "additionalProperties": false,
        "description": "A PATCH on the canvas-wide settings: omitted fields are left unchanged.",
        "properties": {
          "layoutMode": { "type": "string", "enum": ["auto", "advanced"], "description": "Who owns geometry from now on. Anything else is a 400." },
          "aspectRatio": { "type": "string", "description": "New aspect-ratio intent, e.g. \"16/9\"." },
          "fitMode": { "type": "string", "enum": ["contain", "stretch"], "description": "New frame-level fit." },
          "screenPriority": { "type": "boolean", "description": "Whether a shared screen outranks cameras in auto seating." },
          "canvasBackground": { "$ref": "#/components/schemas/CanvasBackground" },
          "verticalOverlay": { "$ref": "#/components/schemas/VerticalOverlay" },
          "baseRev": { "type": "integer", "format": "int64", "description": "The layoutRev this edit was based on. Omit for last-write-wins. Send it and a write based on a revision the canvas has already passed is refused with 409 rather than applied." }
        }
      },
      "UpdateCanvasPlacementRequest": {
        "type": "object",
        "additionalProperties": false,
        "description": "A PATCH on ONE tile. EVERY field is optional and omitted means PRESERVE, which is what makes `{\"zIndex\": 3}` a legal re-stack from a client that never read the rectangle. A source with no stored placement has nothing to preserve, so its first write must carry a rect.",
        "properties": {
          "rect": { "$ref": "#/components/schemas/CanvasRect" },
          "zIndex": { "type": "integer", "minimum": -10000, "maximum": 10000, "description": "New stacking order. Higher draws in front." },
          "alpha": { "type": "number", "minimum": 0, "maximum": 1, "description": "New opacity. Outside 0.0-1.0 is a 400, not a clamp." },
          "visible": { "type": "boolean", "description": "Whether to draw the tile. False keeps the rectangle so the tile does not jump when shown again." },
          "fitMode": { "type": "string", "enum": ["contain", "stretch"], "description": "New per-source fit." },
          "cropZoom": { "type": "number", "minimum": 1, "maximum": 10, "description": "New punch-in factor. Send 1.0 to clear a crop." },
          "cropPanX": { "type": "number", "minimum": 0, "maximum": 1, "description": "New visible-rect centre X." },
          "cropPanY": { "type": "number", "minimum": 0, "maximum": 1, "description": "New visible-rect centre Y." },
          "baseRev": { "type": "integer", "format": "int64", "description": "The layoutRev this edit was based on. Omit for last-write-wins. A stale baseRev is a 409 — EXCEPT when the tile already holds exactly the values this request asks for, which is the retry case and comes back 200. That exception is what makes this call safe to retry over a connection that drops after the write lands." }
        }
      },
      "SetCanvasPresetRequest": {
        "type": "object",
        "additionalProperties": false,
        "description": "Select the auto-mode arrangement. Omitted fields are left alone; the array and map fields are WHOLE-SET replacements when present, because a partial list would silently drop the members it omitted.",
        "properties": {
          "presetId": { "type": "string", "minLength": 1, "description": "A preset id from the capabilities endpoint. Do not hardcode the catalog — it is declared by the encoder and it changes. Present but empty is a 400." },
          "layoutMode": { "type": "string", "enum": ["auto", "advanced"], "description": "Usually omitted. Picking a preset while advanced persists the intent without applying it." },
          "focusSourceId": { "type": "string", "description": "The source to promote to the focus slot. Send an empty string to clear the focus." },
          "traySourceIds": { "type": "array", "items": { "type": "string" }, "description": "The WHOLE overflow tray. Present (including empty) REPLACES the stored set." },
          "tileOrder": { "type": "array", "items": { "type": "string" }, "description": "The WHOLE slot order. Present (including empty) REPLACES the stored order." },
          "slotAssignments": {
            "type": "object",
            "description": "The WHOLE sticky-seat map: preset id -> slot index (as a string) -> source id.",
            "additionalProperties": { "type": "object", "additionalProperties": { "type": "string" } }
          }
        }
      },
      "ReplanCanvasRequest": {
        "type": "object",
        "additionalProperties": false,
        "description": "Hand the arrangement back to the planner.",
        "properties": {
          "layoutMode": { "type": "string", "enum": ["auto", "advanced"], "description": "Send \"auto\" to flip an advanced studio back to the planner and re-plan in one call — this is the way OUT of advanced mode. Omit to re-plan in place, which is a no-op on an advanced studio." }
        }
      },
      "CanvasPresetSlot": {
        "type": "object",
        "additionalProperties": false,
        "required": ["index", "x", "y", "w", "h", "rect"],
        "description": "One seat in a preset, given TWICE: normalized fractions of the canvas (0.0-1.0) for drawing a thumbnail at any size, and rect in canvas units for reasoning about the real canvas.",
        "properties": {
          "index": { "type": "integer", "description": "Slot index, 0-based. Slot 0 is the focus/hero seat where a preset has one." },
          "x": { "type": "number", "description": "Left edge as a fraction of canvas width." },
          "y": { "type": "number", "description": "Top edge as a fraction of canvas height." },
          "w": { "type": "number", "description": "Width as a fraction of canvas width." },
          "h": { "type": "number", "description": "Height as a fraction of canvas height." },
          "rect": { "$ref": "#/components/schemas/CanvasSlotRect" }
        }
      },
      "CanvasPreset": {
        "type": "object",
        "additionalProperties": false,
        "required": ["id", "displayName", "slotCount", "slots", "overflowToTray"],
        "description": "One arrangement the studio's encoder offers.",
        "properties": {
          "id": { "type": "string", "description": "The id to send as presetId. Show displayName, send this." },
          "displayName": { "type": "string", "description": "Human label, e.g. \"Two up\"." },
          "slotCount": { "type": "integer", "description": "How many sources this preset seats. Actives beyond it go to the tray." },
          "slots": { "type": "array", "items": { "$ref": "#/components/schemas/CanvasPresetSlot" }, "description": "The slot geometry, normalized." },
          "overflowToTray": { "type": "boolean", "description": "Whether sources beyond slotCount go to the tray rather than being dropped." },
          "schematicSvg": { "type": "string", "description": "An SVG schematic of the arrangement, ready to render as a picker thumbnail. May be empty." }
        }
      },
      "CanvasCapabilities": {
        "type": "object",
        "additionalProperties": false,
        "required": ["presets", "libraryVersion", "layoutModes", "fitModes", "transitions", "canvasWidth", "canvasHeight"],
        "description": "What this studio's canvas supports: the encoder-declared preset catalog plus the vocabularies a picker enumerates.\n\nAn EMPTY `presets` with an empty `libraryVersion` means the encoder could not be consulted — a degraded read to render (fall back to what you cached, or hide the picker), not an error to retry into. The vocabularies are never degraded; they are the gateway's own and are read from the same lists that ENFORCE them on a write.",
        "properties": {
          "presets": { "type": "array", "items": { "$ref": "#/components/schemas/CanvasPreset" }, "description": "The presets on offer, or empty when the encoder is unreachable." },
          "libraryVersion": { "type": "string", "description": "Catalog version. Cache on it; refetch when it changes. Empty means the preset half is degraded." },
          "layoutModes": { "type": "array", "items": { "type": "string" }, "description": "The layout modes a write accepts." },
          "fitModes": { "type": "array", "items": { "type": "string" }, "description": "The fit modes a tile or the frame may take." },
          "transitions": { "type": "array", "items": { "type": "string" }, "description": "The scene transition names the studio UI offers. ADVISORY, not a closed enum: a scene stores its transition as free text, so a scene made elsewhere may report a name this list does not contain. Render it as the picker's options, not as a validator." },
          "canvasWidth": { "type": "integer", "description": "Canvas coordinate-space width, repeated here so a client can size a picker thumbnail before it has read a canvas." },
          "canvasHeight": { "type": "integer", "description": "Canvas coordinate-space height." }
        }
      },
      "Scene": {
        "type": "object",
        "additionalProperties": false,
        "required": ["id", "name", "category", "isActive", "isFavorite", "transitionType", "transitionDuration", "hasComposition", "updatedAt"],
        "description": "One saved look: the canvas as it was when someone captured it. A scene is a SNAPSHOT, not a channel — it holds the source rectangles, the layout mode and the active-source set at capture time.",
        "properties": {
          "id": { "type": "string", "description": "The scene id — what the recall endpoint takes." },
          "name": { "type": "string", "description": "Display name." },
          "description": { "type": "string", "description": "Free-text description." },
          "category": { "type": "string", "description": "Grouping label, e.g. \"general\"." },
          "isActive": { "type": "boolean", "description": "Whether this is the scene currently on the program." },
          "isFavorite": { "type": "boolean", "description": "Whether the operator marked it a favourite." },
          "hotkey": { "type": "string", "description": "Keyboard shortcut the studio UI binds it to." },
          "transitionType": { "type": "string", "description": "How the program cuts to it: \"cut\", \"fade\", ... Free text — see the capabilities endpoint's `transitions` for the names the studio UI offers." },
          "transitionDuration": { "type": "number", "description": "Transition length in seconds. 0 for a cut." },
          "hasComposition": { "type": "boolean", "description": "FALSE means this scene is a label with no saved canvas behind it — recalling it changes nothing. Reported explicitly so a client can grey the row instead of offering a recall that silently does nothing." },
          "updatedAt": { "type": "string", "description": "When the scene last changed, RFC 3339." }
        }
      },
      "SceneList": {
        "type": "object",
        "additionalProperties": false,
        "required": ["data"],
        "description": "The studio's scenes, in the studio's own order. Not paginated — a studio's scene list is a handful of rows an operator made by hand.",
        "properties": {
          "data": { "type": "array", "items": { "$ref": "#/components/schemas/Scene" } }
        }
      },
      "SetActiveSceneRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": ["sceneId"],
        "description": "Recall one saved scene onto the program.",
        "properties": {
          "sceneId": { "type": "string", "minLength": 1, "description": "The scene to recall. A scene id from a studio you cannot see answers 404, identical to one that does not exist." }
        }
      },
      "Error": {
        "type": "object",
        "additionalProperties": false,
        "required": ["error"],
        "properties": {
          "error": {
            "type": "object",
            "additionalProperties": false,
            "required": ["code", "message", "requestId"],
            "properties": {
              "code": {
                "type": "string",
                "description": "Stable machine-readable error code.",
                "enum": [
                  "invalid_key",
                  "insufficient_scope",
                  "not_found",
                  "invalid_request",
                  "conflict",
                  "rate_limited",
                  "write_disabled",
                  "unavailable",
                  "bad_gateway",
                  "internal",
                  "subscription_required",
                  "input_limit_reached",
                  "storage_quota_exceeded",
                  "payment_required",
                  "interactive_auth_required",
                  "invalid_username",
                  "username_taken",
                  "username_reserved",
                  "username_cooldown",
                  "erasure_incomplete"
                ]
              },
              "message": { "type": "string" },
              "requestId": { "type": "string" },
              "requiredScope": {
                "type": "string",
                "description": "Present only on insufficient_scope: the scope the key must hold."
              }
            }
          }
        }
      },
      "Health": {
        "type": "object",
        "additionalProperties": false,
        "required": ["status"],
        "properties": {
          "status": { "type": "string", "example": "ok" },
          "writeEnabled": { "type": "boolean", "description": "Whether mutating (write) endpoints are currently enabled." }
        }
      },
      "Ok": {
        "type": "object",
        "additionalProperties": false,
        "required": ["ok"],
        "properties": { "ok": { "type": "boolean" } }
      },
      "Studio": {
        "type": "object",
        "additionalProperties": false,
        "required": ["id", "name", "role", "ownerSub"],
        "properties": {
          "id": { "type": "string" },
          "name": { "type": "string" },
          "description": { "type": "string" },
          "role": { "type": "string", "description": "owner | VIEW | EDIT | ADMIN" },
          "ownerSub": { "type": "string" },
          "createdAt": { "type": "string" },
          "lastOpenedAt": { "type": "string" }
        }
      },
      "Source": {
        "type": "object",
        "additionalProperties": false,
        "required": ["id", "studioId", "name", "type", "active"],
        "properties": {
          "id": { "type": "string" },
          "studioId": { "type": "string" },
          "name": { "type": "string" },
          "type": { "type": "string" },
          "active": { "type": "boolean" },
          "status": {
            "type": "string",
            "description": "Control-plane provisioning state (`OK`, `MISSING`, `RECREATING`, `INGEST_DOWN`). Says whether this input still has a backing ingest stream — NOT whether media is arriving. A perfectly provisioned input nobody is publishing to reads `OK`. For \"is anything arriving\", read `publishing`."
          },
          "createdAt": { "type": "string" },
          "publishing": {
            "type": "boolean",
            "nullable": true,
            "description": "Is a publisher attached to this input right now — media-plane truth, the same signal the studio UI's source LEDs use. NULL means the liveness tracker could not be reached on this request: UNKNOWN, not offline. A client polling \"am I live yet\" must treat null as \"ask again\"."
          },
          "publishedAt": {
            "type": "string",
            "description": "When the CURRENT publish began (RFC 3339). Absent when nothing is publishing or when the start time is unknown. Stable while the publisher stays connected — a new value means it dropped and reconnected."
          }
        }
      },
      "Destination": {
        "type": "object",
        "additionalProperties": false,
        "description": "One output leg on a studio.\n\nThe plaintext stream key is never returned; `streamKey` is a mask. Everything else the studio's own UI is shown is here, plus `connectionId`, which the UI does not get.",
        "required": ["id", "studioId", "name", "platform", "enabled", "vertical", "activeBroadcastId"],
        "properties": {
          "id": { "type": "string" },
          "studioId": { "type": "string" },
          "name": { "type": "string" },
          "platform": { "type": "string", "description": "youtube | twitch | kick | custom_rtmp | ... Not validated against a closed set: an unrecognised value is stored as given." },
          "enabled": { "type": "boolean", "description": "VIDEO ONLY. A disabled destination is retained but no video leg is opened for it. It does NOT govern chat: for an always-on platform (twitch, kick, trovo, linkedin) the chat connector attaches from the linked account whether this is true or false, and whether or not the studio is live. See requiresLiveBroadcast." },
          "status": { "type": "string", "description": "IDLE | CONNECTING | LIVE | ERROR | STALLED | STOPPING. Meaningful only while the studio is live; treat the value set as open and match defensively." },
          "vertical": { "type": "boolean" },
          "lastError": { "type": "string", "description": "Why the leg last failed, verbatim from whatever refused it. Read this whenever status is ERROR or STALLED: status alone cannot tell a revoked token from an unreachable ingest host." },
          "connectionId": { "type": "string", "description": "The linked account supplying this destination's credentials; absent for a pasted-credential row. This is the join key to GET /v1/connections — the only way to find out WHICH account to re-link when a leg stops resolving." },
          "rtmpUrl": { "type": "string", "description": "Set for a pasted-credential destination. ABSENT for a connection-backed one: that URL is resolved fresh from the linked account at each go-live and never stored, so there is nothing truthful to return between streams." },
          "streamKey": { "type": "string", "description": "MASKED: \"****\" plus the last four characters, or \"****\" for a key of four characters or fewer. Absent when no key is stored, which is every connection-backed destination. No surface returns the plaintext — losing it means rotating at the platform." },
          "encoderStreamId": { "type": "string", "description": "The encoder's id for this leg while a session runs. Correlation only; absent when the studio is not live." },
          "degraded": { "type": "boolean", "description": "True while the encoder is DROPPING frames toward this leg to protect the others: the stream is still live and the picture is degrading. Meaningful only during a live session." },
          "drainPct": { "type": "integer", "description": "How much of this leg's buffer is being shed, 0-100. On an idle destination this is 0, which means \"not applicable\", not \"measured healthy\"." },
          "requiresLiveBroadcast": { "type": "boolean", "description": "Whether this destination's PLATFORM can only attach chat once a broadcast is live (youtube, facebook, instagram, tiktok have no chat channel id before then). A property of the platform, not this row." },
          "activeBroadcastId": { "type": "string", "description": "The platform broadcast this destination is CURRENTLY operating on, or \"\" when there is none. THIS is the id to pass to PATCH /v1/studios/{studioId}/destinations/{destinationId}/broadcast and to the thumbnail endpoint — the supported way to obtain one; do not read it out of `settings`, which has no contract. A PICKED id is stable until the pick changes. An AUTO-CREATED one (create mode) names the broadcast minted for the current or most recent session and goes STALE once that broadcast completes, since the next go-live mints a fresh one — re-read it after going live rather than caching it across sessions. Always present; empty for a channel-scoped platform (twitch, kick, trovo, linkedin), a create-mode destination that has never gone live, or a cleared pick." },
          "settings": { "description": "Opaque per-destination settings, round-tripped unchanged. Its schema is not part of this contract." },
          "createdAt": { "type": "string" },
          "updatedAt": { "type": "string" }
        }
      },
      "LinkStart": {
        "type": "object",
        "additionalProperties": false,
        "description": "Where to send a browser to complete an OAuth link. Carries no credential.",
        "required": ["authorizationUrl", "state"],
        "properties": {
          "authorizationUrl": { "type": "string", "description": "Open this in a browser the user controls. Single-use, and it expires with the state below. Do NOT follow it server-side: the platform shows a consent screen, not a token." },
          "state": { "type": "string", "description": "The opaque CSRF/state token embedded in the URL, returned so a caller with several link attempts in flight can tell which one came back. Single-use." },
          "expiresAt": { "type": "string", "description": "When the URL stops working, RFC 3339. Ten minutes out today. After this, call the endpoint again." }
        }
      },
      "StorageConfig": {
        "type": "object",
        "additionalProperties": false,
        "description": "A customer-owned S3-compatible bucket TRaX can read and write.\n\nNEVER carries credentials. The access key id is echoed only as its last four characters, and the secret access key has no echo at all — there is no read path for it anywhere, and losing it means issuing a new pair at your provider.",
        "required": ["id", "name", "provider", "endpoint", "bucket", "forcePathStyle", "status"],
        "properties": {
          "id": { "type": "string" },
          "name": { "type": "string" },
          "provider": { "type": "string", "description": "s3 is the only value today; any S3-compatible endpoint (AWS, Backblaze B2, Cloudflare R2, MinIO, Garage) is reached through it." },
          "endpoint": { "type": "string" },
          "region": { "type": "string" },
          "bucket": { "type": "string" },
          "prefix": { "type": "string", "description": "The key prefix TRaX confines itself to. Absent or empty means the bucket root." },
          "forcePathStyle": { "type": "boolean", "description": "Address the bucket as a path segment rather than a subdomain. True for most non-AWS providers; defaults true on create when unset." },
          "accessKeyLast4": { "type": "string", "description": "The last four characters of the stored access key id, so you can tell two credentials apart. Never the whole key, never the secret." },
          "status": { "type": "string", "description": "unverified | ok | unreachable | invalid_creds | forbidden.\n\nEVENT-DRIVEN, never swept: written on create, update and verify, downgraded when a real operation fails, healed by the next success. An old \"ok\" means \"worked when last exercised\", not \"works now\"." },
          "statusDetail": { "type": "string", "description": "Human-readable detail behind status — usually your provider's own error text." },
          "lastVerifiedAt": { "type": "string" },
          "createdAt": { "type": "string" },
          "updatedAt": { "type": "string" }
        }
      },
      "LinkStartRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": ["platform"],
        "properties": {
          "platform": { "type": "string", "enum": ["twitch", "youtube", "kick", "facebook", "linkedin", "trovo"], "description": "Which platform to link. A platform TRaX has no OAuth application configured for is a 400, not an empty URL." },
          "scopes": { "type": "array", "items": { "type": "string" }, "description": "OAuth scopes to request. LEAVE THIS OUT to get the platform's default set, which is what TRaX's own features need — that is the right choice unless you know exactly why it is not. Requesting less links successfully and then silently fails at whichever feature needed the missing scope; check `grantedScopes` on the resulting connection to see what the platform actually gave." },
          "returnTo": { "type": "string", "description": "Where the browser lands after the platform redirects back. Must be a TRaX-owned https host. Omit for the default landing page." },
          "reconnectConnectionId": { "type": "string", "description": "RE-AUTH IN PLACE. Set this to an existing connection.s id to refresh its grant while KEEPING that id, instead of creating a second row. This is the repair for a connection whose status has gone needs_reauth or expired." }
        }
      },
      "StorageWrite": {
        "type": "object",
        "additionalProperties": false,
        "description": "Create/update/probe payload for a bring-your-own bucket. On PATCH every field is optional and an omitted field is left alone.\n\nCredentials travel ONE WAY: in. `accessKeyId` and `secretAccessKey` are write-only and are BOTH-OR-NEITHER on update — sending one without the other is a 400, because a half-rotated credential pair is never what anybody meant.",
        "properties": {
          "name": { "type": "string" },
          "endpoint": { "type": "string", "description": "Full S3 endpoint URL, e.g. https://s3.us-west-002.backblazeb2.com. A bare hostname is rejected." },
          "region": { "type": "string" },
          "bucket": { "type": "string" },
          "prefix": { "type": "string" },
          "forcePathStyle": { "type": "boolean", "description": "Defaults to true when omitted on create, which is right for most non-AWS providers." },
          "accessKeyId": { "type": "string", "description": "Write-only. Only its last four characters are ever readable again." },
          "secretAccessKey": { "type": "string", "description": "Write-only. Never returned by any surface." }
        }
      },
      "StorageConfigList": {
        "type": "object",
        "additionalProperties": false,
        "required": ["data"],
        "properties": {
          "data": { "type": "array", "items": { "$ref": "#/components/schemas/StorageConfig" } },
          "nextCursor": { "type": "string" }
        }
      },
      "StorageProbeResult": {
        "type": "object",
        "additionalProperties": false,
        "description": "The verdict of a non-persisting credential test.",
        "required": ["status"],
        "properties": {
          "status": { "type": "string", "description": "ok | unreachable | invalid_creds | forbidden. A non-ok verdict arrives as a SUCCESSFUL 200 — read this field, not the HTTP code." },
          "statusDetail": { "type": "string" }
        }
      },
      "StudioEventSnapshot": {
        "type": "object",
        "description": "Full current studio state, carried by the `snapshot` event on the /v1 events stream. Exactly what listSources + getStreamStatus + listDestinations return together, plus the chat connector states, so applying one replaces your whole model.",
        "required": ["sources", "streamStatus", "destinations", "chatConnectors", "canvas", "audio"],
        "properties": {
          "sources": { "type": "array", "items": { "$ref": "#/components/schemas/Source" } },
          "streamStatus": { "$ref": "#/components/schemas/StreamStatus" },
          "destinations": { "type": "array", "items": { "$ref": "#/components/schemas/Destination" } },
          "chatConnectors": { "type": "array", "items": { "$ref": "#/components/schemas/ChatConnector" }, "description": "Every linked platform's chat connector state. Empty when no chat platforms are linked — state, not an omission." },
          "canvas": { "$ref": "#/components/schemas/CanvasState", "description": "The studio.s canvas/scenes/presets state — the same shape GET /v1/studios/{id}/canvas returns." },
          "audio": { "$ref": "#/components/schemas/StudioAudio", "description": "The studio.s audio mixer — the same shape GET /v1/studios/{id}/audio returns." }
        }
      },
      "ChatConnector": {
        "type": "object",
        "description": "One linked platform's chat reconnect state — the desired-vs-actual pill the studio shows beside a linked platform. Identity is `platform`; there is at most one connector per platform per studio.",
        "required": ["platform", "desired", "requiresLiveBroadcast", "state"],
        "properties": {
          "platform": { "type": "string", "description": "The connector's identity: youtube, twitch, kick, ..." },
          "desired": { "type": "boolean", "description": "Whether the studio wants this platform's chat attached." },
          "requiresLiveBroadcast": { "type": "boolean", "description": "True for platforms (YouTube) whose chat cannot attach until the broadcast is live, so an off-air \"not connected\" reads as calm rather than broken." },
          "state": { "type": "string", "description": "The connector lifecycle state that drives the pill: connected, reconnecting, failed, idle, ..." }
        }
      },
      "RemovedResource": {
        "type": "object",
        "description": "The `data` of a `sourceRemoved` or `destinationRemoved` event: the id of the row to drop. It carries nothing else, because there is nothing else to say about a row that no longer exists. (A `chatConnectorRemoved` event carries `{ platform }` instead, keyed by the connector's own identity.)",
        "required": ["id"],
        "properties": { "id": { "type": "string" } }
      },
      "StreamStatus": {
        "type": "object",
        "additionalProperties": false,
        "required": ["live"],
        "properties": {
          "live": { "type": "boolean" },
          "encoderStreamId": { "type": "string" },
          "liveStartedAt": { "type": "string" },
          "broadcastStartedAt": { "type": "string" },
          "mode": { "type": "string", "description": "cloud | local" }
        }
      },
      "GoLiveResult": {
        "type": "object",
        "additionalProperties": false,
        "required": ["encoderStreamId", "destinationIds"],
        "properties": {
          "encoderStreamId": { "type": "string" },
          "destinationIds": { "type": "array", "items": { "type": "string" } }
        }
      },
      "GoOfflineResult": {
        "type": "object",
        "additionalProperties": false,
        "required": ["ok"],
        "properties": { "ok": { "type": "boolean" } }
      },
      "Connection": {
        "type": "object",
        "additionalProperties": false,
        "required": ["id", "platform", "displayName", "status", "connectionType"],
        "properties": {
          "id": { "type": "string" },
          "platform": { "type": "string" },
          "displayName": { "type": "string" },
          "externalUserId": { "type": "string" },
          "avatarUrl": { "type": "string" },
          "status": { "type": "string", "description": "active | needs_reauth | expired | revoked" },
          "connectionType": { "type": "string", "description": "oauth | manual" },
          "connectedAt": { "type": "string" },
          "updatedAt": { "type": "string" },
          "expiresAt": { "type": "string" },
          "grantedScopes": { "type": "array", "items": { "type": "string" } }
        }
      },
      "ChatMessage": {
        "type": "object",
        "additionalProperties": false,
        "required": ["id", "platform", "authorDisplayName", "text"],
        "properties": {
          "id": { "type": "string" },
          "platform": { "type": "string" },
          "authorDisplayName": { "type": "string" },
          "authorId": { "type": "string" },
          "text": { "type": "string" },
          "timestamp": { "type": "string" }
        }
      },
      "ChatSendResult": {
        "type": "object",
        "additionalProperties": false,
        "required": ["ok"],
        "properties": {
          "ok": { "type": "boolean" },
          "messageId": { "type": "string" }
        }
      },
      "MediaAsset": {
        "type": "object",
        "additionalProperties": false,
        "required": ["id", "filename", "kind", "byteSize", "status"],
        "properties": {
          "id": { "type": "string" },
          "filename": { "type": "string" },
          "kind": { "type": "string", "description": "video | image | audio" },
          "contentType": { "type": "string" },
          "byteSize": { "type": "integer", "format": "int64" },
          "status": { "type": "string", "description": "UPLOADING | READY | FAILED. Only READY can be attached to a studio." },
          "folder": { "type": "string" },
          "createdAt": { "type": "string" },
          "thumbnailUrl": { "type": "string", "description": "Short-lived presigned GET for a generated thumbnail. Absent for a non-READY asset and for one with no extractable thumbnail (audio, most images) — draw a kind icon, do not treat it as an error. It EXPIRES: fetch it at render time, never store it. A list row carries a thumbnail and no download URL; reading the file itself is `GET /v1/media/{assetId}`." },
          "durationSeconds": { "type": "number", "description": "Fractional seconds. 0 for a still image, and 0 when the file could not be probed — picker metadata, not a guarantee." },
          "width": { "type": "integer", "description": "Pixels. 0 when unknown or not applicable (audio)." },
          "height": { "type": "integer", "description": "Pixels. 0 when unknown or not applicable (audio)." }
        }
      },
      "MediaUploadPart": {
        "type": "object",
        "additionalProperties": false,
        "required": ["partNumber", "offset", "byteSize", "url"],
        "properties": {
          "partNumber": { "type": "integer", "description": "1-based part index. Send it back on `/complete` paired with the ETag the storage host returned for this part." },
          "offset": { "type": "integer", "format": "int64", "description": "Byte offset into your local file where this part starts. Do NOT re-derive it from a part size of your own — the server may have grown the part size to keep the plan under the storage layer's part-count ceiling, so this plan is the only correct description of how the file is cut." },
          "byteSize": { "type": "integer", "format": "int64", "description": "How many bytes to send for this part. Equal to `partBytes` for every part but the last." },
          "url": { "type": "string", "description": "Presigned PUT for this part. SECRET — the URL itself grants write access, so keep it out of logs. Short-lived; an expired plan needs a new `POST /v1/media`, not a retry." }
        }
      },
      "MediaUpload": {
        "type": "object",
        "additionalProperties": false,
        "required": ["assetId", "objectKey", "kind", "mode"],
        "properties": {
          "assetId": { "type": "string", "description": "The reserved asset. It exists immediately in status UPLOADING and counts against quota from this moment — if you give up, call `/abort`." },
          "mode": { "type": "string", "description": "`single` or `multipart`. BRANCH ON THIS. The two shapes are mutually exclusive: `single` carries `uploadUrl` and no parts, `multipart` carries `parts` and no `uploadUrl`. The split point is server policy and it moves, so a client that reads `mode` keeps working when it does — one that only reads `uploadUrl` uploads nothing the day its user picks a big file." },
          "uploadUrl": { "type": "string", "description": "Single-file path only: a short-lived presigned PUT. Send the raw bytes with HTTP PUT and a Content-Type matching what you declared. SECRET — never log it." },
          "partBytes": { "type": "integer", "format": "int64", "description": "Multipart path only: the size of every part but the last. Informational — cut the file by each part's own `offset`/`byteSize`." },
          "parts": { "type": "array", "items": { "$ref": "#/components/schemas/MediaUploadPart" }, "description": "Multipart path only: one presigned PUT per part, in order. PUT each part's slice to its URL, keep the `ETag` response header each one returns, then call `/complete` with the full list. Parts may go up in any order and a few at a time; completeness is what matters, not order." },
          "objectKey": { "type": "string", "description": "Where the bytes land in storage. Informational — you do not need it." },
          "kind": { "type": "string", "description": "video | image | audio, derived from the contentType you declared. Returned so you can branch before the upload finishes." }
        }
      },
      "MediaAssetDetail": {
        "type": "object",
        "additionalProperties": false,
        "required": ["assetId", "kind", "filename", "status", "sizeBytes", "durationSeconds", "width", "height"],
        "properties": {
          "assetId": { "type": "string" },
          "kind": { "type": "string", "description": "video | image | audio" },
          "filename": { "type": "string" },
          "folder": { "type": "string" },
          "status": { "type": "string", "description": "UPLOADING | READY | FAILED. Poll until READY after completing an upload; only a READY asset carries URLs and only a READY asset can back a source." },
          "contentType": { "type": "string" },
          "sizeBytes": { "type": "integer", "format": "int64", "description": "The size MEASURED in storage once the upload completed — not the size you declared." },
          "durationSeconds": { "type": "number", "description": "Fractional seconds. 0 for a still image and 0 when the file could not be probed." },
          "width": { "type": "integer" },
          "height": { "type": "integer" },
          "thumbnailUrl": { "type": "string", "description": "Short-lived presigned GET for the generated thumbnail. Absent unless READY, and absent when none could be extracted." },
          "downloadUrl": { "type": "string", "description": "Short-lived presigned GET for the original file, signed for the public storage host so it resolves from a phone. Absent unless READY. It EXPIRES — fetch it when you render, never persist it. You do NOT need it to put the file on a canvas: pass `mediaAssetId` to `POST /v1/studios/{id}/sources` and the server resolves its own encoder-facing URL." },
          "errorMessage": { "type": "string", "description": "Why the asset is FAILED, when it is." },
          "createdAt": { "type": "string" },
          "updatedAt": { "type": "string", "description": "Moves when the upload completes — the field to watch while polling." }
        }
      },
      "CompletedMediaPart": {
        "type": "object",
        "additionalProperties": false,
        "required": ["partNumber"],
        "properties": {
          "partNumber": { "type": "integer", "description": "The partNumber from the plan." },
          "etag": { "type": "string", "description": "The `ETag` response header that part's PUT returned, verbatim — quotes included, the server normalises. Browsers only see this header when the storage host exposes it via CORS; if you cannot read it, send the whole list with empty etags (or send no list at all) and the server recovers them from storage itself." }
        }
      },
      "CompleteMediaUploadRequest": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "parts": { "type": "array", "items": { "$ref": "#/components/schemas/CompletedMediaPart" }, "description": "The finished parts. Omit the body entirely for a single-file upload. Order does not matter — the server sorts, because parts finish out of order when several are in flight — but COMPLETENESS does: a partial list is an error, not a partial completion." }
        }
      },
      "StudioList": {
        "type": "object",
        "additionalProperties": false,
        "required": ["data"],
        "properties": {
          "data": { "type": "array", "items": { "$ref": "#/components/schemas/Studio" } },
          "nextCursor": { "type": "string" }
        }
      },
      "SourceList": {
        "type": "object",
        "additionalProperties": false,
        "required": ["data"],
        "properties": {
          "data": { "type": "array", "items": { "$ref": "#/components/schemas/Source" } },
          "nextCursor": { "type": "string" }
        }
      },
      "DestinationList": {
        "type": "object",
        "additionalProperties": false,
        "required": ["data"],
        "properties": {
          "data": { "type": "array", "items": { "$ref": "#/components/schemas/Destination" } },
          "nextCursor": { "type": "string" }
        }
      },
      "ConnectionList": {
        "type": "object",
        "additionalProperties": false,
        "required": ["data"],
        "properties": {
          "data": { "type": "array", "items": { "$ref": "#/components/schemas/Connection" } },
          "nextCursor": { "type": "string" }
        }
      },
      "ChatMessageList": {
        "type": "object",
        "additionalProperties": false,
        "required": ["data"],
        "properties": {
          "data": { "type": "array", "items": { "$ref": "#/components/schemas/ChatMessage" } },
          "nextCursor": { "type": "string" }
        }
      },
      "MediaAssetList": {
        "type": "object",
        "additionalProperties": false,
        "required": ["data", "quotaUsedBytes", "quotaLimitBytes"],
        "properties": {
          "data": { "type": "array", "items": { "$ref": "#/components/schemas/MediaAsset" } },
          "nextCursor": { "type": "string" },
          "quotaUsedBytes": { "type": "integer", "format": "int64", "description": "Bytes the library currently holds. Rides on the list rather than a separate endpoint so you can render usage, and warn a user their library is full, BEFORE they pick a file." },
          "quotaLimitBytes": { "type": "integer", "format": "int64", "description": "The account's storage ceiling in bytes. An upload that would cross it is refused at `POST /v1/media` with 402 storage_quota_exceeded." }
        }
      },
      "CreateStudioRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": ["name"],
        "properties": { "name": { "type": "string" } }
      },
      "UpdateStudioRequest": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "name": { "type": "string" },
          "autoGoLive": { "type": "boolean" },
          "autoGoOfflineOnDrop": { "type": "boolean" }
        }
      },
      "CreateSourceRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": ["name", "type"],
        "properties": {
          "name": { "type": "string" },
          "type": { "type": "string", "description": "webcam | rtmp | srt | webrtc-input | media | ... The library-backed kinds — `video-file`, `image`, `audio-file`, `media` — are the ones `mediaAssetId` applies to." },
          "url": { "type": "string", "description": "Source URL for URL-backed kinds. Mutually exclusive with `mediaAssetId`. Ignored for kinds where TRaX provisions the ingest itself (webcam, webrtc-input, generic-input) — for those leave it empty and read the endpoints back from `POST .../ingest`." },
          "active": { "type": "boolean" },
          "mediaAssetId": { "type": "string", "description": "Attach a media-library asset as this source, instead of a URL. Pass an id from `GET /v1/media`; it must be READY and yours, and the type must be a library-backed kind.\n\nThe server resolves it to a storage URL the ENCODER can reach, which is a different signature from the `downloadUrl` a phone gets — and it re-mints that URL whenever it expires. That is why an asset-backed source keeps working and a pasted presigned URL rots.\n\nMutually exclusive with `url`: sending both is a 400, not a precedence rule you have to remember." }
        }
      },
      "UpdateSourceRequest": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "name": { "type": "string" },
          "active": { "type": "boolean" },
          "url": { "type": "string" }
        }
      },
      "CreateDestinationRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": ["name", "platform"],
        "properties": {
          "name": { "type": "string" },
          "platform": { "type": "string" },
          "connectionId": { "type": "string", "description": "Bind to a linked account; mutually exclusive with rtmpUrl+streamKey." },
          "rtmpUrl": { "type": "string" },
          "streamKey": { "type": "string", "description": "Write-only; never returned on any read." },
          "enabled": { "type": "boolean" },
          "vertical": { "type": "boolean" },
          "broadcastConfig": { "$ref": "#/components/schemas/BroadcastConfig" }
        }
      },
      "UpdateDestinationRequest": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "name": { "type": "string" },
          "platform": { "type": "string" },
          "rtmpUrl": { "type": "string" },
          "streamKey": { "type": "string", "description": "Write-only; never returned on any read." },
          "enabled": { "type": "boolean" },
          "vertical": { "type": "boolean" },
          "broadcastConfig": { "$ref": "#/components/schemas/BroadcastConfig" }
        }
      },
      "BroadcastConfig": {
        "type": "object",
        "additionalProperties": false,
        "description": "YouTube auto-create-broadcast template for a destination. Ignored by non-YouTube platforms. enableEmbed/recordFromStart/enableMonitorStream are tri-state: omit to leave unset so YouTube applies its own default (omitting enableMonitorStream also preserves the legacy auto-start monitor derivation). All broadcastConfig fields are immutable on YouTube once the broadcast enters testing/live. There is no members-only privacy — YouTube's live API does not expose it.",
        "properties": {
          "mode": { "type": "string", "enum": ["create", "pick"], "description": "create = auto-create a fresh broadcast at go-live from this template; pick = the operator chose a specific existing broadcast." },
          "title": { "type": "string", "description": "Title stamped on the auto-created broadcast." },
          "privacy": { "type": "string", "enum": ["public", "unlisted", "private"], "description": "Privacy of the auto-created broadcast." },
          "description": { "type": "string" },
          "latency": { "type": "string", "enum": ["normal", "low", "ultraLow"], "description": "ultraLow disables closed captions and caps resolution at 1080p." },
          "autoStart": { "type": "boolean", "description": "enableAutoStart — the broadcast goes live automatically once ingest begins." },
          "autoStop": { "type": "boolean", "description": "enableAutoStop — the broadcast stops automatically when ingest ends." },
          "dvr": { "type": "boolean", "description": "enableDvr — viewers can rewind the live stream." },
          "madeForKids": { "type": "boolean", "description": "status.selfDeclaredMadeForKids (COPPA)." },
          "enableEmbed": { "type": "boolean", "description": "contentDetails.enableEmbed. Tri-state: omit to leave unset." },
          "recordFromStart": { "type": "boolean", "description": "contentDetails.recordFromStart. Tri-state: omit to leave unset." },
          "enableMonitorStream": { "type": "boolean", "description": "contentDetails.monitorStream.enableMonitorStream. Tri-state: omit to preserve the legacy auto-start derivation; set true under autoStart to park the broadcast in testing." },
          "streamDelayMs": { "type": "integer", "format": "int32", "description": "contentDetails.broadcastStreamDelayMs. 0 = no delay." },
          "projection": { "type": "string", "enum": ["rectangular", "360"], "description": "contentDetails.projection. Empty omits the field." }
        }
      },
      "SendChatMessageRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": ["text"],
        "properties": {
          "text": { "type": "string" },
          "platforms": { "type": "array", "items": { "type": "string" }, "description": "Subset to fan out to; empty = every enabled platform." }
        }
      },
      "CreateMediaUploadRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": ["filename", "contentType", "byteSize"],
        "properties": {
          "filename": { "type": "string" },
          "contentType": { "type": "string" },
          "byteSize": { "type": "integer", "format": "int64" },
          "folder": { "type": "string" }
        }
      },
      "MintViewerTokenRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": ["studioId", "caps"],
        "properties": {
          "studioId": {
            "type": "string",
            "description": "The studio the viewer will reach. The token is scoped to exactly this studio and is refused if presented against another."
          },
          "memberRef": {
            "type": "string",
            "maxLength": 128,
            "description": "YOUR opaque identifier for the person, e.g. their user id in your system. Never resolved to anyone by TRaX; used only as a rate-limit and revocation key. Optional for read-only capabilities; REQUIRED whenever caps contains chat.send."
          },
          "displayName": {
            "type": "string",
            "maxLength": 32,
            "description": "The sender name PUBLISHED with a relayed chat message. REQUIRED whenever caps contains chat.send. A relayed message goes out on the BROADCASTER's connected platform accounts, so without a name it reads as the broadcaster speaking. Unlike memberRef this value is PUBLIC by construction — send a display name, never an email or an internal id. Sanitized and truncated to 32 characters."

          },

          "caps": {
            "type": "array",
            "minItems": 1,
            "items": { "type": "string", "enum": ["chat.read", "chat.send", "video.play"] },
            "description": "Requested capabilities. An unrecognised value is rejected rather than ignored — a silently dropped capability is a viewer with quietly wrong access."
          },
          "ttlSeconds": {
            "type": "integer",
            "description": "Requested lifetime. Omit or use 0 for the 900s default; anything above 3600 is clamped rather than refused. Read the real expiry off the response."
          }
        }
      },
      "ViewerToken": {
        "type": "object",
        "required": ["token", "expiresAt", "caps"],
        "properties": {
          "token": {
            "type": "string",
            "description": "The credential. Safe to hand to a browser, unlike the API key that minted it."
          },
          "expiresAt": {
            "type": "string",
            "format": "date-time",
            "description": "Absolute expiry. Reflects the CLAMPED lifetime, which may be shorter than requested — schedule the next mint from this."
          },
          "caps": {
            "type": "array",
            "items": { "type": "string" },
            "description": "The capabilities actually GRANTED, which may be narrower than requested. Render your interface from these."
          }
        }
      },
      "SourceIngestRequest": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "protocols": {
            "type": "array",
            "items": { "type": "string", "enum": ["srt", "rtmp", "rtmps", "webrtc", "rtsp", "hls"] },
            "description": "The protocols you want endpoints for. Omit the body entirely, or send an empty array, for the `[\"srt\"]` default. Ask for exactly what your client will dial: every extra entry is another copy of a live publish credential in your app's memory, logs and crash reports. An unrecognised NAME is a 400; a recognised protocol this source cannot publish over is simply absent from `granted` (see the response)."
          }
        }
      },
      "SourceIngestRotateRequest": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "protocols": {
            "type": "array",
            "items": { "type": "string", "enum": ["srt", "rtmp", "rtmps", "webrtc", "rtsp", "hls"] },
            "description": "The protocols you want endpoints for AFTER rotating. Same semantics as the mint endpoint: omit the body or send an empty array for the `[\"srt\"]` default, and read `granted` off the response rather than assuming you got what you asked for. This scopes what comes BACK, not what is invalidated — rotation replaces the single underlying stream key, so asking only for `srt` still kills the old `rtmp` URL. An unrecognised NAME is a 400 and nothing is rotated."
          },
          "kickPublishers": {
            "type": "boolean",
            "default": false,
            "description": "Cut whatever is publishing to this input right now, immediately after the new credential is saved. The media server authorises a publish once, at handshake, and never re-checks — so a session that connected with the OLD key keeps broadcasting through the rotation until its socket drops. Send `true` when you are revoking a leaked credential and mean it. Leave it `false` (the default) for a planned hand-over: the broadcast in progress finishes on the old key while every NEW connection requires the new one."
          }
        }
      },
      "IngestEndpoint": {
        "type": "object",
        "additionalProperties": false,
        "required": ["complete"],
        "properties": {
          "complete": {
            "type": "string",
            "description": "The URL to hand your encoder, credential included. For SRT that is the whole `srt://host:port?streamid=publish:<path>:<key>:<key>&latency=...` string — the stream key appears TWICE, as the streamid's user and pass fields. For RTMP/RTMPS the credential rides as `?user=<key>&pass=<key>` query parameters, not as URL userinfo. This IS the secret. Store it in the platform keychain, never in a log line or an analytics event.\n\nDial it VERBATIM. Do not reassemble it from parts: the credential form is the server's to decide and it has changed before, and a client that rebuilds the URL from a remembered shape keeps sending the old one long after the server moved on."
          },
          "base": {
            "type": "string",
            "description": "The same endpoint with the credential stripped, e.g. `srt://ingest.traxstreaming.live:8890`. INFORMATIONAL ONLY — it does not authenticate, and `base` + `streamKey` is not a working substitute for `complete`. An RTMP publish is authenticated by the `?user=`/`?pass=` query parameters that only `complete` carries, so an encoder configured with `base` as the server and `streamKey` as the key is rejected at the handshake. Use it to SHOW an operator where a contributor publishes without showing what lets them; dial `complete`."
          }
        }
      },
      "SourceIngest": {
        "type": "object",
        "additionalProperties": false,
        "required": ["streamName", "ingestUrls", "granted", "streamKey"],
        "properties": {
          "streamName": {
            "type": "string",
            "description": "The media path this source publishes to, e.g. `inputs/generic-input-<uuid>`. Stable for the life of the source and the one field here that is safe to log — use it to correlate a device's session with stream status and metrics."
          },
          "ingestUrls": {
            "type": "object",
            "additionalProperties": { "$ref": "#/components/schemas/IngestEndpoint" },
            "description": "Endpoints keyed by protocol. Contains exactly the keys listed in `granted` — never a protocol you asked for but cannot publish over."
          },
          "granted": {
            "type": "array",
            "items": { "type": "string" },
            "description": "The protocols actually GRANTED, which may be narrower than requested and may be EMPTY. Configure your client from this list, never from what you asked for."
          },
          "streamKey": {
            "type": "string",
            "minLength": 1,
            "description": "The input's stream key. ALWAYS PRESENT — on a mint and on a rotate alike. It is the SAME credential that is already embedded inside every `complete` URL in this response: for SRT it is the streamid's user and pass fields, for RTMP/RTMPS the `?user=` and `?pass=` query parameters. Repeating it here therefore adds no exposure, and it exists for encoders whose UI asks for a server and a stream key as two separate fields.\n\nIt is still a live publish credential. Store it in the platform keychain alongside `complete`, never in a log line. Note that `base` + `streamKey` is NOT a working substitute for `complete` — dial `complete` verbatim."
          }
        }
      },
      "ContributeRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": ["deviceId"],
        "properties": {
          "deviceId": {
            "type": "string",
            "minLength": 1,
            "description": "Your app's own stable identifier for THIS DEVICE. Mint it once, keep it in the Keychain / Keystore, and send the same value forever.\n\nIt is the IDEMPOTENCY KEY for this endpoint. The same value always resolves to the same input on the same studio, which is what makes a reconnection land back on the same camera tile with the same credential instead of littering the studio with half-made inputs. A value your app regenerates each launch produces a new input each launch, and the operator watches their source list fill up with duplicates of one phone.\n\nOpaque to TRaX and never resolved to anyone: send an identifier for the DEVICE, not for the person holding it."
          },
          "deviceLabel": {
            "type": "string",
            "description": "The human name for this input — \"EJ's iPhone\". Optional, and refreshed on every call, so renaming the phone renames the input the operator sees. Send nothing and the input keeps whatever it is already called."
          },
          "protocols": {
            "type": "array",
            "items": { "type": "string" },
            "description": "Which publish protocols to hand back. Omit it (or send `[]`) for `[\"srt\"]`, which is what the TRaX phone app dials and the protocol whose URL carries a real per-input credential.\n\nA REQUEST, not a guarantee — read `granted` off the response. Ask for exactly what you will dial: every extra entry is another copy of the same live publish secret sitting in your app's memory, logs and crash reports."
          }
        }
      },
      "ReleaseContributeRequest": {
        "type": "object",
        "additionalProperties": false,
        "description": "Body of DELETE /v1/studios/{id}/contribute. Optional — an absent body releases nothing (a no-op success).",
        "properties": {
          "deviceId": {
            "type": "string",
            "description": "The SAME stable device identifier passed to POST .../contribute. It resolves to the input to release. An empty or unknown value is a no-op success (idempotent teardown); a value that belongs to another user is 403."
          }
        }
      },
      "Contribution": {
        "type": "object",
        "additionalProperties": false,
        "required": ["sourceId", "streamName", "deviceId", "autoSeat", "ingest", "granted", "streamKey", "ingestMetro"],
        "properties": {
          "sourceId": {
            "type": "string",
            "description": "The input this device is bound to. Stable for the life of the device on this studio — store it, and use it for the ordinary source calls: rename with `PATCH /v1/studios/{id}/sources/{sourceId}`, rotate the credential with `POST .../ingest/rotate`, remove the input with `DELETE`."
          },
          "streamName": {
            "type": "string",
            "description": "The media path you are publishing to, e.g. `inputs/mobile-contrib-mc-<hex>`. The one field in this response that is safe to log — use it to correlate this device's session with `GET /v1/studios/{id}/stream-status`."
          },
          "deviceId": {
            "type": "string",
            "description": "Your `deviceId`, echoed back, so an app provisioning several devices can match a reply to a request without relying on ordering."
          },
          "autoSeat": {
            "type": "boolean",
            "description": "Whether this input takes a seat on the canvas by itself when the studio is ALREADY live. It NEVER starts the broadcast — going live is the operator's call and nothing on this path can make it happen. Read it to tell your user what will happen when they hit publish: a seat that appears on air, or a source the operator has to place."
          },
          "ingest": {
            "type": "object",
            "additionalProperties": { "$ref": "#/components/schemas/IngestEndpoint" },
            "description": "Publish endpoints keyed by protocol. Contains exactly the keys listed in `granted`. Same shape `POST .../sources/{sourceId}/ingest` returns, so if you already configure an encoder from a mint result you need no second code path here."
          },
          "granted": {
            "type": "array",
            "items": { "type": "string" },
            "description": "The protocols actually GRANTED, which may be narrower than requested and may be empty. Configure from this list, never from what you asked for."
          },
          "streamKey": {
            "type": "string",
            "minLength": 1,
            "description": "The input's stream key, on its own, for encoders whose UI asks for a server and a stream key as two separate fields. Already embedded in every `complete` URL in this response, so it adds no exposure — and it is still a live publish credential. Keychain, not log line."
          },
          "srtStreamId": {
            "type": "string",
            "description": "The raw SRT streamid to dial with — `publish:<path>:<key>:<key>`, NOT url-encoded, because libsrt wants literal colons as delimiters. It carries no JWT: the scoped key is the whole capability, which is what keeps it comfortably under SRT's 512-byte streamid cap.\n\nPresent only when `srt` is in `granted`. Prefer the `complete` URL; this exists for encoder SDKs that want the streamid as its own socket option."
          },
          "ingestHost": {
            "type": "string",
            "description": "The hostname your credential points at, lifted out of the URLs so you can show \"publishing to …\" without parsing — or displaying — a string that is also a secret. Display only: dial the URLs."
          },
          "ingestMetro": {
            "type": "string",
            "description": "Which metro this device is being steered to, or `\"auto\"`.\n\nToday it is always `\"auto\"`, and that is a measurement rather than a placeholder: nearest-point-of-presence selection happens in DNS, when your device RESOLVES the ingest hostname, not when this call mints the URL. The server genuinely does not know which PoP you will reach, and inferring one from your IP address would be wrong for every VPN, every carrier-grade NAT, and most mobile subscribers — which is exactly the population this endpoint exists for.\n\nDISPLAY it; do not branch on it. A later release may name a real metro here, and a client that special-cased the string would break on the improvement."
          }
        }
      },
      "MonitorRequest": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "ttlSeconds": {
            "type": "integer",
            "description": "Requested lifetime for the credential. Omit it (or send 0 or less) for the 300-second default; anything above the 900-second ceiling is CLAMPED, not refused. Read the real expiry off `expiresAt` — never schedule from the value you asked for."
          }
        }
      },
      "Monitor": {
        "type": "object",
        "additionalProperties": false,
        "required": ["whep", "srt", "streamName", "expiresAt"],
        "properties": {
          "whep": { "$ref": "#/components/schemas/MonitorWhep" },
          "srt": {
            "type": "object",
            "nullable": true,
            "additionalProperties": false,
            "required": ["url"],
            "properties": {
              "url": {
                "type": "string",
                "description": "A ready-to-dial SRT read URL with the credential inside the streamid. There is deliberately no separate token field: for SRT the URL IS the credential, and re-assembling one by hand is how the 512-byte streamid cap gets exceeded."
              }
            },
            "description": "An SRT alternative for a client that cannot speak WHEP, or `null` when one cannot be built.\n\n`null` is a real answer, not a missing field: the read credential rides INSIDE SRT's streamid, which libsrt caps at 512 bytes and rejects locally — before anything reaches the network — when exceeded. When the composed URL would be over the cap, or no SRT endpoint is configured, the server returns null rather than a URL that cannot dial. Handle it by falling back to WHEP, which is the path you should be on anyway."
          },
          "streamName": {
            "type": "string",
            "description": "The program path being monitored, e.g. `s/<studio-id>/pp`. Safe to log."
          },
          "expiresAt": {
            "type": "string",
            "format": "date-time",
            "description": "RFC 3339. Reflects the CLAMPED lifetime, which may be shorter than you asked for.\n\nIt bounds when a NEW connection may be made. A session that is ALREADY playing outlives it: the media server authorises a read once, at session establishment, and never re-checks — so your picture does not go black when this passes. Mint again to (re)connect, not to keep watching."
          }
        }
      },
      "MonitorWhep": {
        "type": "object",
        "additionalProperties": false,
        "required": ["url", "token"],
        "properties": {
          "url": {
            "type": "string",
            "description": "POST your SDP offer here. This is the path every client should take — WebRTC degrades on a lossy cellular uplink far better than the alternative."
          },
          "token": {
            "type": "string",
            "description": "Send as `Authorization: Bearer <token>` on the WHEP signaling request. It is NOT your API key and NOT the user's login token: it authorises exactly this studio's program path, for a few minutes, and nothing else. That is what makes it safe to put on a signaling request your API key has no business appearing on."
          }
        }
      }
    },
    "parameters": {
      "StudioId": {
        "name": "id",
        "in": "path",
        "required": true,
        "schema": { "type": "string" },
        "description": "Studio id."
      },
      "SourceId": {
        "name": "sourceId",
        "in": "path",
        "required": true,
        "schema": { "type": "string" },
        "description": "Source id."
      },
      "DestId": {
        "name": "destId",
        "in": "path",
        "required": true,
        "schema": { "type": "string" },
        "description": "Destination id."
      },
      "ConnectionId": {
        "name": "connectionId",
        "in": "path",
        "required": true,
        "schema": { "type": "string" },
        "description": "Connection id."
      },
      "ConfigId": {
        "name": "configId",
        "in": "path",
        "required": true,
        "schema": { "type": "string" },
        "description": "Storage config id."
      },
      "AssetId": {
        "name": "assetId",
        "in": "path",
        "required": true,
        "schema": { "type": "string" },
        "description": "Media asset id, as returned by `POST /v1/media` or `GET /v1/media`."
      },
      "Cursor": {
        "name": "cursor",
        "in": "query",
        "required": false,
        "schema": { "type": "string" },
        "description": "Opaque pagination cursor from a prior response's nextCursor."
      },
      "Limit": {
        "name": "limit",
        "in": "query",
        "required": false,
        "schema": { "type": "integer", "minimum": 1, "maximum": 200, "default": 50 },
        "description": "Max items per page (default 50, max 200)."
      }
    },
    "responses": {
      "Error": {
        "description": "Error envelope.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      }
    }
  },
  "security": [ { "ApiKeyBearer": [] }, { "UserJWT": [] } ],
  "paths": {
    "/v1/account": {
      "get": {
        "tags": ["account"],
        "operationId": "getAccount",
        "summary": "Read your account profile.",
        "description": "Requires scope `account:read` for API-key callers (opt-in, not in the default key scopes); a signed-in user JWT always passes. Returns the @username identity — a user who has not claimed a handle gets `usernameSet: false`, not an error. Display name and email are deliberately NOT here: they are OIDC claims on the IdP token a signed-in client already holds.",
        "responses": {
          "200": {
            "description": "The caller's account profile.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Account" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      },
      "patch": {
        "tags": ["account"],
        "operationId": "updateAccount",
        "summary": "Update your account profile (claim or change your @username).",
        "description": "Requires scope `account:write` for API-key callers (opt-in); a signed-in user JWT always passes. Gated behind PUBLIC_API_WRITE_ENABLED.\n\nRuns the EXACT validation the TRaX account app uses: same format rules, same reserved blocklist, race-safe uniqueness, and a once-per-cooldown change limit (re-claiming your current handle is an idempotent no-op that does not burn the cooldown). Refusals are typed: 422 `invalid_username` (message carries the format reason), 409 `username_taken` / `username_reserved`, 429 `username_cooldown` with a Retry-After header.",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpdateAccountRequest" } } }
        },
        "responses": {
          "200": {
            "description": "The updated profile.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Account" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      },
      "delete": {
        "tags": ["account"],
        "operationId": "deleteAccount",
        "summary": "Delete your account. IMMEDIATE and IRREVERSIBLE.",
        "description": "JWT-BEARER-ONLY: this requires an interactive sign-in (OIDC JWT). An API key — regardless of its scopes — is refused with 403 `interactive_auth_required`, because a key must never be able to erase the account that owns it. Gated behind PUBLIC_API_WRITE_ENABLED.\n\nTHIS EXECUTES THE ERASURE OF THE AUTHENTICATED ACCOUNT, IMMEDIATELY. It is the same legally recorded flow as 'Delete my account' in the TRaX account app: a row is opened in the append-only deletion record first, then all account data is erased and the identity deleted last. There is no undo, no grace period, and no queued 'request' — present your own confirmation UI before calling.\n\nLegally idempotent: repeating the call returns the ORIGINAL record's id with `alreadyDeleted: true` rather than writing a second erasure record. No confirmation email is sent on this path (the API hop carries no email address) — show the user `requestId`, their receipt reference. 503 `unavailable` means deletion is not armed on this deployment and nothing was deleted; 500 `erasure_incomplete` means the account still exists and the call should be retried.\n\nThe request body is optional: `{ \"reason\": \"...\" }` records why on the legal record.",
        "requestBody": {
          "required": false,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AccountDeletionRequestBody" } } }
        },
        "responses": {
          "200": {
            "description": "The erasure acknowledgment — the caller's receipt.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AccountDeletionResult" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/account/api-keys": {
      "get": {
        "tags": ["account"],
        "operationId": "listApiKeys",
        "summary": "List your developer API keys (metadata only).",
        "description": "JWT-BEARER-ONLY: an API key — regardless of its scopes — is refused with 403 `interactive_auth_required`. API keys cannot manage API keys: a leaked key must not be able to enumerate or reshape its owner's other credentials.\n\nReturns METADATA only, newest first, revoked keys included (with `revokedAt` set). The full secret is never in any list — it existed once, in the create response.",
        "responses": {
          "200": {
            "description": "The caller's key metadata.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AccountApiKeyList" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      },
      "post": {
        "tags": ["account"],
        "operationId": "createApiKey",
        "summary": "Mint a new developer API key.",
        "description": "JWT-BEARER-ONLY (403 `interactive_auth_required` for API-key callers — a key must never mint itself broader, longer-lived successors). Gated behind PUBLIC_API_WRITE_ENABLED.\n\nTHE RESPONSE IS THE ONLY TIME THE FULL SECRET APPEARS. `key` is never stored (only a peppered hash is) and can never be shown again — hand it to the user or store it in your secret manager immediately. Scopes are validated against the canonical /v1 scope vocabulary; an unknown scope refuses the whole request with 400. Omitting `scopes` yields the safe read-only default set.",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateAccountApiKeyRequest" } } }
        },
        "responses": {
          "201": {
            "description": "The minted key, INCLUDING the one-time secret.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AccountApiKeyCreated" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/account/api-keys/{keyId}": {
      "delete": {
        "tags": ["account"],
        "operationId": "revokeApiKey",
        "summary": "Revoke one of your API keys.",
        "description": "JWT-BEARER-ONLY (403 `interactive_auth_required` for API-key callers). Gated behind PUBLIC_API_WRITE_ENABLED.\n\nSoft-revokes the key: it stops verifying within the gateway's short verification-cache window (seconds) and stays listable with `revokedAt` set. Idempotent on an already-revoked key. A key id that does not exist OR belongs to another user is the same 404 — deliberately indistinguishable.",
        "parameters": [
          { "name": "keyId", "in": "path", "required": true, "schema": { "type": "string" }, "description": "The key's `id` from the list." }
        ],
        "responses": {
          "204": { "description": "Revoked (or already revoked)." },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/health": {
      "get": {
        "tags": ["meta"],
        "operationId": "getHealth",
        "summary": "Liveness probe (unauthenticated).",
        "security": [],
        "responses": {
          "200": {
            "description": "Service is up.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Health" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/openapi.json": {
      "get": {
        "tags": ["meta"],
        "operationId": "getOpenapi",
        "summary": "This OpenAPI 3.0 document (unauthenticated).",
        "security": [],
        "responses": {
          "200": {
            "description": "The OpenAPI spec.",
            "content": { "application/json": { "schema": { "type": "object" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/studios": {
      "get": {
        "tags": ["studios"],
        "operationId": "listStudios",
        "summary": "List the caller's studios.",
        "description": "Requires scope studios:read.",
        "parameters": [
          { "$ref": "#/components/parameters/Cursor" },
          { "$ref": "#/components/parameters/Limit" }
        ],
        "responses": {
          "200": {
            "description": "A page of studios.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StudioList" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      },
      "post": {
        "tags": ["studios"],
        "operationId": "createStudio",
        "summary": "Create a studio.",
        "description": "Requires scope studios:write. Gated behind PUBLIC_API_WRITE_ENABLED.",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateStudioRequest" } } }
        },
        "responses": {
          "201": {
            "description": "The created studio.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Studio" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/studios/{id}": {
      "get": {
        "tags": ["studios"],
        "operationId": "getStudio",
        "summary": "Get one studio.",
        "description": "Requires scope studios:read.",
        "parameters": [ { "$ref": "#/components/parameters/StudioId" } ],
        "responses": {
          "200": {
            "description": "The studio.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Studio" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      },
      "patch": {
        "tags": ["studios"],
        "operationId": "updateStudio",
        "summary": "Update a studio (name / auto-go-live settings).",
        "description": "Requires scope studios:write. Gated behind PUBLIC_API_WRITE_ENABLED. There is no studio delete on the public API.",
        "parameters": [ { "$ref": "#/components/parameters/StudioId" } ],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpdateStudioRequest" } } }
        },
        "responses": {
          "200": {
            "description": "The updated studio.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Studio" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/studios/{id}/sources": {
      "get": {
        "tags": ["studios"],
        "operationId": "listSources",
        "summary": "List a studio's sources.",
        "description": "Requires scope sources:read.",
        "parameters": [
          { "$ref": "#/components/parameters/StudioId" },
          { "$ref": "#/components/parameters/Cursor" },
          { "$ref": "#/components/parameters/Limit" }
        ],
        "responses": {
          "200": {
            "description": "A page of sources.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SourceList" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      },
      "post": {
        "tags": ["studios"],
        "operationId": "createSource",
        "summary": "Add a source to a studio.",
        "description": "Requires scope `sources:write`. Gated behind PUBLIC_API_WRITE_ENABLED.\n\nTo put a library file on the canvas, send `mediaAssetId` with a library-backed `type` (`video-file`, `image`, `audio-file`, `media`) and no `url` — the server resolves the encoder-facing URL itself and refreshes it as it expires, so you never hold a storage credential. `url` and `mediaAssetId` are mutually exclusive; sending both is a 400.\n\nFor a live input (`generic-input`, `srt`, `rtmp`) send neither and read the publish endpoints back from `POST /v1/studios/{id}/sources/{sourceId}/ingest`.",
        "parameters": [ { "$ref": "#/components/parameters/StudioId" } ],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateSourceRequest" } } }
        },
        "responses": {
          "201": {
            "description": "The created source.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Source" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/studios/{id}/sources/{sourceId}": {
      "get": {
        "tags": ["studios"],
        "operationId": "getSource",
        "summary": "Read one source.",
        "description": "Requires scope `sources:read`.\n\nThe same object `GET /v1/studios/{id}/sources` returns, for one input. It exists to be POLLED: after a device dials the URLs from `POST .../ingest`, this is how the device confirms the media actually arrived.\n\nRead `publishing`, not `status`. `status` is the control-plane provisioning state and says `OK` for a perfectly configured input that nobody is publishing to — it has never meant \"live\". `publishing` is media-plane truth: is a publisher attached to this input's path right now. It is the same signal the TRaX studio's own source LEDs burn, so the API and the UI cannot disagree.\n\n`publishing` is nullable, and null is a THIRD answer. It means the liveness tracker could not be reached on this request: unknown, not offline. A poll loop must keep asking; treating null as \"my stream is down\" is exactly the wrong move during a blip.\n\n`publishedAt` is when the CURRENT publish began, not when we last noticed it — it holds still while the publisher stays connected, so you can render \"live for 4m\" from it, and a NEW value means the publisher dropped and came back.\n\nA sensible poll is every 2 seconds while waiting to go live, then stop. Liveness normally appears within a couple of seconds of the encoder's handshake, and it clears within about 15 seconds of the publisher going away.\n\nA `sourceId` from a studio you cannot see answers 404 `studio not found`, identical to a studio that does not exist.",
        "parameters": [
          { "$ref": "#/components/parameters/StudioId" },
          { "$ref": "#/components/parameters/SourceId" }
        ],
        "responses": {
          "200": {
            "description": "The source.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Source" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      },
      "patch": {
        "tags": ["studios"],
        "operationId": "updateSource",
        "summary": "Update a source.",
        "description": "Requires scope sources:write. Gated behind PUBLIC_API_WRITE_ENABLED.",
        "parameters": [
          { "$ref": "#/components/parameters/StudioId" },
          { "$ref": "#/components/parameters/SourceId" }
        ],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpdateSourceRequest" } } }
        },
        "responses": {
          "200": {
            "description": "The updated source.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Source" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      },
      "delete": {
        "tags": ["studios"],
        "operationId": "deleteSource",
        "summary": "Remove a source.",
        "description": "Requires scope sources:write. Gated behind PUBLIC_API_WRITE_ENABLED.",
        "parameters": [
          { "$ref": "#/components/parameters/StudioId" },
          { "$ref": "#/components/parameters/SourceId" }
        ],
        "responses": {
          "200": {
            "description": "The source was removed.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Ok" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/studios/{id}/contribute": {
      "post": {
        "tags": ["studios"],
        "operationId": "contribute",
        "summary": "Put a device on a studio and get its publish credential.",
        "description": "Requires scopes `sources:ingest` AND `sources:write`. Gated behind PUBLIC_API_WRITE_ENABLED.\n\nThis is the one call a phone cannot go live without. It provisions — or re-finds — this device's input on the studio and hands back the credential to publish into it, in a single round trip. If it succeeds you can start pushing video; if it fails there is no stream, and nothing else on this API changes that.\n\nIt needs BOTH scopes because it does two separable things: it CREATES an input on the studio (`sources:write`) and it hands back a LIVE PUBLISH CREDENTIAL (`sources:ingest`). Those two deliberately do not imply each other in either direction, so a key holding only one would be doing half of this without having asked for that half. Tick both when you mint the key.\n\nIDEMPOTENT per (owner, studio, `deviceId`) — and that is a contract, not an implementation detail you should avoid relying on. Send the same `deviceId` and you get the same input, the same media path and the same credential, every time. **201** the first time, **200** on every call after. A phone on a bad uplink can send this three times and end up with one input.\n\nRetrying is SAFE, including after a timeout where you never saw the response. Nothing here rotates a credential, so a retry cannot invalidate the key the timed-out attempt already issued. That is why rotation is a separate endpoint with its own name: `POST /v1/studios/{id}/sources/{sourceId}/ingest/rotate` destroys the old credential deliberately, and this call never does it by accident.\n\nSame writer as the TRaX app's own contribute flow, so an input provisioned here and one provisioned in the app are the same row with the same reconnection behaviour — there is no second provisioning path to drift from.\n\n`ingestHost` and `ingestMetro` tell your user where they are publishing. `ingestMetro` is `\"auto\"` today because the nearest point of presence is chosen in DNS when your device resolves the hostname, not by this call — display it, do not branch on it.\n\nRate limited in the credential-handout class, roughly 5 per minute per caller with a small burst. A real client calls this once per session and again on reconnect. On 429 the response carries `Retry-After`; wait that long rather than guessing.\n\nAnswers **503 `contribute_disabled`** when the deployment has the device-contribute path switched off. That is not a transient failure and it carries no `Retry-After` — retrying will never make it succeed.",
        "parameters": [{ "$ref": "#/components/parameters/StudioId" }],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ContributeRequest" } } }
        },
        "responses": {
          "200": {
            "description": "This device already had an input on this studio. Same input, same credential — your earlier call created it.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Contribution" } } }
          },
          "201": {
            "description": "The input was created by this call.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Contribution" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      },
      "delete": {
        "tags": ["studios"],
        "operationId": "releaseContribute",
        "summary": "Release a device's input on a studio (keep-source teardown).",
        "description": "Requires scopes `sources:ingest` AND `sources:write` — the SAME pair as provisioning, so a phone that could create the session can release it and no other key can. Gated behind PUBLIC_API_WRITE_ENABLED.\n\nThe teardown twin of `POST /v1/studios/{id}/contribute`: it gives this device's input back to the studio when the phone is done, stopping the encoder from re-arming a pull against a publisher-less path. Call it from `applicationWillTerminate`.\n\n**KEEP-SOURCE.** The input row SURVIVES — its id, media path, canvas placement and layout stay. A later `POST .../contribute` with the SAME `deviceId` re-seats the SAME tile instead of littering the studio with a new one. This is deactivate, not delete; `DELETE /v1/studios/{id}/sources/{sourceId}` is the call that removes the row.\n\n**Answers 204 on everything that is merely \"nothing to do\":** an unknown `deviceId`, one already released, one that never published, or an absent/empty body. The caller is a client shutting down with no UI to show an error and no chance to retry, so a teardown must never fail on request framing it cannot fix. The only non-204 answers are the ones you must not ignore: **403** when the `deviceId` belongs to another user, **401** without auth, and **5xx** if the release genuinely did not happen.\n\nUnlike provisioning, this is NOT gated on the deployment's contribute switch: that flag stops new sessions being created; it must never strand one that already exists.",
        "parameters": [{ "$ref": "#/components/parameters/StudioId" }],
        "requestBody": {
          "required": false,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ReleaseContributeRequest" } } }
        },
        "responses": {
          "204": {
            "description": "The input was released, or there was nothing to release (unknown/already-released/empty deviceId). No body."
          },
          "403": {
            "description": "The deviceId belongs to another user.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/studios/{id}/monitor": {
      "post": {
        "tags": ["studios"],
        "operationId": "mintProgramMonitor",
        "summary": "Get the return feed — watch the program while you publish into it.",
        "description": "Requires scope `viewer:tokens`. Gated behind PUBLIC_API_WRITE_ENABLED.\n\nIssues a short-lived, read-only credential for the studio's PROGRAM output: what the show is actually sending, right now. It is the app's version of the operator's program monitor, and it is what tells the person holding the camera that their shot is on air.\n\nThe scope is `viewer:tokens` rather than a `:read` scope for a reason worth knowing before you mint a key. This hands back a credential that PLAYS THE PROGRAM — a media credential, not a row of JSON — and `stream:read` is a DEFAULT scope that every casually created key carries forever. `viewer:tokens` is the scope this platform already uses for minting media playback credentials, it is opt-in by name, and any key holding it can already mint a `video.play` token for the same studio through `POST /v1/viewer-tokens`. So requiring it here grants nothing your key could not already do; it just refuses to give this power away to a key that never asked for it.\n\nSHORT-LIVED and re-mintable: 300 seconds by default, 900 maximum, clamped rather than refused. That costs less than it sounds. The media server authorises a read once, at session ESTABLISHMENT, and never re-checks — so a monitor that is already playing keeps playing past `expiresAt`. Mint again to (re)CONNECT, not to keep watching, and schedule from `expiresAt` rather than from the TTL you requested.\n\nTake the WHEP path: `whep.url` with `Authorization: Bearer whep.token` on the signaling POST. WebRTC survives a lossy cellular uplink far better than the alternative. `srt` is there for clients that cannot speak WHEP and is `null` when a dialable URL cannot be built — the credential rides inside SRT's 512-byte streamid cap, and a URL over it is rejected by the client's own SRT library before it reaches the network, so the server declines to hand back one that cannot work.\n\nThe credential is always issued when you may hold it. Whether FRAMES flow depends on the studio being live with its program preview published — a monitor opened off-air simply shows nothing yet. That is a state to render, not an error to report.\n\nSame rate-limit class as the credential endpoints, roughly 5 per minute per caller with a small burst.",
        "parameters": [{ "$ref": "#/components/parameters/StudioId" }],
        "requestBody": {
          "required": false,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MonitorRequest" } } }
        },
        "responses": {
          "200": {
            "description": "The return-feed credential.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Monitor" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/studios/{id}/sources/{sourceId}/ingest": {
      "post": {
        "tags": ["studios"],
        "operationId": "getSourceIngest",
        "summary": "Get the publish endpoints for a source.",
        "description": "Requires scope `sources:ingest`. Gated behind PUBLIC_API_WRITE_ENABLED.\n\nReturns the URLs an encoder — the TRaX phone app, OBS, a hardware unit — dials to put video INTO a source that already exists. Nothing is provisioned here. The input is the durable object and you point a device at it, so calling this twice hands two devices the same endpoint rather than creating a second input. Create the input first with `POST /v1/studios/{id}/sources` (type `generic-input`), then call this whenever a device needs to know where to publish.\n\nTreat the response like a password. Every `complete` URL is a live credential to broadcast on this studio's input: keychain, not log file, not analytics event, not a crash report. That is also why this is a POST — a GET URL ends up in browser history, proxy logs and `Referer` headers, and this response has no business in any of them.\n\n`protocols` is a REQUEST, not a guarantee. Read `granted` off the response and configure from that: it may be narrower than what you asked for and it may be empty. Today `srt`, `rtmp` and `rtmps` are grantable. `webrtc` is not — the WHIP URL for a plain input carries no publish token, so returning it would hand you an endpoint that fails at the handshake — and `rtsp` and `hls` have no publish form at all. Asking for one of those is not an error; it simply does not appear in `granted`. An unrecognised protocol NAME is a 400, because a silently dropped one leaves your client waiting for an endpoint that is never coming.\n\nRate limited separately from the rest of `/v1`, at roughly 5 per minute per caller with a small burst. A client fetches an input's endpoints once when the operator picks that input and again on reconnect; anything sustained above that is a script pulling publish credentials in a loop.\n\n`GET /v1/studios/{id}/sources` deliberately carries none of this — list responses are what people log.",
        "parameters": [
          { "$ref": "#/components/parameters/StudioId" },
          { "$ref": "#/components/parameters/SourceId" }
        ],
        "requestBody": {
          "required": false,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SourceIngestRequest" } } }
        },
        "responses": {
          "200": {
            "description": "The publish endpoints actually granted. Nothing was created; these already existed.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SourceIngest" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/studios/{id}/sources/{sourceId}/ingest/rotate": {
      "post": {
        "tags": ["studios"],
        "operationId": "rotateSourceIngest",
        "summary": "Rotate a source's publish credential.",
        "description": "Requires scope `sources:ingest`. Gated behind PUBLIC_API_WRITE_ENABLED.\n\nReplaces the stream key behind this input and returns the NEW endpoints, in exactly the shape `POST .../ingest` returns — so a client that can configure an encoder from a mint result needs no second code path for a rotate result. This is the answer to \"that stream key got out\": pasted into a support chat, on a laptop that walked off, in the hands of a contributor who no longer works here.\n\nDESTRUCTIVE, and there is no overlap window. The old URLs stop authenticating the moment this returns, because an overlap window is precisely the interval in which the leaked key is still useful. Every device still configured with the old URL will fail its next handshake until you re-point it, so rotate when you can re-provision — not in the sixty seconds before a show.\n\n`kickPublishers` decides what happens to a session that is publishing RIGHT NOW. The media server authorises a publish once, at handshake, and never re-checks, so an in-flight publisher survives the rotation until its socket drops. Send `true` to cut it — that is what \"revoke\" usually means — or `false` (the default) to let a broadcast in progress finish on the old credential while every NEW connection requires the new one.\n\n`protocols` scopes what comes BACK, not what is invalidated. Rotation always replaces the single underlying stream key, so asking only for `srt` still kills the old `rtmp` URL. Read `granted` off the response and configure from that, exactly as with the mint endpoint. An unrecognised protocol NAME is a 400 and the credential is NOT rotated — a typo must not cost you a working key.\n\nSame rate-limit class as the mint endpoint, roughly 5 per minute per caller with a small burst.\n\nThe studio's own operators see this without asking: open studio tabs re-render the Connection Information panel with the new URLs.",
        "parameters": [
          { "$ref": "#/components/parameters/StudioId" },
          { "$ref": "#/components/parameters/SourceId" }
        ],
        "requestBody": {
          "required": false,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SourceIngestRotateRequest" } } }
        },
        "responses": {
          "200": {
            "description": "The publish endpoints built from the NEW credential. The previous ones no longer authenticate.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SourceIngest" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/studios/{id}/audio": {
      "get": {
        "tags": ["studios"],
        "operationId": "getStudioAudio",
        "summary": "Read the studio's audio mix.",
        "description": "Requires scope `audio:read`.\n\nThe whole mix in one call: the master strip plus one strip per source. One call, not one per source, because a mixer surface draws every fader at once and asking per source would let the strips disagree with each other mid-render.\n\nEvery source row gets a strip, including sources with no audio track. The strip is per-source state that persists and replays to the encoder, so hiding rows here would make this API's idea of \"the mix\" narrower than the operator's own panel. Read `active` and `type` to decide what your UI draws.\n\nA source nobody has touched in the mixer reports the RUNNING defaults — volume 1.0, unmuted, centre, trim 1.0, no groups — not an absence. Those are the values the encoder is applying to it, so reporting them is a measurement rather than a guess.\n\n`volume` is a LINEAR gain, not decibels: 0.0 silent, 1.0 unity (0 dB), above 1.0 amplifies, ceiling 10.0. `pan` is -1.0 full left … +1.0 full right. `trim` is the pre-fader input gain, linear, 1.0 nominal.\n\nMUTE IS NOT VOLUME ZERO. `muted` is an independent gate and the fader keeps its position underneath it, so unmuting returns to exactly the level the operator was at.",
        "parameters": [{ "$ref": "#/components/parameters/StudioId" }],
        "responses": {
          "200": {
            "description": "The studio's mix.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StudioAudio" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/studios/{id}/audio/master": {
      "patch": {
        "tags": ["studios"],
        "operationId": "updateAudioMaster",
        "summary": "Set the master level or mute.",
        "description": "Requires scope `audio:write`. Gated behind PUBLIC_API_WRITE_ENABLED.\n\nA PATCH: send only the fields you are changing, and the rest keep their current values. Sending `{\"muted\": true}` does not move the master fader.\n\nMuting the master silences the program for every viewer on every destination while leaving each source's own strip untouched. To silence one contributor, patch that source instead.\n\n`volume` is the same LINEAR scale as a channel fader: 0.0 silent, 1.0 unity, ceiling 10.0. A value outside the range is a 400, not a clamp — a client that believed it was sending decibels should find out.\n\nThe master strip has a fader and a mute and nothing else, which is the actual shape of the master strip in the studio.",
        "parameters": [{ "$ref": "#/components/parameters/StudioId" }],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpdateAudioMasterRequest" } } }
        },
        "responses": {
          "200": {
            "description": "The updated master strip.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AudioMaster" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/studios/{id}/audio/sources/{sourceId}": {
      "patch": {
        "tags": ["studios"],
        "operationId": "updateAudioSource",
        "summary": "Set one source's level, mute, pan, solo or trim.",
        "description": "Requires scope `audio:write`. Gated behind PUBLIC_API_WRITE_ENABLED.\n\nA PATCH: send only the fields you are changing. `{\"muted\": true}` mutes the channel and leaves the level, pan, solo and trim exactly where they were — which is what lets a phone mute a mic without knowing, or clobbering, the mix an operator set on a laptop.\n\nSET `muted`; DO NOT write `volume: 0` and call it mute. Mute is an independent gate and the fader keeps its position underneath, so unmuting returns to the level the operator was at. Collapsing the two loses that position.\n\nThis reaches the same control path the TRaX studio's own mixer uses. On a live studio the change is immediate and audible, and every open studio tab re-renders the fader. On an idle studio it persists and applies at the next go-live.\n\nUnits: `volume` LINEAR 0.0–10.0 (1.0 = unity, 0 dB — not decibels), `pan` -1.0–1.0, `trim` LINEAR 0.1–16.0. Anything outside those ranges is a 400 rather than a silent clamp.\n\n`soloed` is a mixing-desk intent flag synced across the studio's operators; it does not by itself mute the other channels.\n\n`muteGroups` is read-only — it is returned by the mix read but cannot be set here, because the group MASTER button that gives membership its meaning lives in the studio's mixer panel.\n\nA `sourceId` from a studio you cannot see answers 404 `studio not found`, identical to a source that does not exist.",
        "parameters": [
          { "$ref": "#/components/parameters/StudioId" },
          { "$ref": "#/components/parameters/SourceId" }
        ],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpdateAudioSourceRequest" } } }
        },
        "responses": {
          "200": {
            "description": "The updated channel strip, in full.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AudioSourceStrip" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/studios/{id}/output": {
      "get": {
        "tags": ["studios"],
        "operationId": "getStudioOutput",
        "summary": "Read the program resolution and frame rate.",
        "description": "Requires scope `studios:read`.\n\nThree things come back and they are deliberately not the same thing:\n\n- `selected` — what the operator picked and what is persisted.\n- `effective` — what the NEXT go-live will actually run, after the full policy chain.\n- `caps` — why they differ, and what the ceiling is right now.\n\nReading only `selected` will mislead you. The offered resolution is soft-capped by the ENABLED destinations' real platform limits (Twitch ingests 1080p, Instagram 720p, YouTube 4K), so a studio set to 2160p with Twitch enabled airs at 1080p. `effective` is that answer; `caps.cappedBy` names the platform that set the ceiling, and is an EMPTY STRING when nothing is capping.\n\nSET BEFORE LIVE. The encoder does not renegotiate resolution or frame rate mid-stream, so `effective` describes the next go-live, not the session currently on air.\n\nThe frame rate is a RATIONAL — 59.94 is 60000/1001 — so NTSC rates are exact rather than rounded. `fps` is the decimal rendering for display only.\n\nThis is the same format the canvas read reports as `output`, in the same `ProgramOutput` shape; this endpoint adds the intent, the cap and the reason.",
        "parameters": [{ "$ref": "#/components/parameters/StudioId" }],
        "responses": {
          "200": {
            "description": "The studio's program output format.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StudioOutput" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      },
      "patch": {
        "tags": ["studios"],
        "operationId": "updateStudioOutput",
        "summary": "Set the program resolution and/or frame rate.",
        "description": "Requires scope `studios:write`. Gated behind PUBLIC_API_WRITE_ENABLED.\n\nTHIS ENDPOINT REJECTS RATHER THAN CLAMPS, and that is the point of it. Before it existed the four columns took whatever a client sent — a width of 7, a zero denominator, a negative — and the value was quietly replaced with a default or snapped to the nearest supported rung at the next go-live. The caller was told \"saved\" and got something else. Anything off the supported ladder is now a 400 naming the field and listing what is allowed.\n\nResolution must be one of the supported 16:9 sizes exactly: 1280x720, 1920x1080, 2560x1440, 3840x2160. Frame rate is a rational: 24000/1001 (23.976), 30000/1001 (29.97), 60000/1001 (59.94), and 24/25/30/50/60 over 1. The ratio may not exceed 60, which is the encoder's own pacing ceiling rather than a product limit.\n\nTHE DESTINATION SOFT-CAP IS REPORTED, NOT ENFORCED HERE. Setting 2160p on a Twitch-only studio SUCCEEDS and the response says it will air at 1080p. Rejecting it would be wrong: destinations are enabled and disabled after the format is chosen, and a resolution that is illegal this minute becomes legal the moment YouTube is turned on. The selection is the operator's intent; the cap is a fact about today's destination set.\n\nApplying this to a LIVE studio persists and takes effect at the next go-live. It does not disturb the session on air.\n\nThe response is the whole re-resolved view, the same shape GET returns, so you never have to model the cap chain yourself.",
        "parameters": [{ "$ref": "#/components/parameters/StudioId" }],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpdateStudioOutputRequest" } } }
        },
        "responses": {
          "200": {
            "description": "The updated program output format, re-resolved.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StudioOutput" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/studios/{id}/canvas": {
      "get": {
        "tags": ["canvas"],
        "operationId": "getCanvas",
        "summary": "Read the studio's whole canvas.",
        "description": "Requires scope `canvas:read`.\n\nThe whole canvas in ONE call: layout mode, active preset and scene, the coordinate space the rectangles live in, the program output format, and one placement row per source. Everything a phone needs to draw a miniature of what is on air, without a second request.\n\nOne call rather than a layout read plus a source list, on purpose. A tile is a source AND a rectangle; fetching those separately gives you two answers taken at two moments, and the picture drawn from them can show a tile that no longer exists or omit one that just appeared.\n\nGEOMETRY IS THE SAME IN BOTH MODES. The server persists the auto planner's resolved rectangles into the same store advanced mode writes, so what you read here is what the encoder is compositing whichever mode the studio is in. You do not have to re-implement the planner to draw the picture.\n\n`canvasWidth` x `canvasHeight` is the COORDINATE SPACE — a fixed 1920x1080 for every studio — and is NOT the broadcast resolution. `output` is. They differ constantly (a 720p60 program still composites on a 1920x1080 canvas), so scale your miniature by the canvas and label the stream by `output`.\n\nCONDITIONAL. The response carries an ETag; send it back as `If-None-Match` and an unchanged canvas answers 304 with no body. The tag is computed over the whole snapshot, not just the layout revision, so a source renamed or deleted invalidates it too — a 304 always means your picture is still correct.\n\nHold `layoutRev` and send it back as `baseRev` on your next write.",
        "parameters": [
          { "$ref": "#/components/parameters/StudioId" },
          {
            "name": "If-None-Match",
            "in": "header",
            "required": false,
            "schema": { "type": "string" },
            "description": "An ETag from a previous read. An unchanged canvas answers 304 with no body."
          }
        ],
        "responses": {
          "200": {
            "description": "The studio's canvas.",
            "headers": {
              "ETag": { "schema": { "type": "string" }, "description": "Strong validator over this exact snapshot. Send it back as If-None-Match." }
            },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CanvasState" } } }
          },
          "304": { "description": "The canvas has not changed since the ETag you sent. No body." },
          "default": { "$ref": "#/components/responses/Error" }
        }
      },
      "patch": {
        "tags": ["canvas"],
        "operationId": "updateCanvas",
        "summary": "Set the layout mode or the canvas-wide settings.",
        "description": "Requires scope `canvas:write`. Gated behind PUBLIC_API_WRITE_ENABLED.\n\nA PATCH: send only what you are changing. Returns the WHOLE canvas, so the answer to your change is the new picture rather than an acknowledgement.\n\n`layoutMode` is the field that matters. **auto** hands geometry to the server's planner: you express intent (which preset, which source is focused) and the studio computes every rectangle, overriding whatever you last wrote. **advanced** hands geometry to you: each tile stays exactly where it was last put and the planner keeps its hands off. Sending a rectangle while the studio is in auto is not an error and not a no-op either — it lands, and the next re-plan overwrites it. Flip to advanced first if you mean it to stick.\n\nFlipping auto -> advanced is SEAMLESS by construction: the studio seeds the freeform geometry from what auto is displaying at that instant, in the same write as the mode flip, so nothing on the program moves. Flipping back re-plans and DOES move things — that is the point of going back to auto.\n\nSend `baseRev` (the `layoutRev` you read) to make a stale write a 409 instead of a clobber. Omit it for last-write-wins.",
        "parameters": [{ "$ref": "#/components/parameters/StudioId" }],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpdateCanvasRequest" } } }
        },
        "responses": {
          "200": {
            "description": "The canvas after the change.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CanvasState" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/studios/{id}/canvas/capabilities": {
      "get": {
        "tags": ["canvas"],
        "operationId": "getCanvasCapabilities",
        "summary": "Discover the preset catalog and the canvas vocabularies.",
        "description": "Requires scope `canvas:read`.\n\nWhat this studio's canvas supports: the encoder-declared preset catalog — each preset's id, display name, slot count, normalized slot geometry, and an SVG schematic you can render as a picker thumbnail — plus the layout modes, fit modes and scene transitions a picker needs to enumerate, and the canvas coordinate space.\n\nDISCOVER, DO NOT HARDCODE. The catalog is the encoder's, not this API's; ids come and go with encoder releases and a client with a baked-in list quietly loses the ones it never heard of. Cache on `libraryVersion` and refetch when it changes.\n\n`slots` are normalized 0.0-1.0 fractions, not canvas units — they describe the SHAPE of an arrangement independent of resolution, which is what makes them drawable as a thumbnail. The rectangles a preset actually produces for the current source set come back from the canvas read.\n\nDEGRADES RATHER THAN FAILS: an unreachable encoder yields an empty `presets` with an empty `libraryVersion`, never an error. That is a state to render — fall back to what you cached, or hide the picker — and it is the honest answer, because we do not know what that encoder offers right now. The vocabularies are never degraded; they are the gateway's own.",
        "parameters": [{ "$ref": "#/components/parameters/StudioId" }],
        "responses": {
          "200": {
            "description": "What the canvas supports. Possibly a degraded preset half — see the description.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CanvasCapabilities" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/studios/{id}/canvas/placements/{sourceId}": {
      "patch": {
        "tags": ["canvas"],
        "operationId": "updateCanvasPlacement",
        "summary": "Move, resize, re-stack, crop, hide or show one tile.",
        "description": "Requires scope `canvas:write`. Gated behind PUBLIC_API_WRITE_ENABLED.\n\nEVERY FIELD IS OPTIONAL AND OMITTED MEANS PRESERVE. That is what makes `{\"zIndex\": 3}` a legal call: bring a tile forward without knowing its rectangle; hide one without knowing its crop. A source with NO stored placement is the one exception — there is nothing to preserve, so a first placement must carry a `rect`, and the 400 says so.\n\nTHIS FLIPS THE STUDIO TO ADVANCED MODE. Per-source geometry IS the mode: the server writes the mode alongside the rectangle, atomically, because a rectangle sitting under auto mode is geometry the next re-plan is about to erase. If you wanted to nudge one tile inside an auto arrangement, you wanted the preset endpoint instead — or this, then a replan to put auto back.\n\nRectangles are in CANVAS units (the 1920x1080 space the canvas read reports), not output pixels and not fractions. A tile may hang off an edge but must intersect the canvas; width and height must be positive and no larger than the canvas. Values outside their ranges are 400s rather than silent clamps, for the same reason a mixer refuses a fader position in decibels: a client that guessed the units should find out.\n\nSAFE TO RETRY. A stale `baseRev` is normally 409 — but a request that describes the tile EXACTLY AS IT ALREADY IS comes back 200, because the write it asks for is in effect and there is nothing left to do. That exception exists for the case this endpoint was built for: a phone sends a move, the write lands, the response never arrives, and the phone retries with the `baseRev` it started from. Without it that retry gets a conflict for a change it successfully made, and a client that trusts conflicts would undo the user's own edit.\n\nNOT A DRAG PATH. Every call here persists, re-asserts to the encoder and fans out to every open studio tab. For real-time motion the studio WebSocket carries an ephemeral high-frequency transform frame that does none of those things; REST has no equivalent. Move the tile locally, then commit the final rectangle here — once.",
        "parameters": [
          { "$ref": "#/components/parameters/StudioId" },
          { "$ref": "#/components/parameters/SourceId" }
        ],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpdateCanvasPlacementRequest" } } }
        },
        "responses": {
          "200": {
            "description": "The canvas after the move — or unchanged, when a retried write was already in effect.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CanvasState" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/studios/{id}/canvas/preset": {
      "post": {
        "tags": ["canvas"],
        "operationId": "setCanvasPreset",
        "summary": "Pick the auto-mode arrangement.",
        "description": "Requires scope `canvas:write`. Gated behind PUBLIC_API_WRITE_ENABLED.\n\nThe auto-mode counterpart to a placement patch: there you name pixels, here you name INTENT and the planner produces the pixels. For a phone this is almost always the better call — \"put Dana big and everyone else in a strip\" survives a guest joining, where six hand-placed rectangles do not.\n\nThe server persists the intent, re-resolves every rectangle from it, seats any source the new preset made room for, and pushes the result at a live encoder. Returns the whole canvas.\n\n`presetId` must come from the capabilities endpoint. Do not hardcode the catalog. `tileOrder`, `traySourceIds` and `slotAssignments` are WHOLE-SET replacements when present and untouched when absent — send the complete list, because a partial one would silently drop the members it omitted.\n\nLAST-WRITE-WINS, not revision-gated, deliberately: picking a preset is a discrete human action taken a few times a show, not the high-frequency optimistic path a revision clock exists to protect. Two people picking two presets a second apart should end on the second one, not on a conflict.",
        "parameters": [{ "$ref": "#/components/parameters/StudioId" }],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SetCanvasPresetRequest" } } }
        },
        "responses": {
          "200": {
            "description": "The canvas after the arrangement changed.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CanvasState" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/studios/{id}/canvas/replan": {
      "post": {
        "tags": ["canvas"],
        "operationId": "replanCanvas",
        "summary": "Hand the arrangement back to the auto planner.",
        "description": "Requires scope `canvas:write`. Gated behind PUBLIC_API_WRITE_ENABLED.\n\nRe-runs the planner over the studio's CURRENT sources and preset, persists the rectangles it resolves, seats anything the plan now places, pushes the result at a live encoder, and returns the whole canvas. It changes no intent — same preset, same focus, same tray — it just recomputes.\n\nThis is the \"put it back the way the studio would arrange it\" button, and the way OUT of advanced mode: send `layoutMode: \"auto\"` and the row flips and re-plans in one call, so hand-placed rectangles are replaced by the planner's.\n\nSent without a mode against an ADVANCED studio it is a no-op, and reports that honestly by returning the unchanged canvas rather than an error: the planner does not own geometry in advanced mode, and quietly re-planning a studio whose operator took manual control would move tiles on air that nobody asked to move.\n\nYou should rarely need it. Membership changes — a guest joins, a camera goes live, a preset is picked — already re-plan on their own.",
        "parameters": [{ "$ref": "#/components/parameters/StudioId" }],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ReplanCanvasRequest" } } }
        },
        "responses": {
          "200": {
            "description": "The canvas after the re-plan — or unchanged, when the studio is in advanced mode and no mode flip was asked for.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CanvasState" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/studios/{id}/destinations": {
      "get": {
        "tags": ["studios"],
        "operationId": "listDestinations",
        "summary": "List a studio's destinations.",
        "description": "Requires scope destinations:read.",
        "parameters": [
          { "$ref": "#/components/parameters/StudioId" },
          { "$ref": "#/components/parameters/Cursor" },
          { "$ref": "#/components/parameters/Limit" }
        ],
        "responses": {
          "200": {
            "description": "A page of destinations.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DestinationList" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      },
      "post": {
        "tags": ["studios"],
        "operationId": "createDestination",
        "summary": "Add a destination to a studio.",
        "description": "Requires scope destinations:write. Gated behind PUBLIC_API_WRITE_ENABLED. The stream key is write-only and never returned.",
        "parameters": [ { "$ref": "#/components/parameters/StudioId" } ],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateDestinationRequest" } } }
        },
        "responses": {
          "201": {
            "description": "The created destination.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Destination" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/studios/{id}/destinations/{destId}": {
      "get": {
        "tags": ["destinations"],
        "operationId": "getDestination",
        "summary": "Read one destination.",
        "description": "Requires scope `destinations:read`.\n\nSame shape the list returns. Read `lastError` whenever `status` is ERROR or STALLED, and `connectionId` to find which linked account a failing leg depends on.",
        "parameters": [
          { "$ref": "#/components/parameters/StudioId" },
          { "$ref": "#/components/parameters/DestId" }
        ],
        "responses": {
          "200": {
            "description": "The destination.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Destination" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      },
      "patch": {
        "tags": ["studios"],
        "operationId": "updateDestination",
        "summary": "Update a destination.",
        "description": "Requires scope destinations:write. Gated behind PUBLIC_API_WRITE_ENABLED.",
        "parameters": [
          { "$ref": "#/components/parameters/StudioId" },
          { "$ref": "#/components/parameters/DestId" }
        ],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpdateDestinationRequest" } } }
        },
        "responses": {
          "200": {
            "description": "The updated destination.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Destination" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      },
      "delete": {
        "tags": ["studios"],
        "operationId": "deleteDestination",
        "summary": "Remove a destination.",
        "description": "Requires scope destinations:write. Gated behind PUBLIC_API_WRITE_ENABLED.",
        "parameters": [
          { "$ref": "#/components/parameters/StudioId" },
          { "$ref": "#/components/parameters/DestId" }
        ],
        "responses": {
          "200": {
            "description": "The destination was removed.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Ok" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/studios/{id}/scenes": {
      "get": {
        "tags": ["canvas"],
        "operationId": "listScenes",
        "summary": "List the studio's saved scenes.",
        "description": "Requires scope `canvas:read`.\n\nThe looks an operator captured and can recall. A scene is a SNAPSHOT OF THE CANVAS, not a channel: it holds the source rectangles, the layout mode and the active-source set as they were at capture time.\n\n`hasComposition` false means the scene is a label with no saved canvas behind it — recalling it will change nothing. Grey the row rather than offering a recall that silently does nothing.\n\nNot paginated: a studio's scene list is a handful of rows an operator made by hand.",
        "parameters": [{ "$ref": "#/components/parameters/StudioId" }],
        "responses": {
          "200": {
            "description": "The studio's scenes.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SceneList" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/studios/{id}/scenes/active": {
      "post": {
        "tags": ["canvas"],
        "operationId": "setActiveScene",
        "summary": "Recall a saved scene onto the program.",
        "description": "Requires scope `canvas:write`. Gated behind PUBLIC_API_WRITE_ENABLED.\n\nRestores the scene's captured composition through the same geometry authority every other canvas write goes through, re-pushes each source's audio so levels do not snap back to encoder defaults, and tells every open studio tab. Returns the resulting canvas.\n\nON A LIVE STUDIO THIS CHANGES WHAT VIEWERS SEE, IMMEDIATELY. There is no preview bus here to rehearse it on: scene rows carry an is-preview flag, but no second canvas is composited from it, so exposing a \"preview\" would name a rehearsal that does not happen. Program is the only destination a scene has.\n\nA metadata-only scene — `hasComposition` false — recalls cleanly and changes no geometry. The returned canvas will look identical to the one you read before the call. That is success, not a silent failure.\n\nA `sceneId` from a studio you cannot see answers 404, identical to a scene that does not exist.",
        "parameters": [{ "$ref": "#/components/parameters/StudioId" }],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SetActiveSceneRequest" } } }
        },
        "responses": {
          "200": {
            "description": "The canvas after the scene was recalled.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CanvasState" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/studios/{id}/destinations/{destId}/broadcasts": {
      "get": {
        "tags": ["destinations"],
        "operationId": "listDestinationBroadcasts",
        "summary": "List the platform broadcasts a destination can pick from.",
        "description": "Requires scope `destinations:read`.\n\nReturns the platform-side broadcasts on the destination's linked connection (YouTube liveBroadcasts / Facebook live_videos) — recent, upcoming and active — exactly what the studio's own \"pick an existing broadcast\" panel shows. Each row's `id` is what `POST .../broadcast/pick` and `PATCH .../broadcast` take. On YouTube the rows carry the broadcast's LIVE settings (privacy, latency, DVR, ...) so an editor can seed from actual values.\n\nChannel-scoped platforms (Twitch, Kick, Trovo) and pasted-key destinations have no broadcast concept and answer an EMPTY list, not an error.",
        "parameters": [
          { "$ref": "#/components/parameters/StudioId" },
          { "$ref": "#/components/parameters/DestId" },
          { "$ref": "#/components/parameters/Cursor" },
          { "$ref": "#/components/parameters/Limit" }
        ],
        "responses": {
          "200": {
            "description": "A page of platform-side broadcasts.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BroadcastList" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/studios/{id}/destinations/{destId}/broadcast": {
      "post": {
        "tags": ["destinations"],
        "operationId": "createDestinationBroadcast",
        "summary": "Create a broadcast on the platform now and adopt it as the pick.",
        "description": "Requires scope `destinations:write`. Gated behind PUBLIC_API_WRITE_ENABLED.\n\nCreates a broadcast on the destination's platform IMMEDIATELY (YouTube liveBroadcasts.insert — an absent `scheduledStartTime` stamps a near-immediate start; Facebook's legacy scheduled path wants a future time), seeded from the destination's SAVED broadcastConfig — the same template-aware seeding go-live's create mode uses, so privacy/latency/DVR/contentDetails match what going live would have minted. `title`/`description` here override the config's copies.\n\nThe created broadcast is then ADOPTED as the destination's pick (mode → \"pick\"), so the next go-live binds the stream to it. Returns the created broadcast; its `id` is what the edit and pick endpoints take. Platforms with no broadcast create answer 400 `invalid_request`.",
        "parameters": [
          { "$ref": "#/components/parameters/StudioId" },
          { "$ref": "#/components/parameters/DestId" }
        ],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateBroadcastRequest" } } }
        },
        "responses": {
          "201": {
            "description": "The broadcast now created on the platform (and adopted as the pick).",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Broadcast" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      },
      "patch": {
        "tags": ["destinations"],
        "operationId": "updateDestinationBroadcast",
        "summary": "Edit a destination's picked YouTube broadcast.",
        "description": "Requires scope `destinations:write`. Gated behind PUBLIC_API_WRITE_ENABLED.\n\nEdits the mutable fields of the YouTube broadcast this destination has PICKED and pushes them to the platform, exactly as the studio's Broadcast Manager does. `broadcastId` is required and names the broadcast to edit, so the change never lands on the wrong stream. `title` and `description` are applied to the same picked broadcast; the status/contentDetails fields go through YouTube's liveBroadcasts.update.\n\nYouTube FREEZES contentDetails (dvr, embed, recordFromStart, latency, delay, projection, monitorStream) once a broadcast enters testing/live. Any such field the platform refused comes back in `rejectedFields` — the call still succeeds, so read it to learn which edits did not apply. YouTube-only: other platforms answer 400 `invalid_request` \"operation not supported on this platform\".",
        "parameters": [
          { "$ref": "#/components/parameters/StudioId" },
          { "$ref": "#/components/parameters/DestId" }
        ],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpdateBroadcastRequest" } } }
        },
        "responses": {
          "200": {
            "description": "The edit outcome, including any fields the platform locked.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BroadcastEditResult" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/studios/{id}/destinations/{destId}/broadcast/pick": {
      "post": {
        "tags": ["destinations"],
        "operationId": "setDestinationBroadcastPick",
        "summary": "Point the destination at an existing broadcast, or clear the pick.",
        "description": "Requires scope `destinations:write`. Gated behind PUBLIC_API_WRITE_ENABLED.\n\nPoints the destination at ONE existing platform broadcast so go-live binds the stream to it — the same choice the studio's \"Use existing\" tab makes. `broadcastId` comes from `GET .../broadcasts`. Sending `broadcastId: null` (or empty) CLEARS the pick: the destination returns to create mode and go-live auto-creates a fresh broadcast from its saved config.\n\nThis is a POINTER write: the platform is not called to validate the id here, and sibling destination settings are never touched (the write is a targeted merge). A dead or foreign broadcast id surfaces at go-live.",
        "parameters": [
          { "$ref": "#/components/parameters/StudioId" },
          { "$ref": "#/components/parameters/DestId" }
        ],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PickBroadcastRequest" } } }
        },
        "responses": {
          "200": {
            "description": "The destination's resulting broadcast targeting.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BroadcastPickResult" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/studios/{id}/destinations/{destId}/broadcast/thumbnail": {
      "post": {
        "tags": ["destinations"],
        "operationId": "setDestinationBroadcastThumbnail",
        "summary": "Upload a custom broadcast thumbnail.",
        "description": "Requires scope `destinations:write`. Gated behind PUBLIC_API_WRITE_ENABLED.\n\nUploads a custom thumbnail image for a YouTube broadcast on a connection-backed destination and returns the URL YouTube now serves. The image is the RAW request body and its `Content-Type` header is the image MIME type (`image/jpeg` or `image/png`); the broadcast is named by the `broadcastId` query parameter. YouTube caps thumbnails at 2 MB. An unverified channel answers 400 `invalid_request`.",
        "parameters": [
          { "$ref": "#/components/parameters/StudioId" },
          { "$ref": "#/components/parameters/DestId" },
          {
            "name": "broadcastId",
            "in": "query",
            "required": true,
            "schema": { "type": "string" },
            "description": "The YouTube broadcast/video id to set the thumbnail on."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "image/jpeg": { "schema": { "type": "string", "format": "binary" } },
            "image/png": { "schema": { "type": "string", "format": "binary" } }
          }
        },
        "responses": {
          "200": {
            "description": "The thumbnail URL YouTube now serves.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ThumbnailResult" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/studios/{id}/broadcast-templates": {
      "get": {
        "tags": ["destinations"],
        "operationId": "listBroadcastTemplates",
        "summary": "List a studio's broadcast templates.",
        "description": "Requires scope `destinations:read`.\n\nReturns the studio's saved broadcast templates, the pinned default first then newest-updated. A template is a named snapshot of the create-mode broadcast settings that prefills the create form; it creates nothing on YouTube. Each row carries its full `config` object.",
        "parameters": [
          { "$ref": "#/components/parameters/StudioId" },
          { "$ref": "#/components/parameters/Cursor" },
          { "$ref": "#/components/parameters/Limit" }
        ],
        "responses": {
          "200": {
            "description": "A page of broadcast templates.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BroadcastTemplateList" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      },
      "post": {
        "tags": ["destinations"],
        "operationId": "createBroadcastTemplate",
        "summary": "Save a broadcast template.",
        "description": "Requires scope `destinations:write`. Gated behind PUBLIC_API_WRITE_ENABLED.\n\nSaves a new named template from a `name` and a `config` JSON object. `isDefault: true` pins it as the studio's default in the same transaction (at most one default per studio). `config` is stored verbatim and never interpreted server-side.",
        "parameters": [ { "$ref": "#/components/parameters/StudioId" } ],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateBroadcastTemplateRequest" } } }
        },
        "responses": {
          "201": {
            "description": "The created template.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BroadcastTemplate" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/studios/{id}/broadcast-templates/{templateId}": {
      "patch": {
        "tags": ["destinations"],
        "operationId": "updateBroadcastTemplate",
        "summary": "Overwrite a broadcast template.",
        "description": "Requires scope `destinations:write`. Gated behind PUBLIC_API_WRITE_ENABLED.\n\nOverwrites the template's `name` and `config` in place, leaving its default pin untouched. A `templateId` that is not this studio's answers 404.",
        "parameters": [
          { "$ref": "#/components/parameters/StudioId" },
          { "name": "templateId", "in": "path", "required": true, "schema": { "type": "string" }, "description": "The broadcast template id." }
        ],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpdateBroadcastTemplateRequest" } } }
        },
        "responses": {
          "200": {
            "description": "The updated template.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BroadcastTemplate" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      },
      "delete": {
        "tags": ["destinations"],
        "operationId": "deleteBroadcastTemplate",
        "summary": "Delete a broadcast template.",
        "description": "Requires scope `destinations:write`. Gated behind PUBLIC_API_WRITE_ENABLED.\n\nDeletes one template, studio-scoped by id. Deleting the pinned default just leaves the studio with no default. A `templateId` that is not this studio's answers 404.",
        "parameters": [
          { "$ref": "#/components/parameters/StudioId" },
          { "name": "templateId", "in": "path", "required": true, "schema": { "type": "string" }, "description": "The broadcast template id." }
        ],
        "responses": {
          "200": {
            "description": "The template was deleted.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Ok" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/studios/{id}/broadcast-templates/{templateId}/default": {
      "post": {
        "tags": ["destinations"],
        "operationId": "setDefaultBroadcastTemplate",
        "summary": "Pin a broadcast template as the default.",
        "description": "Requires scope `destinations:write`. Gated behind PUBLIC_API_WRITE_ENABLED.\n\nPins one template as the studio's default and clears any other default, atomically. Returns the now-default row. A `templateId` that is not this studio's answers 404.",
        "parameters": [
          { "$ref": "#/components/parameters/StudioId" },
          { "name": "templateId", "in": "path", "required": true, "schema": { "type": "string" }, "description": "The broadcast template id." }
        ],
        "responses": {
          "200": {
            "description": "The now-default template.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BroadcastTemplate" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/studios/{id}/stream-status": {
      "get": {
        "tags": ["studios"],
        "operationId": "getStreamStatus",
        "summary": "Get a studio's live/offline stream status.",
        "description": "Requires scope stream:read.",
        "parameters": [ { "$ref": "#/components/parameters/StudioId" } ],
        "responses": {
          "200": {
            "description": "The stream status.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StreamStatus" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/studios/{id}/events": {
      "get": {
        "tags": ["studios"],
        "operationId": "watchStudioEvents",
        "summary": "Stream studio state changes (Server-Sent Events).",
        "description": "The push channel that replaces polling. Open it once and you are told when something changes, instead of asking every few seconds whether anything did — which is what a phone on cellular should not be spending its uplink on while it is also publishing.\n\nRequires scopes stream:read, sources:read and destinations:read — the same three permissions the three reads it carries require. No new scope: a stream that carries exactly what those GETs return needs exactly what they need.\n\n**Format.** `text/event-stream`, one event per frame:\n\n```\nid: 9f2c1a4b7e3d-12\nevent: source\ndata: {\"id\":\"...\",\"publishing\":true, ...}\n```\n\n**Event types and their `data`, which are the SAME shapes the equivalent GETs return:**\n\n| `event` | `data` |\n| --- | --- |\n| `snapshot` | `{ sources: Source[], streamStatus: StreamStatus, destinations: Destination[], chatConnectors: ChatConnector[] }` |\n| `source` | a `Source` — created, changed, or its `publishing` flipped |\n| `streamStatus` | a `StreamStatus` |\n| `destination` | a `Destination` |\n| `chatConnector` | a `ChatConnector` — a linked platform's reconnect pill state changed |\n| `chatConnectorRemoved` | `{ platform }` — a platform was unlinked; drop its connector |\n| `canvas` | a `CanvasState` — the layout mode, active preset/scene, and per-source placements all in one; replace your whole canvas |\n| `audio` | a `StudioAudio` — the master strip plus one strip per source; replace your whole mixer |\n| `sourceRemoved` | `{ id }` — drop that source |\n| `destinationRemoved` | `{ id }` — drop that destination |\n\nApply an event by replacing the row it names. There is no partial-update form: a client that applies every event ends up identical to one that re-read the collections.\n\n**Snapshot first.** The first event on a fresh stream is a `snapshot` of full current state, so opening this stream IS your initial read — you do not need to GET anything to prime yourself.\n\n**Resuming.** Every event carries an opaque `id`. Reconnect with the standard `Last-Event-ID` header (or `?lastEventId=`) set to the last id you processed and the server replays what you missed. The replay window is bounded, so an id the server cannot honour is NOT an error: you get a fresh `snapshot` and carry on. Always send your last id; never fall back to re-GETting the collections. Treat the id as opaque — do not parse or compare it.\n\n**Keepalives.** On an idle studio the server sends SSE comment lines (`: heartbeat <RFC3339>`), which keep cellular NATs and proxies from reaping the connection. Comments are not events; a standard EventSource client ignores them for you. If you write your own client, use them to reset a dead-stream timer.\n\n**Limits.** At most 5 concurrent event streams per credential (429 with `Retry-After` beyond that), and at most 32 concurrent watchers on one studio. The stream ENDS when your access does — deleting the studio or revoking the key terminates it rather than leaving a socket attached to something that is gone.",
        "parameters": [
          { "$ref": "#/components/parameters/StudioId" },
          {
            "name": "Last-Event-ID",
            "in": "header",
            "required": false,
            "description": "The id of the last event you processed, to resume after a dropped connection. An id the server cannot honour yields a fresh snapshot rather than an error, so it is always safe to send.",
            "schema": { "type": "string" }
          },
          {
            "name": "lastEventId",
            "in": "query",
            "required": false,
            "description": "Query alias for the Last-Event-ID header, for clients that cannot set it.",
            "schema": { "type": "string" }
          }
        ],
        "responses": {
          "200": {
            "description": "The event stream. Stays open until the client disconnects or access ends.",
            "content": {
              "text/event-stream": {
                "schema": {
                  "type": "string",
                  "description": "An SSE stream. See the endpoint description for the event vocabulary; each event's `data` is the JSON of a StudioEventSnapshot, Source, StreamStatus, Destination, ChatConnector, CanvasState, StudioAudio, or RemovedResource."
                }
              }
            }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/studios/{id}/go-live": {
      "post": {
        "tags": ["stream"],
        "operationId": "goLive",
        "summary": "Take a studio live.",
        "description": "Requires scope stream:golive. Gated behind PUBLIC_API_WRITE_ENABLED; returns 503 write_disabled while off.",
        "parameters": [ { "$ref": "#/components/parameters/StudioId" } ],
        "responses": {
          "200": {
            "description": "The studio is going live.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GoLiveResult" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/studios/{id}/go-offline": {
      "post": {
        "tags": ["stream"],
        "operationId": "goOffline",
        "summary": "Take a studio offline.",
        "description": "Requires scope stream:golive. Gated behind PUBLIC_API_WRITE_ENABLED; returns 503 write_disabled while off.",
        "parameters": [ { "$ref": "#/components/parameters/StudioId" } ],
        "responses": {
          "200": {
            "description": "The studio is going offline.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GoOfflineResult" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/studios/{id}/chat/messages": {
      "get": {
        "tags": ["chat"],
        "operationId": "listChatMessages",
        "summary": "List a studio's recent unified-chat messages.",
        "description": "Requires scope chat:read. Returns the most recent messages (bounded by limit); no cursor.",
        "parameters": [
          { "$ref": "#/components/parameters/StudioId" },
          { "$ref": "#/components/parameters/Limit" }
        ],
        "responses": {
          "200": {
            "description": "Recent chat messages.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ChatMessageList" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      },
      "post": {
        "tags": ["chat"],
        "operationId": "sendChatMessage",
        "summary": "Send a message into the studio's unified chat.",
        "description": "Requires scope chat:send. Gated behind PUBLIC_API_WRITE_ENABLED. Fans out to the enabled platforms.",
        "parameters": [ { "$ref": "#/components/parameters/StudioId" } ],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SendChatMessageRequest" } } }
        },
        "responses": {
          "201": {
            "description": "The message was accepted for fan-out.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ChatSendResult" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/connections": {
      "get": {
        "tags": ["connections"],
        "operationId": "listConnections",
        "summary": "List the caller's linked platform accounts.",
        "description": "Requires scope connections:read. Metadata only — never tokens or stream keys.",
        "parameters": [
          { "$ref": "#/components/parameters/Cursor" },
          { "$ref": "#/components/parameters/Limit" }
        ],
        "responses": {
          "200": {
            "description": "A page of connections.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConnectionList" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/connections/link": {
      "post": {
        "tags": ["connections"],
        "operationId": "initiateConnection",
        "summary": "Start linking a platform account.",
        "description": "Requires scope `connections:write`. Gated behind PUBLIC_API_WRITE_ENABLED.\n\nReturns a URL to HAND TO A BROWSER. This is the whole of what an API can do here: the authorization itself is a human consenting on the platform's own consent screen, which no server-to-server call can stand in for. Do not follow `authorizationUrl` from your backend.\n\nThere is no completion callback and no push when it lands. After the user consents they are returned to a TRaX page; POLL `GET /v1/connections` and watch for the new row to appear, or for an existing row's `status` to flip back to `active`. `state` is returned so a caller with several attempts in flight can tell which one completed.\n\n`returnTo` must be a TRaX-owned https host — anything else is rejected rather than followed, because this value is a redirect target reached with the user's session. A third-party application cannot receive this redirect.\n\nTO REPAIR A BROKEN CONNECTION, set `reconnectConnectionId` to the existing connection's id. That refreshes the grant IN PLACE and KEEPS THE ID. Without it you get a second connection row, and every destination bound to the old one is orphaned — it will still be listed and will still fail at go-live.",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/LinkStartRequest" } } }
        },
        "responses": {
          "200": {
            "description": "A URL to open in a browser.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/LinkStart" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/connections/manual": {
      "post": {
        "tags": ["connections"],
        "operationId": "createManualConnection",
        "summary": "Save a stream key as a manual connection.",
        "description": "Requires scope `connections:write`. Gated behind PUBLIC_API_WRITE_ENABLED.\n\nStores a pasted RTMP URL + stream key as a MANUAL connection — the path for platforms with no OAuth link (Facebook and Instagram today, plus rumble/twitter/tiktok/custom). The returned connection's `id` is what `CreateDestination.connectionId` takes, so the flow is: save the key here once, then create destinations against it from any studio.\n\nThe stream key is WRITE-ONLY: it is stored encrypted and never returned by any read on this API — the response carries no key material. Note that chat is NOT available on stream-key connections: with no API grant there is nothing to read the platform's chat with.\n\nNot idempotent — a retry saves a second connection.",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateManualConnectionRequest" } } }
        },
        "responses": {
          "201": {
            "description": "The saved manual connection. No key material.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Connection" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/connections/{connectionId}": {
      "patch": {
        "tags": ["connections"],
        "operationId": "updateManualConnection",
        "summary": "Update a manual connection's label, URL or stream key.",
        "description": "Requires scope `connections:write`. Gated behind PUBLIC_API_WRITE_ENABLED.\n\nPatches a MANUAL connection in place: only the fields sent are applied, and an absent or empty `streamKey` leaves the stored key untouched — so a label rename never has to re-enter the key. Rotating a key on the platform is a PATCH with the new `streamKey`; existing destinations bound to this connection pick it up at the next go-live.\n\nOnly valid on `connectionType: \"manual\"` rows — an OAuth connection answers 400 `invalid_request` (its credentials are managed by the platform link, not by hand). A connection that is missing and one that belongs to another user are both 404, deliberately indistinguishable.",
        "parameters": [ { "$ref": "#/components/parameters/ConnectionId" } ],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpdateManualConnectionRequest" } } }
        },
        "responses": {
          "200": {
            "description": "The updated connection. No key material.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Connection" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      },
      "get": {
        "tags": ["connections"],
        "operationId": "getConnection",
        "summary": "Read one linked account.",
        "description": "Requires scope `connections:read`. A connection that is missing and one that belongs to another user are both 404 — the two are deliberately indistinguishable.",
        "parameters": [ { "$ref": "#/components/parameters/ConnectionId" } ],
        "responses": {
          "200": {
            "description": "The connection.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Connection" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      },
      "delete": {
        "tags": ["connections"],
        "operationId": "deleteConnection",
        "summary": "Unlink a connection.",
        "description": "Requires scope `connections:write`. Gated behind PUBLIC_API_WRITE_ENABLED.\n\nRevokes the grant at the platform and THEN deletes the stored tokens. The revoke is best-effort: a provider that is down or slow does not block the delete, and two providers (LinkedIn, Trovo) expose no revoke endpoint at all, so for those the token remains valid at the provider until it expires naturally. The row and the tokens are gone from TRaX either way.\n\nDestructive beyond the connection itself: destinations bound to it lose their credential source and will fail to resolve at the next go-live. Find them first with `connectionId` on `GET /v1/studios/{id}/destinations`.",
        "parameters": [ { "$ref": "#/components/parameters/ConnectionId" } ],
        "responses": {
          "200": {
            "description": "The connection was unlinked.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Ok" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/storage-configs": {
      "get": {
        "tags": ["storage"],
        "operationId": "listStorageConfigs",
        "summary": "List your connected object-storage buckets.",
        "description": "Requires scope `storage:read`, which is NOT in the default key scope set — a key must ask for it by name.\n\nOwner-scoped, like connections: a bucket belongs to a person and is usable from any of their studios, so nothing here names one. Credentials are never returned.\n\n503 `storage_disabled` when object storage is not configured on this deployment. That is deliberately not an empty list: \"you have no buckets\" and \"we cannot answer\" are different claims.",
        "parameters": [
          { "$ref": "#/components/parameters/Cursor" },
          { "$ref": "#/components/parameters/Limit" }
        ],
        "responses": {
          "200": {
            "description": "A page of storage configs.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StorageConfigList" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      },
      "post": {
        "tags": ["storage"],
        "operationId": "createStorageConfig",
        "summary": "Connect a bucket.",
        "description": "Requires scope `storage:write`. Gated behind PUBLIC_API_WRITE_ENABLED, and requires an active subscription.\n\nStores the config and probes the bucket inline. A FAILING PROBE STILL CREATES THE ROW and still answers 201 — read `status`, not the HTTP code. That is deliberate: a typo'd endpoint should leave you something editable rather than nothing.\n\nCredentials are encrypted before they touch storage and cannot be read back through any surface.",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StorageWrite" } } }
        },
        "responses": {
          "201": {
            "description": "Created. Check `status` for whether the bucket actually answered.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StorageConfig" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/storage-configs/probe": {
      "post": {
        "tags": ["storage"],
        "operationId": "probeStorageConfig",
        "summary": "Test credentials without storing them.",
        "description": "Requires scope `storage:write`. Gated behind PUBLIC_API_WRITE_ENABLED.\n\nThe dry run behind a \"test connection\" button: no row is created, nothing is encrypted, nothing is persisted, and the credentials are discarded when the call returns. Use it before create so a bad bucket is caught BEFORE a row exists.\n\nA bucket that refuses the credentials is a 200 with a non-ok `status`, not a 4xx — the probe SUCCEEDED, it found out the answer. Only malformed input is a client error.",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StorageWrite" } } }
        },
        "responses": {
          "200": {
            "description": "The probe verdict. Read `status`.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StorageProbeResult" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/storage-configs/{configId}": {
      "get": {
        "tags": ["storage"],
        "operationId": "getStorageConfig",
        "summary": "Read one storage config.",
        "description": "Requires scope `storage:read`. A config that is missing and one belonging to another user are both 404.",
        "parameters": [ { "$ref": "#/components/parameters/ConfigId" } ],
        "responses": {
          "200": {
            "description": "The storage config.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StorageConfig" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      },
      "patch": {
        "tags": ["storage"],
        "operationId": "updateStorageConfig",
        "summary": "Edit a storage config.",
        "description": "Requires scope `storage:write`. Gated behind PUBLIC_API_WRITE_ENABLED, and requires an active subscription.\n\nPartial: an omitted field is left alone. Credentials are BOTH-OR-NEITHER — send `accessKeyId` and `secretAccessKey` together to rotate, or send neither. Re-probes after writing, with the same rule as create: the row survives a failed probe and the verdict lands in `status`.",
        "parameters": [ { "$ref": "#/components/parameters/ConfigId" } ],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StorageWrite" } } }
        },
        "responses": {
          "200": {
            "description": "The updated storage config.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StorageConfig" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      },
      "delete": {
        "tags": ["storage"],
        "operationId": "deleteStorageConfig",
        "summary": "Disconnect a bucket.",
        "description": "Requires scope `storage:write`. Gated behind PUBLIC_API_WRITE_ENABLED.\n\nSOFT delete: TRaX stops using the bucket and it disappears from every list, but NOTHING IN YOUR BUCKET IS TOUCHED and the stored credentials are retained encrypted rather than scrubbed.\n\nGUARDED: 409 `conflict` when imported media assets still resolve through this config, naming how many would be stranded. Pass `?force=true` to delete anyway — those assets degrade to unresolvable rather than disappearing.",
        "parameters": [
          { "$ref": "#/components/parameters/ConfigId" },
          { "name": "force", "in": "query", "required": false, "schema": { "type": "string", "enum": ["true"] }, "description": "Delete even though imported media still references this config." }
        ],
        "responses": {
          "200": {
            "description": "The config was disconnected.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Ok" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/storage-configs/{configId}/verify": {
      "post": {
        "tags": ["storage"],
        "operationId": "verifyStorageConfig",
        "summary": "Re-probe a stored bucket.",
        "description": "Requires scope `storage:write`. Gated behind PUBLIC_API_WRITE_ENABLED, and requires an active subscription.\n\nRe-runs the probe and writes the fresh verdict onto the row. The way to clear a stale `forbidden` after fixing a bucket policy, without re-entering credentials nobody can read back.\n\nAs with create, a failed probe is reported in `status`, not as an error.",
        "parameters": [ { "$ref": "#/components/parameters/ConfigId" } ],
        "responses": {
          "200": {
            "description": "The config with its refreshed status.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StorageConfig" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/media": {
      "get": {
        "tags": ["media"],
        "operationId": "listMedia",
        "summary": "List the caller's media-library assets.",
        "description": "Requires scope `media:read`.\n\nThe library belongs to the USER, not to a studio, so nothing here names one. Rows are cursor-paginated and carry a thumbnail; the file itself is read one asset at a time through `GET /v1/media/{assetId}`, because handing out object access on a list response is a different grant from saying what exists — and list responses are what end up in logs.\n\n`quotaUsedBytes` / `quotaLimitBytes` ride along so you can render usage and refuse an over-large pick client-side, instead of finding the ceiling when an upload comes back 402.\n\nOnly READY assets appear. An upload in flight is not listed — poll it by id.",
        "parameters": [
          { "$ref": "#/components/parameters/Cursor" },
          { "$ref": "#/components/parameters/Limit" }
        ],
        "responses": {
          "200": {
            "description": "A page of media assets, plus the storage allowance.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MediaAssetList" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      },
      "post": {
        "tags": ["media"],
        "operationId": "createMediaUpload",
        "summary": "Start an upload — returns a presigned PUT or a multipart plan.",
        "description": "Requires scope `media:write`. Gated behind PUBLIC_API_WRITE_ENABLED.\n\nStep 1 of THREE. This reserves the asset row and hands back the plan for pushing the bytes; you upload them yourself, straight to storage; then `POST /v1/media/{assetId}/complete` makes the upload real. An upload that stops after step 2 has produced nothing anybody can use — the row sits in UPLOADING, holding quota, attachable to nothing.\n\nBRANCH ON `mode`.\n\n* `single` — PUT the whole file to `uploadUrl`, then call `/complete` with no body.\n* `multipart` — no `uploadUrl` at all. PUT each entry of `parts` at its own `offset`/`byteSize` to its own `url`, keep the `ETag` response header each PUT returns, then call `/complete` with the full list. Parts may go up in any order; three in flight is a good number, and each is worth a couple of retries. Cut the file by the plan, never by a part size of your own — the server may have grown it to stay under the storage layer's part-count ceiling.\n\nThe reason large files come back as a plan: every request to our storage host crosses a CDN edge that caps ONE request body at 100 MB, and it enforces that at the edge — a 150 MB single PUT dies at 413 after about 2 MB. Parts are sized to stay under that cap, so the ceiling on a file is the account's quota, not the edge. The single/multipart split point is server policy and it moves; that is why you branch on `mode` rather than on a size of your own.\n\nNot idempotent: calling this twice creates two assets and reserves quota twice. There is no dedupe on filename or content.\n\n`byteSize` is checked against the remaining quota BEFORE any URL is issued, so an upload that cannot fit is refused here (402 `storage_quota_exceeded`) rather than after the bytes are sent. `contentType` decides the asset's `kind` and must be one we accept — `video/mp4`, `video/webm`, `video/quicktime`, `image/jpeg`, `image/png`, `image/webp`, `image/gif`, `audio/mpeg`, `audio/wav`, `audio/ogg`, `audio/aac`.",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateMediaUploadRequest" } } }
        },
        "responses": {
          "201": {
            "description": "An upload was reserved. Read `mode` before anything else.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MediaUpload" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/media/{assetId}": {
      "get": {
        "tags": ["media"],
        "operationId": "getMediaAsset",
        "summary": "Read one asset, with its playback URLs.",
        "description": "Requires scope `media:read`.\n\nThis is the read you POLL after completing an upload, and the only place on `/v1` that hands out access to the stored file. Watch `status`: an asset is usable when it reads READY. A large upload is assembled and probed server-side, so READY can lag `/complete` by a moment — poll every second or two, and stop.\n\n`thumbnailUrl` and `downloadUrl` are presigned for the PUBLIC storage host, so they resolve from a phone. Both are absent unless the asset is READY, and `thumbnailUrl` is best-effort even then — an image or an audio file may simply not have one, which is a kind icon on your side, not an error.\n\nEvery URL here EXPIRES. Treat them as render-time values: fetch, use, discard. Storing one in your database produces a link that works in testing and 403s a day later.\n\nAn assetId belonging to anyone else answers 404 `not_found`, identical to one that does not exist.",
        "parameters": [ { "$ref": "#/components/parameters/AssetId" } ],
        "responses": {
          "200": {
            "description": "The asset.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MediaAssetDetail" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      },
      "delete": {
        "tags": ["media"],
        "operationId": "deleteMediaAsset",
        "summary": "Delete an asset and release its storage.",
        "description": "Requires scope `media:write`. Gated behind PUBLIC_API_WRITE_ENABLED.\n\nDESTRUCTIVE and not undoable: the stored file and its thumbnail go with the row, and the quota comes back.\n\nIt is REFUSED with 409 `conflict` while any studio source still uses the asset, and the message says how many. That is not politeness — a source pointing at a deleted file renders a permanent \"unavailable\" card, and if that studio is live the encoder's next read on the file fails and the input drops to a placeholder mid-show. Delete the sources first, then the asset.\n\nFor an upload still in flight use `/abort` instead; this endpoint is about finished library content.",
        "parameters": [ { "$ref": "#/components/parameters/AssetId" } ],
        "responses": {
          "200": {
            "description": "The asset is gone and its storage is released.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Ok" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/media/{assetId}/complete": {
      "post": {
        "tags": ["media"],
        "operationId": "completeMediaUpload",
        "summary": "Finish an upload — the step that makes it real.",
        "description": "Requires scope `media:write`. Gated behind PUBLIC_API_WRITE_ENABLED.\n\nStep 3 of three, and the one that is easy to skip. Until it runs the asset stays UPLOADING, counts against quota, and can be attached to nothing; on the multipart path it is also the ASSEMBLY call — the parts are held separately and no whole object exists until this reassembles them.\n\nSingle-file upload: send no body.\n\nMultipart upload: send every part with the `ETag` its PUT returned. Order does not matter; completeness does. If you could not read the ETags — a browser cannot unless the storage host exposes the header through CORS — send the parts with empty etags, or send no body at all, and the server recovers them from storage itself. That fallback is why a missing ETag is not fatal.\n\nIDEMPOTENT. An already-completed asset comes back unchanged, so a client that lost the response to a dropped connection should simply call again rather than re-uploading.\n\nThe response is the asset. `status` may read READY immediately or lag by a moment while the file is probed and a thumbnail is made — poll `GET /v1/media/{assetId}` rather than assuming.\n\nThe bytes in storage are the authority, not what you declared: an upload larger than the `byteSize` you reserved is rejected and deleted here rather than quietly eating the quota.",
        "parameters": [ { "$ref": "#/components/parameters/AssetId" } ],
        "requestBody": {
          "required": false,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CompleteMediaUploadRequest" } } }
        },
        "responses": {
          "200": {
            "description": "The finished asset.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MediaAssetDetail" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/media/{assetId}/abort": {
      "post": {
        "tags": ["media"],
        "operationId": "abortMediaUpload",
        "summary": "Cancel an upload in flight and give the quota back.",
        "description": "Requires scope `media:write`. Gated behind PUBLIC_API_WRITE_ENABLED.\n\nCall it when a user cancels, or when an upload has failed past retrying. Every part already written is discarded and the reserved row is deleted.\n\nThe quota is the reason this matters. An abandoned upload does not appear in `GET /v1/media` — that lists finished assets — and it still occupies the ceiling, so a client that abandons uploads without aborting them slowly locks its own user out of uploading anything. There is a server-side sweeper, but it runs on the order of hours.\n\nIDEMPOTENT, and NOT a delete: an upload that is already gone is a success, while a FINISHED asset is refused with 409 `conflict`. Removing finished content is `DELETE /v1/media/{assetId}`; a mistimed cancel must not become data loss.",
        "parameters": [ { "$ref": "#/components/parameters/AssetId" } ],
        "responses": {
          "200": {
            "description": "The upload is discarded and its quota released.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Ok" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/viewer-tokens": {
      "post": {
        "tags": ["embed"],
        "operationId": "mintViewerToken",
        "summary": "Mint a browser-safe credential for one of your own members.",
        "description": "Requires scope viewer:tokens. Gated behind PUBLIC_API_WRITE_ENABLED.\n\nCall this from YOUR SERVER, never from a browser — it is authenticated with your secret API key. You have already authenticated your own member and decided what they may do; this exchanges that decision for a short-lived token you hand to that member's page, where it is safe.\n\n`memberRef` is YOUR identifier for the person and stays opaque to TRaX: it is never resolved to anyone, and exists only so the token can be rate-limited and revoked. It is REQUIRED whenever `caps` contains a write capability, because a relayed chat message reaches the broadcaster's connected platform accounts and must be attributable to someone you vouched for and can stop vouching for.\n\n`ttlSeconds` is clamped server-side (default 900, maximum 3600) and the response carries the real expiry. Revocation of a cancelled membership IS token expiry, so re-mint on a schedule rather than asking for a longer life.\n\nThe response echoes the capabilities actually GRANTED, which may be narrower than requested. Render your interface from those — a composer drawn from a capability you merely hoped for is a control that fails on submit.",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MintViewerTokenRequest" } } }
        },
        "responses": {
          "201": {
            "description": "A viewer token was minted.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ViewerToken" } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    }
  }
}
