# AssetFlow > AssetFlow is Katalyst's self-hosted asset CDN: media is stored in Cloudflare R2, catalogued by a small Go API, and served from Cloudflare's edge at stable public URLs. Upload once, store the `id`, and render `delivery_url` for public assets. API base: `https://assetflow-production-fcf6.up.railway.app` (JSON). Delivery base: `https://katalyst-iceberg-delivery.juliavictorio16.workers.dev` (bytes, no auth). Version 1.0.0. ## Auth Send `Authorization: Bearer kic_…` on every API call. Mint a token in the dashboard ("API tokens" in the sidebar) or with `POST /auth/tokens` from a signed-in session; the plaintext is shown once. Installed MCP clients use OAuth discovery at `/.well-known/oauth-authorization-server`. Its `revocation_endpoint` is `POST /oauth/revoke`: send form fields `token`, optional `token_type_hint`, and `client_id` for a public DCR client. Revoking either an access or refresh token atomically invalidates the full grant. A successful, unknown, already-revoked, or wrong-client token receives the same empty 200 response. A `503 temporarily_unavailable` means no partial revoke is accepted: retain both credentials and retry. The `/oauth` group is IP-rate-limited and may return 429 with `Retry-After`. ## The one rule **Store `id`; render `delivery_url` for public assets; never build a URL.** Private asset bodies omit `delivery_url`; request an authenticated, expiring link with `POST /assets/{assetID}/delivery-links`. Do not read `StorageKey` (deprecated, removed 60 days after 1.0.0). Do not join a host with a key. The bucket layout is not a contract and has already changed once. ## Upload (three calls) ```sh API=https://assetflow-production-fcf6.up.railway.app; TOKEN=kic_… # 1. Reserve the key, get a presigned R2 PUT URL (valid 60 min). id and delivery_url are final here. curl -sS -X POST $API/assets/init-upload -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" -d '{"key":"hero/welcome.webp","content_type":"image/webp"}' # -> {"id":"387f…","delivery_url":"https://katalyst-iceberg-delivery.juliavictorio16.workers.dev/a/387f…","upload_url":"https://…","expires_at":"…"} # 2. PUT the bytes straight to R2 with the same Content-Type. The API never sees them. curl -sS -X PUT "$UPLOAD_URL" -H "Content-Type: image/webp" --upload-file ./welcome.webp # 3. Mark ready. AssetFlow HEADs storage, records size and ETag, and delivery_url starts serving. curl -sS -X POST $API/assets/complete -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" -d '{"key":"hero/welcome.webp"}' # -> full asset body, Status "ready" ``` `409 asset already exists` on step 1 means the key is taken; use `POST /assets/replace` (multipart) to overwrite bytes at the same URL. `409 object not in storage yet` on step 3 means the PUT has not landed; retry it. ## List and page `GET /assets` returns a bare JSON array, newest first. Filters (anything else is `400 unknown_query_param`): `prefix`, `folder` (exact, `folder=` for root), `content_type` (prefix, e.g. `video/`), `status` (`ready` default, `pending`, `trashed`), `limit` (1..1000, default 100), `after` (cursor), `tenant_id` (super_admin only). Paging is in headers: `X-Iceberg-Next-Cursor: ` and `Link: <…&after=>; rel="next"`, both absent on the final page. `X-Iceberg-Filters` echoes what was applied. Walk a tenant by repeating the query with `after=` until the header is gone. ```sh curl -sS -D - "$API/assets?prefix=hero/&limit=100" -H "Authorization: Bearer $TOKEN" ``` ## Folders Folders are the display tree, never part of the URL. Moving an asset between them keeps its `id` and its `delivery_url`, so nothing already published breaks. `GET /folders` lists them (`{"TenantID","Path"}`, PascalCase like the asset body); `POST /folders {"path"}` creates an empty one (uploading a key with slashes creates folders on its own); `POST /folders/move {"keys":[…],"path"}` moves assets (`path": ""` is the root); `POST /folders/delete {"path"}` removes the folder and leaves its assets where they are. Move and delete answer 204, create answers 201. ## Trash and permanent delete - `POST /assets/trash {"key"}` soft-deletes: `Status` becomes `trashed`, the CDN answers 404 at once, the row and URL are kept. `POST /assets/restore {"key"}` undoes it. Roles: owner, tenant_admin, or the member who uploaded it. - `GET /assets/trash` lists trashed rows with `PurgeEligibleAt`. - `DELETE /assets/{key}` permanently deletes one trashed asset. Four gates: role `owner` (or super_admin); credential minted with `can_purge: true` (or a dashboard session elevated by `POST /auth/purge-session`, 15 min); asset already trashed; retention window passed (30 days after trashing by default, never under 7). Refusals: `403 purge_capability_required`, `409 {"error":"asset_in_retention","eligible_at":"…"}`, `429 purge_throttled` after 20 single deletes in 10 minutes (`Retry-After: 600`). - `POST /assets/purge-batch {"keys":[…],"approval_ref":"TICKET-1"}` purges up to 100 with an approval reference (exempt from the budget, written to audit). Answers 200 with per-key `status`: `deleted`, `asset_in_retention`, `not_found`, `not_trashed`, `failed`. ## URL forms | Form | Status | |---|---| | `https://katalyst-iceberg-delivery.juliavictorio16.workers.dev/a/{id}` | Canonical. What `delivery_url` returns. Derived: `/a/{id}/poster.jpg`, `/a/{id}/hls/master.m3u8`, `/a/{id}/variants/{width}` (images; 256, 1024, 2048), `/a/{id}/playback.json`, `/a/{id}?download=1`. | | `https://katalyst-iceberg-delivery.juliavictorio16.workers.dev/{tenant}/{key}` | Legacy alias, kept permanently, slower (authority lookup + 308 to `/a/{id}`). Do not create new ones. | | `https://katalyst-iceberg-delivery.juliavictorio16.workers.dev/objects/{id}` | Compatibility only: 308 to `/a/{id}` until 2026-12-31, then 404. Backfill stored HTML to `delivery_url` before then. | ## Errors Every error is JSON `{"error": ""}`. Query errors add `"param"` and `"detail"`. Common: `400 invalid json`, `400 key is required`, `400 tenant_id is required` (super_admin without a tenant), `401 invalid api token`, `403 forbidden`, `403 tenant suspended`, `404 asset not found`, `429 rate limit exceeded`. ## Asset body Keys are PascalCase plus `id` and, for public assets, `delivery_url`: `ID`, `TenantID`, `OwnerID`, `Key`, `StorageKey` (deprecated), `AssetFolder`, `Size`, `ContentType`, `ETag`, `Status` (`ready`|`pending`|`trashed`|`deleting`), `TrashedAt`, `Width`, `Height`, `DurationMillis`, `Bitrate`, `Codec`, `MediaProbeStatus` (`not_attempted`|`available`|`unavailable`), `MediaProbeUnavailableReason`, `PublishedBundleID`, `DeliveryRevision`, `DesiredSourceGenerationID`, `CreatedAt`. Trash rows add `PurgeEligibleAt`. ## Reference - MCP endpoint: `POST /mcp` with OAuth or an AssetFlow API token. `tools/list` includes named asset, folder, and token workflows; see `docs/mcp-v1.md` in the source repository. Browser login, logout, and purge-session use the existing browser/OAuth flow. Permanent-delete tools require a `can_purge` API token. - [OpenAPI 3.1 spec](https://katalyst-iceberg-delivery.juliavictorio16.workers.dev/openapi.json): every endpoint, schema and error code. - [Interactive reference](https://katalyst-iceberg-delivery.juliavictorio16.workers.dev/docs): the same spec rendered with Scalar. - [Full text with one curl per endpoint](https://katalyst-iceberg-delivery.juliavictorio16.workers.dev/llms-full.txt) - [Source](https://github.com/Jvictorio09/asset-flow)