Home Playground Docs hub

Guide G-71 · Infrastructure & Storage

Storing & serving files with the CDN

Verified against local · 2026-09-25 · cdn-service 0.1.0 (workspace HEAD)
Journey. As a developer, I want to store files in a shared,
tenant-scoped object store where every file has an owner, only the owner
(or an admin) can read and delete it, and I can hand out time-limited
download tokens — and I want to know exactly which failure code each
mistake produces.

The CDN Service is the platform's file storage layer. Where G-04 "Uploading files the platform way" walks the upload journey end to end, this guide covers what makes the CDN storage infrastructure: the ownership model, token lifecycle, delete semantics, path safety, and limits — the parts you design around when several services and users share one tenant.

AspectBehavior
AddressingRelative paths inside the caller's tenant namespace — there is no global bucket listing
OwnershipEvery file records its owner's sub (from the JWT); reads/deletes/token-mints enforce owner-or-admin
Accessprivate/download for the owner; download?token=… scoped by a signed, expiring token (caller credentials still required)
Payload limit50 MiB per upload (INAYA_LABS_CDN_MAX_PAYLOAD_BYTES)
Tenant quota5 GiB per tenant (enforced at write time)
Storage backendsinayalabs today; the storage_provider parameter is reserved for future backends
Token TTL180 s default (INAYA_LABS_CDN_TOKEN_TTL_IN_SECONDS)

Prerequisites

JWTs from POST /v3/auth/qa/login — one per "user" — because this guide demonstrates cross-user access control. Sandbox accepts any tenant id; the snippets use playground_a43b973c77e94b7f8b0e64d96c86edf8.

Concepts

tenant, but the owning principal is the JWT sub. Two users in one tenant cannot see each other's files: a non-owner's read or delete returns 403, even though the path exists.

was never uploaded — or was deleted — returns 403 "You are not allowed to download this file", identical to being a non-owner. Existence is never revealed; design UIs accordingly.

provider, expiry, and an HMAC signature, and it is bound to the owning tenant/user. But GET /v3/cdn/files/download?token=… still requires the caller's key pair and JWT — the token is a capability on top of caller identity, not a replacement for it.

return the same 403 as for a never-existing path, and a second delete is also 403 (there is no ownership record left to authorize against).

Step 1 — Round trip in a fresh namespace

Owner "A" uploads a file and reads it back. The multipart contract (path + file fields) is covered in depth in G-04; here we use it to set up the ownership fixtures the rest of the guide exercises:

bash
TOKEN_A=$(curl -sf -X POST "${AUTH_URL}/v3/auth/qa/login" \
  -H "Content-Type: application/json" \
  -H "x-api-key: ${INAYA_API_KEY}" \
  -H "x-client-app-key: ${INAYA_APP_KEY}" \
  -d "{\"device_id\": \"g71-owner-01\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\", \"username\": \"g71-owner\"}" \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')
echo "JWT A obtained (${#TOKEN_A} chars)"

printf 'quarterly report draft\n' > /tmp/g71-report.txt
UP=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
  "${CDN_URL}/v3/cdn/files/upload" \
  -H "x-api-key: ${INAYA_API_KEY}" \
  -H "x-client-app-key: ${INAYA_APP_KEY}" \
  -H "Authorization: Bearer ${TOKEN_A}" \
  -F "path=g71/report.txt" -F "file=@/tmp/g71-report.txt")
echo "upload: ${UP}"
test "${UP}" = "201" || exit 1

BODY=$(curl -s "${CDN_URL}/v3/cdn/files/private/download?path=g71/report.txt" \
  -H "x-api-key: ${INAYA_API_KEY}" \
  -H "x-client-app-key: ${INAYA_APP_KEY}" \
  -H "Authorization: Bearer ${TOKEN_A}")
echo "downloaded: ${BODY}"
test "${BODY}" = "quarterly report draft" && echo "round trip verified"

Expected — upload: 201 (empty body), then downloaded: quarterly report draft and round trip verified. GET /v3/cdn/files/private/download takes the path as a query parameter and returns the raw bytes with Content-Disposition/Content-Type headers set.

Step 2 — Ownership: a second user in the same tenant is a stranger

User "B" shares the tenant but has a different sub. B cannot read or delete A's file — the error is indistinguishable from "file doesn't exist":

bash
TOKEN_B=$(curl -sf -X POST "${AUTH_URL}/v3/auth/qa/login" \
  -H "Content-Type: application/json" \
  -H "x-api-key: ${INAYA_API_KEY}" \
  -H "x-client-app-key: ${INAYA_APP_KEY}" \
  -d "{\"device_id\": \"g71-intruder-02\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\", \"username\": \"g71-intruder\"}" \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')
echo "JWT B obtained (${#TOKEN_B} chars)"

DL_B=$(curl -s -w "\n%{http_code}" \
  "${CDN_URL}/v3/cdn/files/private/download?path=g71/report.txt" \
  -H "x-api-key: ${INAYA_API_KEY}" \
  -H "x-client-app-key: ${INAYA_APP_KEY}" \
  -H "Authorization: Bearer ${TOKEN_B}")
echo "${DL_B}"
DEL_B=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \
  "${CDN_URL}/v3/cdn/files/g71/report.txt" \
  -H "x-api-key: ${INAYA_API_KEY}" \
  -H "x-client-app-key: ${INAYA_APP_KEY}" \
  -H "Authorization: Bearer ${TOKEN_B}")
echo "B delete: ${DEL_B}"
test "$(echo "${DL_B}" | sed '$d')" = "You are not allowed to download this file" \
  && test "$(echo "${DL_B}" | tail -n 1)" = "403" && test "${DEL_B}" = "403" \
  && echo "ownership enforcement verified"

Expected — B's download returns 403 with the plain-text body You are not allowed to download this file, and B's delete is 403 too.

Admin bypass. Callers whose JWT roles claim contains Admin or SuperAdmin pass the ownership check for any file in the tenant. The QA login issues roles: "Subscriber", so the bypass can't be demonstrated with the login shortcut — it is enforced in the ownership middleware and is the escape hatch for support workflows.

Step 3 — Download tokens: mint, verify, tamper, strip credentials

Owner A mints a token for the report and inspects it. The token is base64-encoded and decodes to a signed record — expiry, provider, tenant, owner, and path, separated by :, with an HMAC:

bash
TOK_RESP=$(curl -s -X POST "${CDN_URL}/v3/cdn/files/token" \
  -H "Content-Type: application/json" \
  -H "x-api-key: ${INAYA_API_KEY}" \
  -H "x-client-app-key: ${INAYA_APP_KEY}" \
  -H "Authorization: Bearer ${TOKEN_A}" \
  -d '{"path": "g71/report.txt"}')
echo "${TOK_RESP}"
DL_TOKEN=$(echo "${TOK_RESP}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')

EXPIRES=$(echo "${TOK_RESP}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["expires_at"])')
NOW=$(date +%s)
TTL=$((EXPIRES - NOW))
echo "token TTL: ${TTL}s (default 180s)"
test "${TTL}" -ge 170 && test "${TTL}" -le 180 || exit 1

DECODED=$(python3 -c "import base64,sys; t=sys.argv[1]; print(base64.b64decode(t + '=' * (-len(t) % 4)).decode())" "${DL_TOKEN}")
echo "decoded token record: ${DECODED}"
echo "${DECODED}" | grep -q "^${EXPIRES}:inayalabs:" && echo "token structure verified"

Expected — {"token":"…","expires_at":<now+180>}; the TTL check passes; the decoded record looks like <expires_at>:inayalabs:<tenant>/<sub>/g71/report.txt:<hex-hmac>.

The download works with the caller's credentials (as documented in G-04) — and fails without them. A flipped character invalidates the HMAC:

bash
WITH_AUTH=$(curl -s -o /dev/null -w "%{http_code}" \
  "${CDN_URL}/v3/cdn/files/download?token=${DL_TOKEN}" \
  -H "x-api-key: ${INAYA_API_KEY}" \
  -H "x-client-app-key: ${INAYA_APP_KEY}" \
  -H "Authorization: Bearer ${TOKEN_A}")
echo "download with credentials: ${WITH_AUTH}"
test "${WITH_AUTH}" = "200" || exit 1

NO_AUTH=$(curl -s -o /dev/null -w "%{http_code}" \
  "${CDN_URL}/v3/cdn/files/download?token=${DL_TOKEN}")
echo "download without credentials: ${NO_AUTH} (token does NOT replace auth)"

LAST_CHAR="${DL_TOKEN: -1}"
FLIPPED=$([ "${LAST_CHAR}" = "A" ] && echo B || echo A)
TAMPERED="${DL_TOKEN%?}${FLIPPED}"
TAMPER_RESP=$(curl -s -w "\n%{http_code}" \
  "${CDN_URL}/v3/cdn/files/download?token=${TAMPERED}" \
  -H "x-api-key: ${INAYA_API_KEY}" \
  -H "x-client-app-key: ${INAYA_APP_KEY}" \
  -H "Authorization: Bearer ${TOKEN_A}")
echo "${TAMPER_RESP}"
test "${NO_AUTH}" = "401" \
  && test "$(echo "${TAMPER_RESP}" | sed '$d')" = "Invalid or expired token" \
  && test "$(echo "${TAMPER_RESP}" | tail -n 1)" = "401" \
  && echo "token verification verified"

Expected — download with credentials: 200; download without credentials: 401 (the token does not exempt a caller from the key pair + JWT); the tampered token returns 401 Invalid or expired token.

Two token quirks, stated plainly:

field \token\ — Axum's query-deserialization rejection fires before the handler's own 400 "Query parameter token is required"` branch, so that friendlier message is currently unreachable.

expired token`). With the default 180 s TTL, waiting one TTL out is the only way to observe expiry — plan for short TTLs when testing revocation.

Step 4 — Delete lifecycle: bytes and ownership go together

bash
DEL=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \
  "${CDN_URL}/v3/cdn/files/g71/report.txt" \
  -H "x-api-key: ${INAYA_API_KEY}" \
  -H "x-client-app-key: ${INAYA_APP_KEY}" \
  -H "Authorization: Bearer ${TOKEN_A}")
echo "owner delete: ${DEL}"
test "${DEL}" = "204" || exit 1

READ_AFTER=$(curl -s -w "\n%{http_code}" \
  "${CDN_URL}/v3/cdn/files/private/download?path=g71/report.txt" \
  -H "x-api-key: ${INAYA_API_KEY}" \
  -H "x-client-app-key: ${INAYA_APP_KEY}" \
  -H "Authorization: Bearer ${TOKEN_A}")
echo "${READ_AFTER}"
DEL_AGAIN=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \
  "${CDN_URL}/v3/cdn/files/g71/report.txt" \
  -H "x-api-key: ${INAYA_API_KEY}" \
  -H "x-client-app-key: ${INAYA_APP_KEY}" \
  -H "Authorization: Bearer ${TOKEN_A}")
echo "second delete: ${DEL_AGAIN}"
test "$(echo "${READ_AFTER}" | sed '$d')" = "You are not allowed to download this file" \
  && test "$(echo "${READ_AFTER}" | tail -n 1)" = "403" && test "${DEL_AGAIN}" = "403" \
  && echo "delete lifecycle verified"

Expected — owner delete: 204 (empty body); the owner's own subsequent read returns 403 "You are not allowed to download this file" (the ownership record is gone — identical to the never-existed case); a second delete is also 403. There is no idempotent "already gone" signal: if your cleanup logic needs to distinguish "deleted now" from "was already gone", track that in your own state.

Step 5 — Path safety and limits

Every path is validated component-by-component: .., absolute paths, and root prefixes are rejected before touching storage. Unknown storage backends are rejected at the query layer:

bash
TRAV=$(curl -s -w "\n%{http_code}" -X POST \
  "${CDN_URL}/v3/cdn/files/upload" \
  -H "x-api-key: ${INAYA_API_KEY}" \
  -H "x-client-app-key: ${INAYA_APP_KEY}" \
  -H "Authorization: Bearer ${TOKEN_A}" \
  -F "path=../escape.txt" -F "file=@/tmp/g71-report.txt")
echo "${TRAV}"
ABS=$(curl -s -w "\n%{http_code}" -X POST \
  "${CDN_URL}/v3/cdn/files/upload" \
  -H "x-api-key: ${INAYA_API_KEY}" \
  -H "x-client-app-key: ${INAYA_APP_KEY}" \
  -H "Authorization: Bearer ${TOKEN_A}" \
  -F "path=/etc/escape.txt" -F "file=@/tmp/g71-report.txt")
echo "${ABS}"
PROV=$(curl -s -w "\n%{http_code}" -X POST \
  "${CDN_URL}/v3/cdn/files/upload?storage_provider=local" \
  -H "x-api-key: ${INAYA_API_KEY}" \
  -H "x-client-app-key: ${INAYA_APP_KEY}" \
  -H "Authorization: Bearer ${TOKEN_A}" \
  -F "path=g71/x.txt" -F "file=@/tmp/g71-report.txt")
echo "${PROV}"
test "$(echo "${TRAV}" | sed '$d')" = "Invalid path" && test "$(echo "${TRAV}" | tail -n 1)" = "400" \
  && test "$(echo "${ABS}" | tail -n 1)" = "400" && test "$(echo "${PROV}" | tail -n 1)" = "400" \
  && echo "path safety and provider validation verified"

Expected — traversal and absolute paths: 400 Invalid path (plain text); bogus provider: 400 with unknown variant \local\, expected \inayalabs\`. Only inayalabs` exists today — the parameter is a forward-compatible hook, so sending it explicitly pins you to the default backend.

Limits (from the service configuration, not demoed per-request):

router's DefaultBodyLimit enforces it.

Error contract cheat sheet

SituationStatusBody
Missing JWT (any route)401{"error":"Missing Authorization token"}
Non-owner read403You are not allowed to download this file
Non-owner delete / delete of missing file403(empty)
Owner read of deleted/never-existing path403You are not allowed to download this file
Re-upload without overwrite=true409(see G-04)
.. or absolute path400Invalid path
Unknown storage_provider400unknown variant …, expected \inayalabs\``
Tokenized download, missing token400Failed to deserialize query string: missing field \token\``
Tokenized download, bad/tampered/expired token401Invalid or expired token

Notable asymmetry: 404 essentially never appears on the file routes — the ownership check fires before (or instead of) existence checks, so "gone" and "not yours" both read as 403.

Agents: MCP tools

Agents can manage files directly: the service exposes three MCP tools on /agent/v1/mcp (streamable HTTP, spec 2026-07-28), under the same key pair + JWT authentication — upload_file (content base64-encoded), delete_file (owner-or-admin enforced), and generate_download_token (owner-or-admin; returns the token + expires_at). Every tool mirrors its REST counterpart and applies the same ownership rules.

Putting it together

A small "secure drop" script — upload as A, mint a token, hand both the token and the reminder that credentials are still required to B, clean up:

bash
# A: upload + mint
curl -sf -X POST "${CDN_URL}/v3/cdn/files/upload" \
  -H "x-api-key: ${INAYA_API_KEY}" -H "x-client-app-key: ${INAYA_APP_KEY}" \
  -H "Authorization: Bearer ${TOKEN_A}" \
  -F "path=g71/drop/report.txt" -F "file=@./report.txt"
TOK=$(curl -sf -X POST "${CDN_URL}/v3/cdn/files/token" \
  -H "Content-Type: application/json" \
  -H "x-api-key: ${INAYA_API_KEY}" -H "x-client-app-key: ${INAYA_APP_KEY}" \
  -H "Authorization: Bearer ${TOKEN_A}" -d '{"path": "g71/drop/report.txt"}' \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')

# B (or any caller WITH credentials): download via token
curl -sf "${CDN_URL}/v3/cdn/files/download?token=${TOK}" \
  -H "x-api-key: ${INAYA_API_KEY}" -H "x-client-app-key: ${INAYA_APP_KEY}" \
  -H "Authorization: Bearer ${TOKEN_B}" -o ./report-copy.txt

# A: revoke-by-delete (tokens die with the file)
curl -sf -o /dev/null -X DELETE "${CDN_URL}/v3/cdn/files/g71/drop/report.txt" \
  -H "x-api-key: ${INAYA_API_KEY}" -H "x-client-app-key: ${INAYA_APP_KEY}" \
  -H "Authorization: Bearer ${TOKEN_A}"

(CDN_URL is http://127.0.0.1:40040 locally; AUTH_URL is http://127.0.0.1:40004; TENANT_ID is your tenant.)

Try it live

Open the Playground → Infrastructure & Storage — the CDN console exercises the upload/download/delete cycle against the live service with a file picker.

Copy agent prompt

Agent prompt — copy and hand to your agent
You are working with the Inaya Labs CDN Service at CDN_URL (:40040); Auth is
AUTH_URL (:40004); the tenant key pair is in INAYA_API_KEY / INAYA_APP_KEY and
TENANT_ID. Every file route requires the key pair AND a user JWT from POST
AUTH /v3/auth/qa/login {"device_id","locale":"en-US","tenant_id","username"}.
Steps: (1) OWNER A: upload POST CDN /v3/cdn/files/upload (multipart fields
"path" and "file") -> 201 empty; read back GET
/v3/cdn/files/private/download?path=<p> with A's JWT -> 200 raw bytes. (2)
OWNERSHIP: obtain JWT B (different username); B's private/download and DELETE
/v3/cdn/files/<p> both -> 403 (B's download body is the plain text "You are
not allowed to download this file"; indistinguishable from a missing file).
(3) TOKENS: A mints POST /v3/cdn/files/token {"path":<p>} -> 200
{"token","expires_at"} (TTL default 180s from
INAYA_LABS_CDN_TOKEN_TTL_IN_SECONDS); the token is base64 of
"<expiry>:inayalabs:<tenant>/<sub>/<path>:<hmac>". GET
/v3/cdn/files/download?token=<t> works WITH caller credentials (200) but
returns 401 WITHOUT them — tokens scope, they do not replace auth; a
single-character flip -> 401 "Invalid or expired token"; missing token -> 400
"Failed to deserialize query string: missing field `token`". (4) DELETE:
owner A deletes -> 204; A's own subsequent read -> 403 (ownership record
gone); second delete -> 403 (no idempotent already-gone signal). (5) PATH
SAFETY: upload paths "../x" or "/etc/x" -> 400 "Invalid path"; only
storage_provider "inayalabs" exists (others -> 400). Limits: 50 MiB upload,
5 GiB per tenant, 30 s request timeout. Note 404 essentially never appears —
403 covers non-owner AND missing. Report status codes and bodies per step;
never log the JWTs.

API reference

Next journeys