{
  "openapi": "3.1.0",
  "info": {
    "title": "AssetFlow API",
    "version": "1.4.0",
    "summary": "Katalyst's self-hosted asset CDN: upload once, get a stable public URL.",
    "description": "AssetFlow stores media in Cloudflare R2 and serves it from `katalyst-iceberg-delivery.juliavictorio16.workers.dev`. The API (this document) lives on `assetflow-production-fcf6.up.railway.app` and owns auth, the asset catalog, presigned uploads, trash and retention. Bytes never pass through the API on upload or download.\n\n## The one rule\n\n**Store `id`; render `delivery_url` for public assets; never build a URL.** Private asset bodies omit `delivery_url`. Use authenticated, expiring delivery links for private source reads. Do not read `StorageKey`, do not join a host with a key, do not derive anything from the bucket layout. The layout is not a contract; it changed on 2026-09-18 and broke every client that built its own URLs.\n\n## Delivery URL forms\n\n1. **Canonical: `https://katalyst-iceberg-delivery.juliavictorio16.workers.dev/a/{id}`.** What `delivery_url` returns. Served by the edge Worker with no extra hop.\n2. **Legacy, kept permanently: `https://katalyst-iceberg-delivery.juliavictorio16.workers.dev/{tenant}/{key}`** (e.g. `/t1/hero/welcome.webp`). Every pre-2026-09-04 path is a durable alias. The Worker resolves it through the delivery authority and answers `308` to `/a/{id}`, so each image costs one authority round trip plus one redirect. New integrations must not create more of these.\n3. **Compatibility only, retired 2026-12-31: `https://katalyst-iceberg-delivery.juliavictorio16.workers.dev/objects/{id}`.** `308` to `/a/{id}` until then, `404 retired-route` after. Backfill stored HTML to `delivery_url` before that date.\n\nDerived media hang off the canonical form and are served by the same Worker:\n\n- `https://katalyst-iceberg-delivery.juliavictorio16.workers.dev/a/{id}/poster.jpg` (video poster frame)\n- `https://katalyst-iceberg-delivery.juliavictorio16.workers.dev/a/{id}/hls/master.m3u8` (HLS playlist)\n- `https://katalyst-iceberg-delivery.juliavictorio16.workers.dev/a/{id}/variants/{width}` (resized image; widths 256, 1024 and 2048 are generated for optimizable images at upload)\n- `https://katalyst-iceberg-delivery.juliavictorio16.workers.dev/a/{id}/playback.json` (poster and HLS URLs for the published bundle)\n- `https://katalyst-iceberg-delivery.juliavictorio16.workers.dev/a/{id}?download=1` (same bytes with `Content-Disposition: attachment`; what `POST /assets/presign-download` returns)\n\nA trashed asset answers `404` at every form until restored; the URL itself never changes.\n\n## Authentication\n\nMint an API token in the dashboard (or `POST /auth/tokens` from a session) and send `Authorization: Bearer kic_…` on every call. The dashboard uses the `katalyst_session` cookie set by `POST /auth/login`; both work on every authenticated route.\n\n## Errors\n\nEvery error is JSON: `{\"error\": \"<code or message>\"}`. Query-parameter errors add `param` (the offending key) and `detail` (what was expected). Permanent-delete refusals use fixed codes: `asset_in_retention` (409, with `eligible_at`), `purge_capability_required` (403), `purge_throttled` (429, with `Retry-After`).\n\n## Tenancy\n\nOne account may have explicit memberships in several tenants, with a separate role in each. A dashboard session uses one active tenant at a time; switch it through `POST /auth/active-tenant`. API tokens stay bound to the tenant selected when they were created. A `super_admin` has no active tenant and must pass `tenant_id` on tenant-scoped calls.\n\n## Versioning\n\nThis document is `info.version` 1.4.0 and follows the CHANGELOG. A field or URL form is never removed outside a major version. `StorageKey` is deprecated and leaves the body 60 days after the 1.0.0 release.",
    "contact": {
      "name": "Katalyst",
      "url": "https://github.com/Jvictorio09/asset-flow"
    }
  },
  "servers": [
    {
      "url": "https://assetflow-production-fcf6.up.railway.app",
      "description": "Production API"
    }
  ],
  "tags": [
    {
      "name": "Auth",
      "description": "Sessions, API tokens and the purge elevation."
    },
    {
      "name": "OAuth",
      "description": "OAuth endpoints used by installed MCP clients. Discover the complete authorization-server surface from `/.well-known/oauth-authorization-server`."
    },
    {
      "name": "Uploads",
      "description": "Presigned direct-to-R2 upload flow and the legacy multipart path."
    },
    {
      "name": "Assets",
      "description": "List, inspect, replace and roll back assets."
    },
    {
      "name": "Analytics",
      "description": "Privacy-preserving tenant delivery and anonymous playback aggregates."
    },
    {
      "name": "Tags",
      "description": "Free-form tags on assets."
    },
    {
      "name": "Trash",
      "description": "Soft delete, restore, and permanent deletion behind the retention window."
    },
    {
      "name": "Audit",
      "description": "Who did what, per tenant."
    },
    {
      "name": "Folders",
      "description": "The display tree assets are listed under. A folder is a label, never part of the delivery URL."
    }
  ],
  "security": [
    {
      "bearerToken": []
    },
    {
      "sessionCookie": []
    }
  ],
  "paths": {
    "/oauth/revoke": {
      "post": {
        "tags": [
          "OAuth"
        ],
        "summary": "Revoke an installed OAuth client's grant",
        "operationId": "revokeOAuthToken",
        "security": [],
        "description": "RFC 7009 token revocation. Send either an access or refresh token. AssetFlow atomically invalidates both token types belonging to the same grant. Public DCR clients send `client_id`; confidential clients use HTTP Basic authentication. A token that is unknown, already revoked, or not owned by the authenticated client receives the same empty `200` response and is never disclosed. Operational storage failures return `503 temporarily_unavailable`; retain the credentials and retry. Discover this URL from `revocation_endpoint` in authorization-server metadata.",
        "requestBody": {
          "required": true,
          "content": {
            "application/x-www-form-urlencoded": {
              "schema": {
                "type": "object",
                "required": [
                  "token"
                ],
                "properties": {
                  "token": {
                    "type": "string",
                    "description": "The opaque access or refresh token to revoke."
                  },
                  "token_type_hint": {
                    "type": "string",
                    "examples": [
                      "access_token",
                      "refresh_token"
                    ],
                    "description": "Optional lookup hint. AssetFlow searches both supported token types if the hint is wrong or omitted."
                  },
                  "client_id": {
                    "type": "string",
                    "description": "Required for public DCR clients. Confidential clients authenticate with HTTP Basic instead."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Revoked or already absent. The response body is empty so token existence is not revealed."
          },
          "400": {
            "description": "Malformed form request.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OAuthError"
                }
              }
            }
          },
          "401": {
            "description": "Missing, unknown, or invalid client authentication.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OAuthError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "503": {
            "description": "Revocation could not be completed atomically. Retain the credentials and retry later.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OAuthError"
                },
                "examples": {
                  "temporary storage failure": {
                    "value": {
                      "error": "temporarily_unavailable",
                      "error_description": "The authorization server is currently unable to handle the request due to a temporary overloading or maintenance of the server."
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/auth/katek/start": {
      "get": {
        "tags": [
          "Auth"
        ],
        "summary": "Start a flag-gated Katek OIDC login or account link",
        "operationId": "startKatekOIDC",
        "security": [],
        "description": "Available only when central identity is enabled. Uses authorization code + PKCE, nonce, state and a fresh IdP login. An existing host-only AssetFlow session changes the flow into an explicit link attempt; the callback still enforces the same verified email and a fresh legacy session.",
        "parameters": [
          {
            "name": "next",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Optional same-origin relative path after login. Unsafe values are discarded."
          }
        ],
        "responses": {
          "302": {
            "description": "Redirect to the configured Katek authorization endpoint and set a host-only HttpOnly transaction cookie."
          },
          "401": {
            "description": "An attempted link did not carry a valid live legacy session."
          },
          "404": {
            "description": "Central identity is disabled."
          }
        }
      }
    },
    "/auth/katek/callback": {
      "get": {
        "tags": [
          "Auth"
        ],
        "summary": "Complete a flag-gated Katek OIDC login or account link",
        "operationId": "completeKatekOIDC",
        "security": [],
        "description": "Consumes the signed transaction cookie, verifies state, PKCE, nonce, exact issuer, signature, audience and expiry, then resolves an existing issuer/subject link. New links additionally require the same verified email and a fresh legacy session. Tenant roles stay local and product entitlement is checked fail-closed through the tenant registry.",
        "parameters": [
          {
            "name": "code",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "state",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "302": {
            "description": "Set the ordinary host-only AssetFlow session cookie and redirect to the safe local continuation."
          },
          "400": {
            "description": "Missing or invalid callback transaction."
          },
          "401": {
            "description": "OIDC verification, mapping or linking failed."
          },
          "404": {
            "description": "Central identity is disabled."
          },
          "503": {
            "description": "Product entitlement could not be verified."
          }
        }
      }
    },
    "/auth/login": {
      "post": {
        "tags": [
          "Auth"
        ],
        "summary": "Sign in and receive a session cookie",
        "operationId": "login",
        "security": [],
        "description": "Sets the `katalyst_session` cookie (HttpOnly, 12 h). Machine clients should mint an API token instead and never call this.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/LoginRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Signed in. `can_purge` is always `false` here.",
            "headers": {
              "Set-Cookie": {
                "schema": {
                  "type": "string"
                },
                "description": "`katalyst_session=…; Path=/; HttpOnly; SameSite=Lax`"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Account"
                }
              }
            }
          },
          "400": {
            "description": "Body is not JSON.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "invalid json": {
                    "value": {
                      "error": "invalid json"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Wrong email or password.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "invalid credentials": {
                    "value": {
                      "error": "invalid credentials"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/auth/logout": {
      "post": {
        "tags": [
          "Auth"
        ],
        "summary": "Clear the session cookie",
        "operationId": "logout",
        "security": [],
        "description": "Idempotent. Has no effect on API tokens; revoke those with `DELETE /auth/tokens/{id}`.",
        "responses": {
          "204": {
            "description": "Cookie cleared."
          }
        }
      }
    },
    "/auth/whoami": {
      "get": {
        "tags": [
          "Auth"
        ],
        "summary": "The account behind this credential",
        "operationId": "whoami",
        "description": "Use it to check a token works and to read `can_purge` before attempting a permanent delete.",
        "responses": {
          "200": {
            "description": "The authenticated account.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Account"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/auth/purge-session": {
      "post": {
        "tags": [
          "Auth"
        ],
        "summary": "Elevate the session for permanent deletes",
        "operationId": "purgeSession",
        "description": "Re-checks the password and re-issues the session cookie with a 15 minute `purge_until` claim. `owner` and `super_admin` only. Dashboard flow; API clients use a `can_purge` token instead.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PurgeSessionRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Elevated.",
            "headers": {
              "Set-Cookie": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PurgeSessionResponse"
                }
              }
            }
          },
          "400": {
            "description": "Body is not JSON.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "invalid json": {
                    "value": {
                      "error": "invalid json"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Password wrong, or not authenticated.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "invalid credentials": {
                    "value": {
                      "error": "invalid credentials"
                    }
                  }
                }
              }
            }
          },
          "403": {
            "description": "Role may not hold the purge capability.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "forbidden": {
                    "value": {
                      "error": "forbidden"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/auth/tokens": {
      "post": {
        "tags": [
          "Auth"
        ],
        "summary": "Mint an API token",
        "operationId": "createToken",
        "description": "The plaintext `token` is in this response exactly once; the server keeps only a hash. Usually called from a dashboard session.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateTokenRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Token minted.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TokenCreated"
                }
              }
            }
          },
          "400": {
            "description": "Body is not JSON, or `name` is empty.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "invalid json": {
                    "value": {
                      "error": "invalid json"
                    }
                  },
                  "token name required": {
                    "value": {
                      "error": "token name required"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "`can_purge: true` from a role that may not hold it.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "forbidden": {
                    "value": {
                      "error": "forbidden"
                    }
                  }
                }
              }
            }
          }
        }
      },
      "get": {
        "tags": [
          "Auth"
        ],
        "summary": "List the caller's API tokens",
        "operationId": "listTokens",
        "description": "Only tokens owned by the calling account. Plaintext is never returned.",
        "responses": {
          "200": {
            "description": "Tokens, without secrets.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Token"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/auth/tokens/{id}": {
      "delete": {
        "tags": [
          "Auth"
        ],
        "summary": "Revoke an API token",
        "operationId": "revokeToken",
        "description": "Scoped to the calling account: another account's token id answers `404`.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Revoked."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "Not one of the caller's tokens.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "token not found": {
                    "value": {
                      "error": "token not found"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/assets/init-upload": {
      "post": {
        "tags": [
          "Uploads"
        ],
        "summary": "Step 1 of 3: reserve a key and get a presigned PUT URL",
        "operationId": "initUpload",
        "description": "Creates a `pending` asset row and returns a presigned R2 URL (60 min). The response carries the final `id` and, for public assets, `delivery_url`; store them now. Then `PUT` the bytes to `upload_url` with the same `Content-Type`, and call `POST /assets/complete`. A stale pending row is swept after the URL expires.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/InitUploadRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Key reserved. Nothing is served until `complete` succeeds.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/InitUploadResponse"
                }
              }
            }
          },
          "400": {
            "description": "Body is not JSON, `key` is missing, or the key is invalid (leading slash, reserved `.versions/` segment).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "invalid json": {
                    "value": {
                      "error": "invalid json"
                    }
                  },
                  "key is required": {
                    "value": {
                      "error": "key is required"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "description": "A `ready` asset already has this key in the tenant. Use `POST /assets/replace` to overwrite bytes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "asset already exists": {
                    "value": {
                      "error": "asset already exists"
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/assets/complete": {
      "post": {
        "tags": [
          "Uploads"
        ],
        "summary": "Step 3 of 3: promote the pending asset to ready",
        "operationId": "completeUpload",
        "description": "AssetFlow HEADs storage to confirm the object landed and records the real size and ETag. The returned asset is `ready`; public assets start serving at `delivery_url`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CompleteUploadRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Asset is ready.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Asset"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No pending or ready asset with that key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "asset not found": {
                    "value": {
                      "error": "asset not found"
                    }
                  }
                }
              }
            }
          },
          "409": {
            "description": "The PUT has not landed in storage yet. Retry the PUT, or wait and call `complete` again.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "object not in storage yet — retry the upload PUT": {
                    "value": {
                      "error": "object not in storage yet — retry the upload PUT"
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/assets": {
      "post": {
        "tags": [
          "Uploads"
        ],
        "summary": "Legacy multipart upload through the API",
        "operationId": "uploadAsset",
        "description": "Bytes pass through the API. Kept for small files and backwards compatibility; new integrations should use `init-upload` + `complete`. Subject to the configured size cap (`413` above it).",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [
                  "file",
                  "key"
                ],
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary"
                  },
                  "key": {
                    "type": "string"
                  },
                  "content_type": {
                    "type": "string",
                    "description": "Defaults to the file part's `Content-Type`, then `application/octet-stream`."
                  },
                  "tenant_id": {
                    "type": "string",
                    "description": "`super_admin` only."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Stored and ready.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Asset"
                }
              }
            }
          },
          "400": {
            "description": "Not a multipart form, `file` part missing, `key` missing, or key invalid.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "invalid multipart form": {
                    "value": {
                      "error": "invalid multipart form"
                    }
                  },
                  "file part missing": {
                    "value": {
                      "error": "file part missing"
                    }
                  },
                  "key is required": {
                    "value": {
                      "error": "key is required"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "description": "Key already exists.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "asset already exists": {
                    "value": {
                      "error": "asset already exists"
                    }
                  }
                }
              }
            }
          },
          "413": {
            "description": "Body exceeds the upload cap.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "upload exceeds maximum allowed size": {
                    "value": {
                      "error": "upload exceeds maximum allowed size"
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "get": {
        "tags": [
          "Assets"
        ],
        "summary": "List one page of assets",
        "operationId": "listAssets",
        "description": "Newest first, filtered in the database. The body is a bare JSON array (frozen contract); paging rides in headers, GitHub-style. Any query parameter not listed here is `400 unknown_query_param`, never silently ignored. `pending` rows are excluded unless `status=pending`.\n\nWalk a tenant: repeat the same query with `after=<X-Iceberg-Next-Cursor>` until the header is absent, or follow `Link: <…>; rel=\"next\"` verbatim.",
        "parameters": [
          {
            "name": "tenant_id",
            "in": "query",
            "required": false,
            "description": "Tenant to act on. Only `super_admin` may name a tenant other than its own; every other role is scoped to its own tenant and can leave this out. A `super_admin` that omits it gets `400 tenant_id is required`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 1000,
              "default": 100
            },
            "description": "Rows per page. Above 1000 is `400`, never a truncated page."
          },
          {
            "name": "prefix",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Keys starting with this string (NFC and NFD spellings both match)."
          },
          {
            "name": "folder",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Exact `AssetFolder` match. Send `folder=` (empty) for the root. Leading and trailing `/` trimmed."
          },
          {
            "name": "content_type",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Content types starting with this, e.g. `video/` or `image/webp`."
          },
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "ready",
                "pending",
                "trashed"
              ],
              "default": "ready"
            }
          },
          {
            "name": "after",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Id of the last asset on the previous page (`X-Iceberg-Next-Cursor`). Resumes strictly after it in `(created_at DESC, id DESC)` order, so rows trashed or deleted mid-walk neither repeat nor skip. An id not in the tenant is `400`."
          }
        ],
        "responses": {
          "200": {
            "description": "One page, newest first. Empty array when nothing matches.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Asset"
                  }
                }
              }
            },
            "headers": {
              "Link": {
                "schema": {
                  "type": "string"
                },
                "description": "`<https://assetflow-production-fcf6.up.railway.app/assets?…&after=<last id>>; rel=\"next\"`. Absent on the final page."
              },
              "X-Iceberg-Next-Cursor": {
                "schema": {
                  "type": "string"
                },
                "description": "The last id on this page; pass as `after`. Absent on the final page."
              },
              "X-Iceberg-Filters": {
                "schema": {
                  "type": "string"
                },
                "description": "The filters actually applied, URL-encoded, e.g. `limit=100&prefix=hero%2F&status=ready&tenant_id=t1`. Assert on it before acting on the list."
              }
            }
          },
          "400": {
            "description": "Bad query. `param` names the key, `detail` says what was expected.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "unknown_query_param": {
                    "value": {
                      "error": "unknown_query_param",
                      "param": "q",
                      "detail": "query parameter is not recognised on this endpoint"
                    }
                  },
                  "invalid_limit": {
                    "value": {
                      "error": "invalid_query_param",
                      "param": "limit",
                      "detail": "limit must be an integer between 1 and 1000"
                    }
                  },
                  "invalid_status": {
                    "value": {
                      "error": "invalid_query_param",
                      "param": "status",
                      "detail": "status must be ready, pending or trashed"
                    }
                  },
                  "invalid_after": {
                    "value": {
                      "error": "invalid_query_param",
                      "param": "after",
                      "detail": "after must be the id of an asset in this tenant"
                    }
                  },
                  "tenant_required": {
                    "value": {
                      "error": "tenant_id is required"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/assets/summary": {
      "get": {
        "tags": [
          "Assets"
        ],
        "summary": "Tenant storage summary",
        "operationId": "assetSummary",
        "description": "Totals, breakdown by content type, the largest and oldest assets (full asset bodies), monthly growth, and stale counts. Cost is deliberately not included.",
        "parameters": [
          {
            "name": "tenant_id",
            "in": "query",
            "required": false,
            "description": "Tenant to act on. Only `super_admin` may name a tenant other than its own; every other role is scoped to its own tenant and can leave this out. A `super_admin` that omits it gets `400 tenant_id is required`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Summary.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AssetSummary"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/assets/replace": {
      "post": {
        "tags": [
          "Assets"
        ],
        "summary": "Overwrite an asset's bytes in place",
        "operationId": "replaceAsset",
        "description": "Same key, same `id`, same `delivery_url`. The previous bytes are archived as a version (see `GET /assets/versions`). Multipart, like the legacy upload.",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [
                  "file",
                  "key"
                ],
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary"
                  },
                  "key": {
                    "type": "string"
                  },
                  "content_type": {
                    "type": "string"
                  },
                  "tenant_id": {
                    "type": "string",
                    "description": "`super_admin` only."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Replaced. Same id and URL.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Asset"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/AssetNotFound"
          },
          "413": {
            "description": "Body exceeds the upload cap.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "upload exceeds maximum allowed size": {
                    "value": {
                      "error": "upload exceeds maximum allowed size"
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/assets/versions": {
      "get": {
        "tags": [
          "Assets"
        ],
        "summary": "Version history of an asset",
        "operationId": "listVersions",
        "description": "Newest first. Only `key` and `tenant_id` are accepted as query parameters.",
        "parameters": [
          {
            "name": "key",
            "in": "query",
            "required": true,
            "description": "Tenant-relative asset key, e.g. `hero/welcome.webp`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "tenant_id",
            "in": "query",
            "required": false,
            "description": "Tenant to act on. Only `super_admin` may name a tenant other than its own; every other role is scoped to its own tenant and can leave this out. A `super_admin` that omits it gets `400 tenant_id is required`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Versions, newest first.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/AssetVersion"
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/AssetNotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/assets/rollback": {
      "post": {
        "tags": [
          "Assets"
        ],
        "summary": "Serve a previous version's bytes at the live key",
        "operationId": "rollbackAsset",
        "description": "Append-only: the current bytes are archived as a new version, then the chosen version is copied to the live key. `id` and `delivery_url` do not change.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RollbackRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Rolled back.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Asset"
                }
              }
            }
          },
          "400": {
            "description": "Body is not JSON or a field is missing.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "invalid json": {
                    "value": {
                      "error": "invalid json"
                    }
                  },
                  "key and version_id are required": {
                    "value": {
                      "error": "key and version_id are required"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "Asset or version not found.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "asset not found": {
                    "value": {
                      "error": "asset not found"
                    }
                  },
                  "version not found": {
                    "value": {
                      "error": "version not found"
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/assets/presign-download": {
      "post": {
        "tags": [
          "Assets"
        ],
        "summary": "Get a download URL for an asset",
        "operationId": "presignDownload",
        "description": "Public-asset compatibility endpoint. Authorises the read (`CanReadAsset`) and returns a stable edge download route; private assets answer 404 and use authenticated expiring delivery links instead.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/KeyRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Download URL.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PresignDownloadResponse"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/AssetNotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/assets/tags": {
      "post": {
        "tags": [
          "Tags"
        ],
        "summary": "Add a tag to an asset",
        "operationId": "addTag",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TagRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The asset's tags after the change.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StringList"
                }
              }
            }
          },
          "400": {
            "description": "Body invalid, `key` missing, or tag invalid.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "invalid json": {
                    "value": {
                      "error": "invalid json"
                    }
                  },
                  "key is required": {
                    "value": {
                      "error": "key is required"
                    }
                  },
                  "invalid tag": {
                    "value": {
                      "error": "invalid tag"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/AssetNotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "delete": {
        "tags": [
          "Tags"
        ],
        "summary": "Remove a tag from an asset",
        "operationId": "removeTag",
        "parameters": [
          {
            "name": "key",
            "in": "query",
            "required": true,
            "description": "Tenant-relative asset key, e.g. `hero/welcome.webp`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "tag",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "tenant_id",
            "in": "query",
            "required": false,
            "description": "Tenant to act on. Only `super_admin` may name a tenant other than its own; every other role is scoped to its own tenant and can leave this out. A `super_admin` that omits it gets `400 tenant_id is required`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The asset's tags after the change.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StringList"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/AssetNotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "get": {
        "tags": [
          "Tags"
        ],
        "summary": "List an asset's tags",
        "operationId": "listTags",
        "parameters": [
          {
            "name": "key",
            "in": "query",
            "required": true,
            "description": "Tenant-relative asset key, e.g. `hero/welcome.webp`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "tenant_id",
            "in": "query",
            "required": false,
            "description": "Tenant to act on. Only `super_admin` may name a tenant other than its own; every other role is scoped to its own tenant and can leave this out. A `super_admin` that omits it gets `400 tenant_id is required`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Tags on the asset.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StringList"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/AssetNotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/assets/tags/tenant": {
      "get": {
        "tags": [
          "Tags"
        ],
        "summary": "Distinct tags used in the tenant",
        "operationId": "listTenantTags",
        "parameters": [
          {
            "name": "tenant_id",
            "in": "query",
            "required": false,
            "description": "Tenant to act on. Only `super_admin` may name a tenant other than its own; every other role is scoped to its own tenant and can leave this out. A `super_admin` that omits it gets `400 tenant_id is required`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Distinct tags.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StringList"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/assets/tags/keys": {
      "get": {
        "tags": [
          "Tags"
        ],
        "summary": "Asset keys carrying a tag",
        "operationId": "keysByTag",
        "parameters": [
          {
            "name": "tag",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "tenant_id",
            "in": "query",
            "required": false,
            "description": "Tenant to act on. Only `super_admin` may name a tenant other than its own; every other role is scoped to its own tenant and can leave this out. A `super_admin` that omits it gets `400 tenant_id is required`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Keys with that tag.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StringList"
                }
              }
            }
          },
          "400": {
            "description": "`tag` missing.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "tag is required": {
                    "value": {
                      "error": "tag is required"
                    }
                  },
                  "tenant_id is required": {
                    "value": {
                      "error": "tenant_id is required"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/assets/trash": {
      "post": {
        "tags": [
          "Trash"
        ],
        "summary": "Move an asset to the trash (soft delete)",
        "operationId": "trashAsset",
        "description": "Sets `Status: trashed` and `TrashedAt`. The edge answers `404` at every URL form immediately; the row, bytes and `delivery_url` are kept. `owner`, `tenant_admin`, or the `member` who uploaded it. Reversible with `POST /assets/restore` until purged.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/KeyRequest"
              }
            }
          }
        },
        "responses": {
          "204": {
            "description": "Trashed."
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/AssetNotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "get": {
        "tags": [
          "Trash"
        ],
        "summary": "List trashed assets",
        "operationId": "listTrash",
        "description": "Newest first, bare array. Each row is the asset plus `PurgeEligibleAt`. Only `tenant_id` is accepted as a query parameter.",
        "parameters": [
          {
            "name": "tenant_id",
            "in": "query",
            "required": false,
            "description": "Tenant to act on. Only `super_admin` may name a tenant other than its own; every other role is scoped to its own tenant and can leave this out. A `super_admin` that omits it gets `400 tenant_id is required`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Trashed assets.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/TrashEntry"
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/assets/restore": {
      "post": {
        "tags": [
          "Trash"
        ],
        "summary": "Restore a trashed asset",
        "operationId": "restoreAsset",
        "description": "Clears `TrashedAt`, sets `Status: ready`; `delivery_url` serves again. Same roles as trash. No body: the trash row you restored from already carries `id` and `delivery_url`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/KeyRequest"
              }
            }
          }
        },
        "responses": {
          "204": {
            "description": "Restored."
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/AssetNotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/assets/{key}": {
      "delete": {
        "tags": [
          "Trash"
        ],
        "summary": "Permanently delete one trashed asset",
        "operationId": "deleteAsset",
        "description": "Removes the bytes, every archived version, derived media and the catalog row. Irreversible.\n\nPermanent deletion has four gates, all enforced server-side:\n\n1. **Role**: `owner` of the asset's tenant, or `super_admin`. Anyone else is `403 forbidden`.\n2. **Credential**: the token was minted with `can_purge: true`, or the session was elevated by `POST /auth/purge-session` within the last 15 minutes. Otherwise `403 purge_capability_required`.\n3. **State**: the asset is already `trashed` (`POST /assets/trash` first). Otherwise `400`.\n4. **Retention**: `PurgeEligibleAt` has passed (30 days after trashing by default, tenant-configurable, never under 7). Otherwise `409 asset_in_retention` with `eligible_at`.\n\nSingle deletes also spend a rolling budget of 20 per actor per 10 minutes; beyond it the call is `429 purge_throttled` with `Retry-After`. Bulk work goes through `POST /assets/purge-batch` with an `approval_ref`, which is exempt from the budget.",
        "parameters": [
          {
            "name": "key",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Asset key, percent-encoded. Slashes are allowed and may be sent raw (`/assets/hero/welcome.webp`) or encoded (`/assets/hero%2Fwelcome.webp`).",
            "allowReserved": true
          },
          {
            "name": "tenant_id",
            "in": "query",
            "required": false,
            "description": "Tenant to act on. Only `super_admin` may name a tenant other than its own; every other role is scoped to its own tenant and can leave this out. A `super_admin` that omits it gets `400 tenant_id is required`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Gone. An audit row `delete` with IP, user agent and any approval reference is written."
          },
          "400": {
            "description": "Not trashed yet, or key missing.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "asset must be trashed before permanent deletion": {
                    "value": {
                      "error": "asset must be trashed before permanent deletion"
                    }
                  },
                  "key is required": {
                    "value": {
                      "error": "key is required"
                    }
                  },
                  "tenant_id is required": {
                    "value": {
                      "error": "tenant_id is required"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Role may not purge, credential lacks `can_purge`, or tenant suspended.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "forbidden": {
                    "value": {
                      "error": "forbidden"
                    }
                  },
                  "purge_capability_required": {
                    "value": {
                      "error": "purge_capability_required"
                    }
                  },
                  "tenant suspended": {
                    "value": {
                      "error": "tenant suspended"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/AssetNotFound"
          },
          "409": {
            "description": "Still inside the retention window.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "asset_in_retention",
                  "eligible_at": "2026-10-21T09:15:00Z"
                }
              }
            }
          },
          "429": {
            "description": "Purge budget spent (20 single deletes per 10 minutes), or the general rate limit.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Seconds; 600 for the purge budget."
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "purge_throttled": {
                    "value": {
                      "error": "purge_throttled"
                    }
                  },
                  "rate_limit": {
                    "value": {
                      "error": "rate limit exceeded"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/assets/purge-batch": {
      "post": {
        "tags": [
          "Trash"
        ],
        "summary": "Permanently delete up to 100 trashed assets with an approval reference",
        "operationId": "purgeBatch",
        "description": "The bulk purge. Answers `200` even when some keys were refused: read `results[].status` per key. The `approval_ref` lands in every audit row and exempts the batch from the rolling budget; the role, credential, trashed-state and retention gates still apply per key.\n\nPermanent deletion has four gates, all enforced server-side:\n\n1. **Role**: `owner` of the asset's tenant, or `super_admin`. Anyone else is `403 forbidden`.\n2. **Credential**: the token was minted with `can_purge: true`, or the session was elevated by `POST /auth/purge-session` within the last 15 minutes. Otherwise `403 purge_capability_required`.\n3. **State**: the asset is already `trashed` (`POST /assets/trash` first). Otherwise `400`.\n4. **Retention**: `PurgeEligibleAt` has passed (30 days after trashing by default, tenant-configurable, never under 7). Otherwise `409 asset_in_retention` with `eligible_at`.\n\nSingle deletes also spend a rolling budget of 20 per actor per 10 minutes; beyond it the call is `429 purge_throttled` with `Retry-After`. Bulk work goes through `POST /assets/purge-batch` with an `approval_ref`, which is exempt from the budget.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PurgeBatchRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Per-key outcomes. Partial success is normal; check every `status`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PurgeBatchResponse"
                }
              }
            }
          },
          "400": {
            "description": "Missing approval reference, empty or oversized batch.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "approval_ref is required": {
                    "value": {
                      "error": "approval_ref is required"
                    }
                  },
                  "keys must not be empty": {
                    "value": {
                      "error": "keys must not be empty"
                    }
                  },
                  "too many keys in one purge batch": {
                    "value": {
                      "error": "too many keys in one purge batch"
                    }
                  },
                  "invalid json": {
                    "value": {
                      "error": "invalid json"
                    }
                  },
                  "tenant_id is required": {
                    "value": {
                      "error": "tenant_id is required"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Role may not purge, or credential lacks `can_purge`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "forbidden": {
                    "value": {
                      "error": "forbidden"
                    }
                  },
                  "purge_capability_required": {
                    "value": {
                      "error": "purge_capability_required"
                    }
                  },
                  "tenant suspended": {
                    "value": {
                      "error": "tenant suspended"
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/assets/audit": {
      "get": {
        "tags": [
          "Audit"
        ],
        "summary": "Tenant audit trail",
        "operationId": "listAudit",
        "description": "Newest first, capped at 500 rows. `owner`, `tenant_admin` or `super_admin`. Only `tenant_id` is accepted as a query parameter.",
        "parameters": [
          {
            "name": "tenant_id",
            "in": "query",
            "required": false,
            "description": "Tenant to act on. Only `super_admin` may name a tenant other than its own; every other role is scoped to its own tenant and can leave this out. A `super_admin` that omits it gets `400 tenant_id is required`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Audit entries.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/AuditEntry"
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/folders": {
      "get": {
        "tags": [
          "Folders"
        ],
        "summary": "List folders",
        "operationId": "listFolders",
        "description": "Every folder in the tenant, as a bare array. A folder is display-only: moving an asset between folders never changes its `delivery_url`. Use `GET /assets?folder=<path>` to list what is in one, and `folder=` for the root.",
        "parameters": [
          {
            "name": "tenant_id",
            "in": "query",
            "required": false,
            "description": "Tenant to act on. Only `super_admin` may name a tenant other than its own; every other role is scoped to its own tenant and can leave this out.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Folders.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Folder"
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "post": {
        "tags": [
          "Folders"
        ],
        "summary": "Create a folder",
        "operationId": "createFolder",
        "description": "Creates an empty folder at `path`. Folders are created implicitly by uploading a key with slashes in it, so this is only needed for an empty one.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/FolderRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created."
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/folders/move": {
      "post": {
        "tags": [
          "Folders"
        ],
        "summary": "Move assets into a folder",
        "operationId": "moveAssets",
        "description": "Moves the named keys under `path` (`\"\"` for the root). The assets keep their `id` and their `delivery_url`; only the display location changes. Already-published URLs keep working.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/MoveAssetsRequest"
              }
            }
          }
        },
        "responses": {
          "204": {
            "description": "Moved."
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/folders/delete": {
      "post": {
        "tags": [
          "Folders"
        ],
        "summary": "Delete a folder",
        "operationId": "deleteFolder",
        "description": "Removes the folder itself. It does not trash or delete the assets in it; move them first, or they keep their keys and stay reachable at their `delivery_url`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/FolderRequest"
              }
            }
          }
        },
        "responses": {
          "204": {
            "description": "Deleted."
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/analytics/summary": {
      "get": {
        "tags": [
          "Analytics"
        ],
        "summary": "Tenant media analytics summary and trend",
        "operationId": "mediaAnalyticsSummary",
        "description": "Returns durable aggregate delivery and anonymous playback measures. Only a `tenant_admin` for the selected tenant or a `super_admin` may call this endpoint. `7d` uses hourly trend buckets; longer ranges use daily buckets. `stale: true` means the last complete imported hour is behind, while `503 analytics_unavailable` means an import has never completed for both datasets. Sampling is disclosed by `estimated`; no viewer, session, IP, user-agent, referrer, or raw-event data is returned.",
        "parameters": [
          {
            "name": "tenant_id",
            "in": "query",
            "required": false,
            "description": "Canonical tenant UUID. A tenant administrator may omit its own tenant; a super administrator must select one.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "range",
            "in": "query",
            "required": false,
            "description": "Bounded reporting window. Defaults to `30d`.",
            "schema": {
              "type": "string",
              "enum": [
                "7d",
                "30d",
                "90d",
                "365d"
              ],
              "default": "30d"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Aggregate measures, trend buckets, and freshness metadata.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MediaAnalyticsSummary"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "503": {
            "$ref": "#/components/responses/AnalyticsUnavailable"
          }
        }
      }
    },
    "/analytics/assets": {
      "get": {
        "tags": [
          "Analytics"
        ],
        "summary": "Rank tenant assets by delivery requests",
        "operationId": "mediaAnalyticsAssets",
        "description": "Returns the same aggregate measures as the summary, ranked by delivery requests descending and then asset UUID ascending. Pass the opaque `next_cursor` unchanged to continue a stable keyset page.",
        "parameters": [
          {
            "name": "tenant_id",
            "in": "query",
            "required": false,
            "description": "Canonical tenant UUID. A tenant administrator may omit its own tenant; a super administrator must select one.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "range",
            "in": "query",
            "required": false,
            "description": "Bounded reporting window. Defaults to `30d`.",
            "schema": {
              "type": "string",
              "enum": [
                "7d",
                "30d",
                "90d",
                "365d"
              ],
              "default": "30d"
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "required": false,
            "description": "Opaque cursor returned as `next_cursor` by the previous page.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Rows per page.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 20
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Ranked asset page and freshness metadata.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MediaAnalyticsAssets"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "503": {
            "$ref": "#/components/responses/AnalyticsUnavailable"
          }
        }
      }
    },
    "/assets/{assetID}/delivery-links": {
      "post": {
        "tags": [
          "Assets"
        ],
        "summary": "Issue a private delivery link",
        "operationId": "issuePrivateDeliveryLink",
        "description": "Requires a private, ready asset in the caller tenant. Default lifetime is four hours; maximum is 24 hours. The returned embed URL carries the token for Safari and HLS segment requests.",
        "parameters": [
          {
            "name": "assetID",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "lifetime_seconds": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 86400
                  },
                  "referrer_origin": {
                    "type": "string",
                    "format": "uri",
                    "description": "Optional HTTPS origin. When set, delivery requests must have a Referer from that origin."
                  }
                },
                "additionalProperties": false
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Signed link.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "token",
                    "delivery_url",
                    "embed_url",
                    "expires_at"
                  ],
                  "properties": {
                    "token": {
                      "type": "string"
                    },
                    "delivery_url": {
                      "type": "string",
                      "format": "uri"
                    },
                    "embed_url": {
                      "type": "string",
                      "format": "uri"
                    },
                    "expires_at": {
                      "type": "string",
                      "format": "date-time"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/assets/{assetID}/delivery-links/revoke": {
      "post": {
        "tags": [
          "Assets"
        ],
        "summary": "Revoke all private delivery links for an asset",
        "operationId": "revokePrivateDeliveryLinks",
        "description": "Advances the delivery revision, invalidating every outstanding link for this file.",
        "parameters": [
          {
            "name": "assetID",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "New delivery revision.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "asset_id": {
                      "type": "string"
                    },
                    "revision": {
                      "type": "integer"
                    }
                  },
                  "required": [
                    "asset_id",
                    "revision"
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/assets/{assetID}/privacy": {
      "put": {
        "tags": [
          "Assets"
        ],
        "summary": "Set asset privacy",
        "operationId": "setAssetPrivacy",
        "parameters": [
          {
            "name": "assetID",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "private": {
                    "type": "boolean"
                  }
                },
                "required": [
                  "private"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated privacy state.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "asset_id": {
                      "type": "string"
                    },
                    "private": {
                      "type": "boolean"
                    },
                    "revision": {
                      "type": "integer"
                    }
                  },
                  "required": [
                    "asset_id",
                    "private",
                    "revision"
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/folders/privacy": {
      "put": {
        "tags": [
          "Folders"
        ],
        "summary": "Set folder privacy default",
        "operationId": "setFolderPrivacy",
        "description": "Applies only to new uploads; existing assets retain their own privacy state.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "tenant_id": {
                    "type": "string"
                  },
                  "path": {
                    "type": "string"
                  },
                  "default_private": {
                    "type": "boolean"
                  }
                },
                "required": [
                  "path",
                  "default_private"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated folder default.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "tenant_id": {
                      "type": "string"
                    },
                    "path": {
                      "type": "string"
                    },
                    "default_private": {
                      "type": "boolean"
                    }
                  },
                  "required": [
                    "tenant_id",
                    "path",
                    "default_private"
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/assets/uploads/{id}/captions": {
      "post": {
        "tags": [
          "Assets"
        ],
        "summary": "Attach a timed text file before video upload completion",
        "operationId": "uploadSessionCaption",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "201": {
            "description": "Caption track",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "id",
                    "language",
                    "kind",
                    "source_generation_id"
                  ],
                  "properties": {
                    "id": {
                      "type": "string"
                    },
                    "language": {
                      "type": "string",
                      "description": "BCP 47 language code"
                    },
                    "kind": {
                      "type": "string",
                      "enum": [
                        "captions",
                        "subtitles"
                      ]
                    },
                    "source_generation_id": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid caption file or metadata"
          },
          "403": {
            "description": "Access denied"
          },
          "404": {
            "description": "Asset, upload, or track not found"
          },
          "409": {
            "description": "Caption state changed"
          },
          "503": {
            "description": "Caption storage unavailable"
          }
        },
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [
                  "file",
                  "language",
                  "kind"
                ],
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary",
                    "description": "UTF-8 .srt or .vtt, up to 1 MiB"
                  },
                  "language": {
                    "type": "string",
                    "description": "BCP 47 language code"
                  },
                  "kind": {
                    "type": "string",
                    "enum": [
                      "captions",
                      "subtitles"
                    ]
                  },
                  "tenant_id": {
                    "type": "string",
                    "description": "Upload session tenant when required"
                  }
                }
              }
            }
          }
        }
      }
    },
    "/assets/{assetID}/captions": {
      "get": {
        "tags": [
          "Assets"
        ],
        "summary": "List active caption tracks and previous tracks needing review",
        "operationId": "listCaptionTracks",
        "parameters": [
          {
            "name": "assetID",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Caption tracks",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "active": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "required": [
                          "id",
                          "language",
                          "kind",
                          "source_generation_id"
                        ],
                        "properties": {
                          "id": {
                            "type": "string"
                          },
                          "language": {
                            "type": "string",
                            "description": "BCP 47 language code"
                          },
                          "kind": {
                            "type": "string",
                            "enum": [
                              "captions",
                              "subtitles"
                            ]
                          },
                          "source_generation_id": {
                            "type": "string"
                          }
                        }
                      }
                    },
                    "needs_review": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "required": [
                          "id",
                          "language",
                          "kind",
                          "source_generation_id"
                        ],
                        "properties": {
                          "id": {
                            "type": "string"
                          },
                          "language": {
                            "type": "string",
                            "description": "BCP 47 language code"
                          },
                          "kind": {
                            "type": "string",
                            "enum": [
                              "captions",
                              "subtitles"
                            ]
                          },
                          "source_generation_id": {
                            "type": "string"
                          }
                        }
                      }
                    },
                    "pending": {
                      "type": "array",
                      "description": "Tracks attached to a replacement source awaiting publication",
                      "items": {
                        "type": "object",
                        "required": [
                          "id",
                          "language",
                          "kind",
                          "source_generation_id"
                        ],
                        "properties": {
                          "id": {
                            "type": "string"
                          },
                          "language": {
                            "type": "string",
                            "description": "BCP 47 language code"
                          },
                          "kind": {
                            "type": "string",
                            "enum": [
                              "captions",
                              "subtitles"
                            ]
                          },
                          "source_generation_id": {
                            "type": "string"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid caption file or metadata"
          },
          "403": {
            "description": "Access denied"
          },
          "404": {
            "description": "Asset, upload, or track not found"
          },
          "409": {
            "description": "Caption state changed"
          },
          "503": {
            "description": "Caption storage unavailable"
          }
        }
      },
      "post": {
        "tags": [
          "Assets"
        ],
        "summary": "Add or replace a caption track on the current video source",
        "operationId": "uploadAssetCaption",
        "parameters": [
          {
            "name": "assetID",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "201": {
            "description": "Caption track",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "id",
                    "language",
                    "kind",
                    "source_generation_id"
                  ],
                  "properties": {
                    "id": {
                      "type": "string"
                    },
                    "language": {
                      "type": "string",
                      "description": "BCP 47 language code"
                    },
                    "kind": {
                      "type": "string",
                      "enum": [
                        "captions",
                        "subtitles"
                      ]
                    },
                    "source_generation_id": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid caption file or metadata"
          },
          "403": {
            "description": "Access denied"
          },
          "404": {
            "description": "Asset, upload, or track not found"
          },
          "409": {
            "description": "Caption state changed"
          },
          "503": {
            "description": "Caption storage unavailable"
          }
        },
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [
                  "file",
                  "language",
                  "kind"
                ],
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary",
                    "description": "UTF-8 .srt or .vtt, up to 1 MiB"
                  },
                  "language": {
                    "type": "string",
                    "description": "BCP 47 language code"
                  },
                  "kind": {
                    "type": "string",
                    "enum": [
                      "captions",
                      "subtitles"
                    ]
                  },
                  "tenant_id": {
                    "type": "string",
                    "description": "Upload session tenant when required"
                  }
                }
              }
            }
          }
        }
      }
    },
    "/assets/{assetID}/captions/{trackID}/confirm": {
      "post": {
        "tags": [
          "Assets"
        ],
        "summary": "Confirm a previous track for a replaced video",
        "operationId": "confirmCaptionTrack",
        "parameters": [
          {
            "name": "assetID",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "trackID",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "201": {
            "description": "Caption track",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "id",
                    "language",
                    "kind",
                    "source_generation_id"
                  ],
                  "properties": {
                    "id": {
                      "type": "string"
                    },
                    "language": {
                      "type": "string",
                      "description": "BCP 47 language code"
                    },
                    "kind": {
                      "type": "string",
                      "enum": [
                        "captions",
                        "subtitles"
                      ]
                    },
                    "source_generation_id": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid caption file or metadata"
          },
          "403": {
            "description": "Access denied"
          },
          "404": {
            "description": "Asset, upload, or track not found"
          },
          "409": {
            "description": "Caption state changed"
          },
          "503": {
            "description": "Caption storage unavailable"
          }
        }
      }
    },
    "/assets/{assetID}/captions/{trackID}": {
      "delete": {
        "tags": [
          "Assets"
        ],
        "summary": "Hide and delete a caption track",
        "operationId": "deleteCaptionTrack",
        "parameters": [
          {
            "name": "assetID",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "trackID",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Track deleted"
          },
          "400": {
            "description": "Invalid caption file or metadata"
          },
          "403": {
            "description": "Access denied"
          },
          "404": {
            "description": "Asset, upload, or track not found"
          },
          "409": {
            "description": "Caption state changed"
          },
          "503": {
            "description": "Caption storage unavailable"
          }
        }
      }
    },
    "/assets/{assetID}/captions/{trackID}/download": {
      "get": {
        "tags": [
          "Assets"
        ],
        "summary": "Download a caption track as WebVTT",
        "operationId": "downloadCaptionTrack",
        "parameters": [
          {
            "name": "assetID",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "trackID",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "WebVTT file",
            "content": {
              "text/vtt": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "400": {
            "description": "Invalid caption file or metadata"
          },
          "403": {
            "description": "Access denied"
          },
          "404": {
            "description": "Asset, upload, or track not found"
          },
          "409": {
            "description": "Caption state changed"
          },
          "503": {
            "description": "Caption storage unavailable"
          }
        }
      }
    },
    "/auth/memberships": {
      "get": {
        "tags": [
          "Auth"
        ],
        "summary": "List your tenant memberships",
        "operationId": "listMemberships",
        "description": "Dashboard session. Returns the tenants and roles explicitly granted to the signed-in account.",
        "responses": {
          "200": {
            "description": "Memberships.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/TenantMembership"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "503": {
            "description": "Membership list unavailable.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/auth/active-tenant": {
      "post": {
        "tags": [
          "Auth"
        ],
        "summary": "Switch the active dashboard tenant",
        "operationId": "switchActiveTenant",
        "description": "Requires a dashboard cookie. Issues a new cookie after verifying the selected tenant membership.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SwitchTenantRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Active tenant changed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Account"
                }
              }
            }
          },
          "400": {
            "description": "Missing tenant.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Tenant unavailable or not granted.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "Switch unavailable.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/accounts/memberships": {
      "post": {
        "tags": [
          "Auth"
        ],
        "summary": "Add an existing account to a tenant",
        "operationId": "grantMembership",
        "description": "A tenant admin may grant access to their active tenant; a platform admin supplies tenant_id. Repeating the same grant succeeds; changing an existing role requires the role update operation.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/GrantMembershipRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Membership created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TeamAccount"
                }
              }
            }
          },
          "400": {
            "description": "Invalid request.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Not allowed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Account not found.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Membership exists.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "Membership service unavailable.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerToken": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "kic_…",
        "description": "AssetFlow API token. Plaintext starts with `kic_`, is shown once by `POST /auth/tokens`, and is stored hashed. A token minted with `can_purge: true` may permanently delete."
      },
      "sessionCookie": {
        "type": "apiKey",
        "in": "cookie",
        "name": "katalyst_session",
        "description": "Dashboard session set by `POST /auth/login`, 12 h lifetime. `POST /auth/purge-session` re-issues it with a 15 min purge elevation."
      }
    },
    "responses": {
      "BadRequest": {
        "description": "Malformed request. `error` says what is wrong.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "invalid json": {
                "value": {
                  "error": "invalid json"
                }
              },
              "key is required": {
                "value": {
                  "error": "key is required"
                }
              },
              "tenant_id is required": {
                "value": {
                  "error": "tenant_id is required"
                }
              }
            }
          }
        }
      },
      "Unauthorized": {
        "description": "Not authenticated: no credential, an unknown `kic_` token, or an expired session.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "invalid api token": {
                "value": {
                  "error": "invalid api token"
                }
              },
              "missing session": {
                "value": {
                  "error": "missing session"
                }
              },
              "invalid session": {
                "value": {
                  "error": "invalid session"
                }
              }
            }
          }
        }
      },
      "Forbidden": {
        "description": "Authenticated but not allowed: the role cannot do this to this tenant, or the tenant is suspended.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "forbidden": {
                "value": {
                  "error": "forbidden"
                }
              },
              "tenant suspended": {
                "value": {
                  "error": "tenant suspended"
                }
              }
            }
          }
        }
      },
      "AssetNotFound": {
        "description": "No asset with that key in the tenant.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "asset not found": {
                "value": {
                  "error": "asset not found"
                }
              }
            }
          }
        }
      },
      "RateLimited": {
        "description": "The configured per-account or per-IP rate limit was hit.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "rate limit exceeded": {
                "value": {
                  "error": "rate limit exceeded"
                }
              }
            }
          }
        }
      },
      "AnalyticsUnavailable": {
        "description": "One or both of the delivery and playback imports have not completed yet. This is distinct from a stale successful import.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "analytics_unavailable"
            }
          }
        }
      }
    },
    "schemas": {
      "OAuthError": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "string",
            "description": "RFC 6749 error code, such as `invalid_request` or `invalid_client`."
          },
          "error_description": {
            "type": "string"
          }
        }
      },
      "Error": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "string",
            "description": "Machine-readable code (`asset_in_retention`, `unknown_query_param`) or a short message (`asset not found`)."
          },
          "detail": {
            "type": "string",
            "description": "Human explanation. Present on query-parameter errors."
          },
          "param": {
            "type": "string",
            "description": "The offending query parameter. Present on query-parameter errors."
          },
          "eligible_at": {
            "type": "string",
            "format": "date-time",
            "description": "Present only with `asset_in_retention`: when the asset may be permanently deleted."
          }
        },
        "examples": [
          {
            "error": "asset not found"
          },
          {
            "error": "unknown_query_param",
            "param": "q",
            "detail": "query parameter is not recognised on this endpoint"
          },
          {
            "error": "asset_in_retention",
            "eligible_at": "2026-10-21T09:15:00Z"
          }
        ]
      },
      "MediaAnalyticsMeasures": {
        "type": "object",
        "required": [
          "delivery_requests",
          "expected_bytes",
          "playback_starts",
          "progress_25",
          "progress_50",
          "progress_75",
          "completions",
          "errors",
          "fallbacks",
          "completion_rate",
          "error_rate",
          "fallback_rate"
        ],
        "properties": {
          "delivery_requests": {
            "type": "integer",
            "format": "int64",
            "minimum": 0
          },
          "expected_bytes": {
            "type": "integer",
            "format": "int64",
            "minimum": 0,
            "description": "Expected delivered bytes after Analytics Engine sampling weights."
          },
          "playback_starts": {
            "type": "integer",
            "format": "int64",
            "minimum": 0,
            "description": "Anonymous player start events; not unique viewers."
          },
          "progress_25": {
            "type": "integer",
            "format": "int64",
            "minimum": 0
          },
          "progress_50": {
            "type": "integer",
            "format": "int64",
            "minimum": 0
          },
          "progress_75": {
            "type": "integer",
            "format": "int64",
            "minimum": 0
          },
          "completions": {
            "type": "integer",
            "format": "int64",
            "minimum": 0
          },
          "errors": {
            "type": "integer",
            "format": "int64",
            "minimum": 0
          },
          "fallbacks": {
            "type": "integer",
            "format": "int64",
            "minimum": 0
          },
          "completion_rate": {
            "type": "number",
            "format": "double",
            "minimum": 0,
            "description": "Completions divided by playback starts; zero when starts are zero."
          },
          "error_rate": {
            "type": "number",
            "format": "double",
            "minimum": 0,
            "description": "Error events divided by playback starts; zero when starts are zero."
          },
          "fallback_rate": {
            "type": "number",
            "format": "double",
            "minimum": 0,
            "description": "Progressive fallback events divided by playback starts; zero when starts are zero."
          }
        }
      },
      "MediaAnalyticsMetadata": {
        "type": "object",
        "required": [
          "tenant_id",
          "range",
          "granularity",
          "from",
          "to",
          "generated_at",
          "data_through",
          "estimated",
          "stale"
        ],
        "properties": {
          "tenant_id": {
            "type": "string",
            "format": "uuid"
          },
          "range": {
            "type": "string",
            "enum": [
              "7d",
              "30d",
              "90d",
              "365d"
            ]
          },
          "granularity": {
            "type": "string",
            "enum": [
              "hourly",
              "daily"
            ]
          },
          "from": {
            "type": "string",
            "format": "date-time"
          },
          "to": {
            "type": "string",
            "format": "date-time"
          },
          "generated_at": {
            "type": "string",
            "format": "date-time",
            "description": "Older last-success timestamp across delivery and playback imports."
          },
          "data_through": {
            "type": "string",
            "format": "date-time",
            "description": "Older completed data boundary across delivery and playback imports."
          },
          "estimated": {
            "type": "boolean",
            "description": "True when any returned aggregate used sampling weights."
          },
          "stale": {
            "type": "boolean",
            "description": "True when data_through trails the most recently closed UTC hour."
          }
        }
      },
      "MediaAnalyticsTrendBucket": {
        "allOf": [
          {
            "$ref": "#/components/schemas/MediaAnalyticsMeasures"
          },
          {
            "type": "object",
            "required": [
              "bucket_start",
              "estimated"
            ],
            "properties": {
              "bucket_start": {
                "type": "string",
                "format": "date-time"
              },
              "estimated": {
                "type": "boolean"
              }
            }
          }
        ]
      },
      "MediaAnalyticsSummary": {
        "allOf": [
          {
            "$ref": "#/components/schemas/MediaAnalyticsMetadata"
          },
          {
            "$ref": "#/components/schemas/MediaAnalyticsMeasures"
          },
          {
            "type": "object",
            "required": [
              "trend"
            ],
            "properties": {
              "trend": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/MediaAnalyticsTrendBucket"
                }
              }
            }
          }
        ]
      },
      "MediaAnalyticsAsset": {
        "allOf": [
          {
            "$ref": "#/components/schemas/MediaAnalyticsMeasures"
          },
          {
            "type": "object",
            "required": [
              "asset_id",
              "estimated"
            ],
            "properties": {
              "asset_id": {
                "type": "string",
                "minLength": 1
              },
              "estimated": {
                "type": "boolean"
              }
            }
          }
        ]
      },
      "MediaAnalyticsAssets": {
        "allOf": [
          {
            "$ref": "#/components/schemas/MediaAnalyticsMetadata"
          },
          {
            "type": "object",
            "required": [
              "assets",
              "next_cursor"
            ],
            "properties": {
              "assets": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/MediaAnalyticsAsset"
                }
              },
              "next_cursor": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Opaque stable keyset cursor; null on the final page."
              }
            }
          }
        ]
      },
      "Asset": {
        "type": "object",
        "description": "An asset as every endpoint returns it. Every asset has `id`; public assets also have `delivery_url`. Private assets omit the public URL.",
        "required": [
          "id",
          "ID",
          "TenantID",
          "Key",
          "Status",
          "ContentType",
          "Size",
          "CreatedAt"
        ],
        "properties": {
          "delivery_url": {
            "type": "string",
            "format": "uri",
            "description": "Present only for public assets. Absolute public URL, `https://katalyst-iceberg-delivery.juliavictorio16.workers.dev/a/{id}`; stable across replace, rollback, move and rename.",
            "examples": [
              "https://katalyst-iceberg-delivery.juliavictorio16.workers.dev/a/387f8ade-9d5c-4c58-b7a5-3f9f2f0a1b2c"
            ]
          },
          "id": {
            "type": "string",
            "description": "Immutable asset id. Store this. Same value as `ID`.",
            "examples": [
              "387f8ade-9d5c-4c58-b7a5-3f9f2f0a1b2c"
            ]
          },
          "ID": {
            "type": "string",
            "description": "Same as `id`; the original PascalCase key."
          },
          "TenantID": {
            "type": "string",
            "description": "Tenant slug, e.g. `t1`."
          },
          "OwnerID": {
            "type": "string",
            "description": "Account id of the uploader."
          },
          "Key": {
            "type": "string",
            "description": "Tenant-relative display key, e.g. `hero/welcome.webp`. Slashes allowed, no leading slash."
          },
          "StorageKey": {
            "type": "string",
            "deprecated": true,
            "description": "Physical object address in the bucket. Deprecated since 1.0.0 and removed 60 days after that release. Do not read it; it is not a URL and the bucket layout is not a contract. Make it optional in strict decoders now."
          },
          "DesiredSourceGenerationID": {
            "type": "string",
            "description": "Source generation current processing targets. Internal; may be empty."
          },
          "PublishedBundleID": {
            "type": "string",
            "description": "Media bundle currently served at the delivery URL. Empty for assets without derived media."
          },
          "DeliveryRevision": {
            "type": "integer",
            "format": "int64",
            "description": "Monotonic delivery revision, bumped on every lifecycle change."
          },
          "AssetFolder": {
            "type": "string",
            "description": "Library folder (mutable, display only). Empty string is the root."
          },
          "Size": {
            "type": "integer",
            "format": "int64",
            "description": "Bytes. `0` while `Status` is `pending`."
          },
          "ContentType": {
            "type": "string",
            "examples": [
              "image/jpeg"
            ]
          },
          "ETag": {
            "type": "string",
            "description": "Storage ETag captured at completion. Empty while pending."
          },
          "Status": {
            "type": "string",
            "enum": [
              "ready",
              "pending",
              "trashed",
              "deleting"
            ],
            "description": "`ready`: bytes are in storage. `pending`: key reserved by `init-upload`, bytes not yet completed. `trashed`: soft-deleted, hidden from the default list, 404 at the edge. `deleting`: permanent delete in progress."
          },
          "TrashedAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Set while trashed; the retention window counts from here."
          },
          "Width": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Pixels, when the media probe ran."
          },
          "Height": {
            "type": [
              "integer",
              "null"
            ]
          },
          "DurationMillis": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int64",
            "description": "Audio/video duration."
          },
          "Bitrate": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int64"
          },
          "Codec": {
            "type": [
              "string",
              "null"
            ]
          },
          "MediaProbeStatus": {
            "type": "string",
            "enum": [
              "not_attempted",
              "available",
              "unavailable"
            ],
            "description": "Whether metadata extraction ran for this asset."
          },
          "MediaProbeUnavailableReason": {
            "type": [
              "string",
              "null"
            ],
            "description": "Set when `MediaProbeStatus` is `unavailable`."
          },
          "CreatedAt": {
            "type": "string",
            "format": "date-time"
          },
          "IsPrivate": {
            "type": "boolean",
            "description": "Whether this asset requires a signed private delivery link."
          }
        }
      },
      "TrashEntry": {
        "description": "A trashed asset plus the instant it becomes purgeable.",
        "allOf": [
          {
            "$ref": "#/components/schemas/Asset"
          },
          {
            "type": "object",
            "required": [
              "PurgeEligibleAt"
            ],
            "properties": {
              "PurgeEligibleAt": {
                "type": "string",
                "format": "date-time",
                "description": "`TrashedAt` plus the tenant's retention window (30 days by default, never under 7). `DELETE /assets/{key}` answers `409 asset_in_retention` before this instant."
              }
            }
          }
        ]
      },
      "LoginRequest": {
        "type": "object",
        "required": [
          "email",
          "password"
        ],
        "properties": {
          "email": {
            "type": "string",
            "format": "email"
          },
          "password": {
            "type": "string",
            "format": "password"
          }
        }
      },
      "Account": {
        "type": "object",
        "required": [
          "id",
          "email",
          "role",
          "tenant_id",
          "tenant_uuid",
          "can_purge"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "email": {
            "type": "string",
            "format": "email"
          },
          "role": {
            "type": "string",
            "enum": [
              "super_admin",
              "owner",
              "tenant_admin",
              "member",
              "viewer"
            ]
          },
          "tenant_id": {
            "type": "string",
            "description": "Permanent tenant slug used in URLs and storage paths; empty for `super_admin`."
          },
          "tenant_uuid": {
            "type": "string",
            "description": "Immutable tenant UUID used by media analytics; empty for `super_admin`."
          },
          "can_purge": {
            "type": "boolean",
            "description": "Whether this credential may permanently delete right now: a `can_purge` token, or a session inside its purge window. Always `false` straight after login."
          },
          "purge_until": {
            "type": "string",
            "format": "date-time",
            "description": "Only on `/auth/whoami` for an elevated session: when the elevation lapses."
          }
        }
      },
      "PurgeSessionRequest": {
        "type": "object",
        "required": [
          "password"
        ],
        "properties": {
          "password": {
            "type": "string",
            "format": "password",
            "description": "The account password, re-checked."
          }
        }
      },
      "PurgeSessionResponse": {
        "type": "object",
        "required": [
          "can_purge",
          "purge_until"
        ],
        "properties": {
          "can_purge": {
            "type": "boolean",
            "const": true
          },
          "purge_until": {
            "type": "string",
            "format": "date-time",
            "description": "Now plus 15 minutes."
          }
        }
      },
      "CreateTokenRequest": {
        "type": "object",
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string",
            "description": "Label shown in the dashboard token list.",
            "examples": [
              "ci-uploader"
            ]
          },
          "can_purge": {
            "type": "boolean",
            "default": false,
            "description": "Mint the purge capability onto the token. `owner` and `super_admin` only; anyone else gets `403`."
          }
        }
      },
      "TokenCreated": {
        "type": "object",
        "required": [
          "id",
          "name",
          "prefix",
          "token",
          "created_at",
          "can_purge"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "prefix": {
            "type": "string",
            "description": "`kic_` plus the first 8 characters; the only part stored in clear.",
            "examples": [
              "kic_a7q2m9xk"
            ]
          },
          "token": {
            "type": "string",
            "description": "The full plaintext. Shown exactly once; store it now.",
            "examples": [
              "kic_a7q2m9xk3pd7fj2lq0z8w6yv4tnr5hcbe1sgu9okm"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "can_purge": {
            "type": "boolean"
          }
        }
      },
      "Token": {
        "type": "object",
        "required": [
          "id",
          "tenant_id",
          "name",
          "prefix",
          "created_at",
          "can_purge"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "tenant_id": {
            "type": "string",
            "description": "Team slug this token was issued for; empty for a platform administrator token."
          },
          "name": {
            "type": "string"
          },
          "prefix": {
            "type": "string"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "last_used_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "can_purge": {
            "type": "boolean"
          }
        }
      },
      "InitUploadRequest": {
        "type": "object",
        "required": [
          "key"
        ],
        "properties": {
          "key": {
            "type": "string",
            "description": "Tenant-relative key. Slashes allowed, no leading slash, `.versions/` segment reserved.",
            "examples": [
              "hero/welcome.webp"
            ]
          },
          "content_type": {
            "type": "string",
            "default": "application/octet-stream",
            "description": "Signed into the presigned URL; the PUT must send the same `Content-Type`.",
            "examples": [
              "image/webp"
            ]
          },
          "tenant_id": {
            "type": "string",
            "description": "`super_admin` only."
          }
        }
      },
      "InitUploadResponse": {
        "type": "object",
        "required": [
          "id",
          "asset_id",
          "key",
          "tenant_id",
          "upload_url",
          "content_type",
          "expires_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "The asset id. Store it now; it is final before the bytes exist."
          },
          "delivery_url": {
            "type": "string",
            "format": "uri",
            "description": "Present only for public assets. Where the bytes will be served once `complete` is called; answers 404 until then."
          },
          "asset_id": {
            "type": "string",
            "description": "Same as `id`."
          },
          "key": {
            "type": "string"
          },
          "tenant_id": {
            "type": "string"
          },
          "upload_url": {
            "type": "string",
            "format": "uri",
            "description": "Presigned R2 PUT URL, valid for 60 minutes. PUT the raw bytes here with the signed `Content-Type`."
          },
          "content_type": {
            "type": "string"
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "description": "When `upload_url` stops working."
          }
        }
      },
      "CompleteUploadRequest": {
        "type": "object",
        "required": [
          "key"
        ],
        "properties": {
          "key": {
            "type": "string",
            "description": "The key from `init-upload`."
          },
          "tenant_id": {
            "type": "string",
            "description": "`super_admin` only."
          }
        }
      },
      "KeyRequest": {
        "type": "object",
        "required": [
          "key"
        ],
        "properties": {
          "key": {
            "type": "string"
          },
          "tenant_id": {
            "type": "string",
            "description": "`super_admin` only."
          }
        }
      },
      "PresignDownloadResponse": {
        "type": "object",
        "required": [
          "asset_id",
          "key",
          "tenant_id",
          "download_url",
          "content_type"
        ],
        "properties": {
          "asset_id": {
            "type": "string"
          },
          "key": {
            "type": "string"
          },
          "tenant_id": {
            "type": "string"
          },
          "download_url": {
            "type": "string",
            "format": "uri",
            "description": "`https://katalyst-iceberg-delivery.juliavictorio16.workers.dev/a/{id}?download=1`. A stable edge route, not a time-limited R2 URL; the edge applies the lifecycle gate on every request."
          },
          "content_type": {
            "type": "string"
          }
        }
      },
      "RollbackRequest": {
        "type": "object",
        "required": [
          "key",
          "version_id"
        ],
        "properties": {
          "key": {
            "type": "string"
          },
          "version_id": {
            "type": "string",
            "description": "An `id` from `GET /assets/versions`."
          },
          "tenant_id": {
            "type": "string",
            "description": "`super_admin` only."
          }
        }
      },
      "AssetVersion": {
        "type": "object",
        "required": [
          "id",
          "tenant_id",
          "asset_key",
          "asset_id",
          "version_number",
          "size",
          "content_type",
          "etag",
          "archived_at",
          "archived_by"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "Version id; pass as `version_id` to rollback."
          },
          "tenant_id": {
            "type": "string"
          },
          "asset_key": {
            "type": "string"
          },
          "asset_id": {
            "type": "string",
            "description": "The live asset this is a version of."
          },
          "delivery_url": {
            "type": "string",
            "format": "uri",
            "description": "Present only when the live asset is public. A rollback serves these bytes at the same URL."
          },
          "version_number": {
            "type": "integer",
            "description": "Monotonic per key; higher is more recent."
          },
          "storage_key": {
            "type": "string",
            "deprecated": true,
            "description": "Archive path, internal. Removed 60 days after 1.0.0."
          },
          "size": {
            "type": "integer",
            "format": "int64"
          },
          "content_type": {
            "type": "string"
          },
          "etag": {
            "type": "string"
          },
          "archived_at": {
            "type": "string",
            "format": "date-time",
            "description": "When these bytes stopped being live."
          },
          "archived_by": {
            "type": "string",
            "description": "Account id that replaced or rolled back."
          }
        }
      },
      "AssetSummary": {
        "type": "object",
        "required": [
          "tenant_id",
          "total_assets",
          "total_bytes",
          "content_types",
          "largest",
          "oldest",
          "monthly_growth",
          "stale_count",
          "stale_bytes"
        ],
        "properties": {
          "tenant_id": {
            "type": "string"
          },
          "total_assets": {
            "type": "integer"
          },
          "total_bytes": {
            "type": "integer",
            "format": "int64"
          },
          "content_types": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "content_type",
                "count",
                "bytes"
              ],
              "properties": {
                "content_type": {
                  "type": "string"
                },
                "count": {
                  "type": "integer"
                },
                "bytes": {
                  "type": "integer",
                  "format": "int64"
                }
              }
            }
          },
          "largest": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Asset"
            }
          },
          "oldest": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Asset"
            }
          },
          "monthly_growth": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "month",
                "count",
                "bytes"
              ],
              "properties": {
                "month": {
                  "type": "string",
                  "examples": [
                    "2026-09"
                  ]
                },
                "count": {
                  "type": "integer"
                },
                "bytes": {
                  "type": "integer",
                  "format": "int64"
                }
              }
            }
          },
          "stale_count": {
            "type": "integer"
          },
          "stale_bytes": {
            "type": "integer",
            "format": "int64"
          }
        }
      },
      "TagRequest": {
        "type": "object",
        "required": [
          "key",
          "tag"
        ],
        "properties": {
          "key": {
            "type": "string"
          },
          "tag": {
            "type": "string"
          },
          "tenant_id": {
            "type": "string",
            "description": "`super_admin` only."
          }
        }
      },
      "StringList": {
        "type": "array",
        "items": {
          "type": "string"
        }
      },
      "PurgeBatchRequest": {
        "type": "object",
        "required": [
          "keys",
          "approval_ref"
        ],
        "properties": {
          "keys": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "minItems": 1,
            "maxItems": 100,
            "description": "Trashed keys to purge. At most 100."
          },
          "approval_ref": {
            "type": "string",
            "description": "Ticket or approval reference. Required; written into every audit row and exempts the batch from the rolling budget.",
            "examples": [
              "ICE-95"
            ]
          },
          "tenant_id": {
            "type": "string",
            "description": "`super_admin` only."
          }
        }
      },
      "PurgeBatchResponse": {
        "type": "object",
        "required": [
          "approval_ref",
          "results"
        ],
        "properties": {
          "approval_ref": {
            "type": "string"
          },
          "results": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "key",
                "status"
              ],
              "properties": {
                "key": {
                  "type": "string"
                },
                "status": {
                  "type": "string",
                  "enum": [
                    "deleted",
                    "asset_in_retention",
                    "not_found",
                    "not_trashed",
                    "failed"
                  ]
                },
                "eligible_at": {
                  "type": "string",
                  "format": "date-time",
                  "description": "With `asset_in_retention`."
                },
                "error": {
                  "type": "string",
                  "description": "With `failed`."
                }
              }
            }
          }
        }
      },
      "AuditEntry": {
        "type": "object",
        "required": [
          "id",
          "actor_id",
          "actor_email",
          "action",
          "target_key",
          "detail",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "actor_id": {
            "type": "string"
          },
          "actor_email": {
            "type": "string"
          },
          "action": {
            "type": "string",
            "description": "One of `upload`, `replace`, `rollback`, `trash`, `restore`, `delete`, `purge_throttled`, `tag.add`, `tag.remove`, `account.role.update`, `oauth.token.issue`, `transcode.start`, `transcode.failed`, `transcode.retry`, `asset.transfer`, `delivery.alias.retire`, `delivery.alias.restore`."
          },
          "target_key": {
            "type": "string"
          },
          "detail": {
            "type": "string",
            "description": "Free text. Delete rows carry `ip=… ua=… [approval_ref=…]`."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "Folder": {
        "type": "object",
        "description": "A folder in the display tree. PascalCase, like the asset fields.",
        "required": [
          "TenantID",
          "Path"
        ],
        "properties": {
          "TenantID": {
            "type": "string"
          },
          "Path": {
            "type": "string",
            "description": "Slash-separated, no leading or trailing slash. The root folder is the empty string.",
            "examples": [
              "courses/intro"
            ]
          },
          "DefaultPrivate": {
            "type": "boolean",
            "description": "New uploads in this folder inherit this value."
          }
        }
      },
      "FolderRequest": {
        "type": "object",
        "required": [
          "path"
        ],
        "properties": {
          "path": {
            "type": "string",
            "description": "Slash-separated, no leading or trailing slash."
          },
          "tenant_id": {
            "type": "string",
            "description": "`super_admin` only."
          }
        }
      },
      "MoveAssetsRequest": {
        "type": "object",
        "required": [
          "keys",
          "path"
        ],
        "properties": {
          "keys": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Keys to move. At least one; an empty array is `400`."
          },
          "path": {
            "type": "string",
            "description": "Destination folder. The empty string moves to the root."
          },
          "tenant_id": {
            "type": "string",
            "description": "`super_admin` only."
          }
        }
      },
      "TenantMembership": {
        "type": "object",
        "required": [
          "tenant_id",
          "tenant_name",
          "role"
        ],
        "properties": {
          "tenant_id": {
            "type": "string"
          },
          "tenant_name": {
            "type": "string"
          },
          "role": {
            "type": "string",
            "enum": [
              "owner",
              "tenant_admin",
              "member",
              "viewer"
            ]
          }
        }
      },
      "SwitchTenantRequest": {
        "type": "object",
        "required": [
          "tenant_id"
        ],
        "properties": {
          "tenant_id": {
            "type": "string"
          }
        }
      },
      "GrantMembershipRequest": {
        "type": "object",
        "required": [
          "email",
          "role"
        ],
        "properties": {
          "email": {
            "type": "string",
            "format": "email"
          },
          "role": {
            "type": "string",
            "enum": [
              "owner",
              "tenant_admin",
              "member",
              "viewer"
            ]
          },
          "tenant_id": {
            "type": "string",
            "description": "Required for a platform admin; ignored for a tenant admin."
          }
        }
      },
      "TeamAccount": {
        "type": "object",
        "required": [
          "id",
          "email",
          "role",
          "tenant_id",
          "tenant_uuid",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "email": {
            "type": "string",
            "format": "email"
          },
          "role": {
            "type": "string"
          },
          "tenant_id": {
            "type": "string"
          },
          "tenant_uuid": {
            "type": "string"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      }
    }
  }
}
