Assets
Every uploaded file — a blog cover, a social post image, the .wav the speech pipeline mirrors off its provider — is a blob behind one port, IAssetStorage. There is no assets table: blobs are addressed by an opaque key, and the URLs that point at them live inside the cards that use them.
The key is the tenancy map — and the access-control map
Section titled “The key is the tenancy map — and the access-control map”A key is <orgId>/<visibility>/<uuid>.<ext>, where visibility is pub or priv.
The org segment is what makes assets organisable per org. On the filesystem it is a directory; on S3 it is a key prefix. So the question “what does org X store” is du -sh <uploadDir>/<orgId>, and moving a tenant onto a dedicated asset server is a directory copy rather than a per-file lookup.
The visibility segment is what decides who may read the blob, and it lives in the ADDRESS rather than in a column — see Reading.
/app/data/assets/ org_27a41…/ pub/ a1b2c3d4-….png e5f6a7b8-….wav priv/ 7c8d9e0f-….png org_9f3ba…/ pub/ c9d0e1f2-….jpgbuildAssetKey in packages/platform-domain/src/assets/ports/IAssetStorage.ts is the only thing that builds a key, and parseAssetKey is the only thing that validates one. Each adapter used to inline its own ${randomUUID()}.${ext}, which meant a key-shape change was three edits and any missed one wrote blobs into a layout the others could not address.
Why not the alternatives
Section titled “Why not the alternatives”- Flat keys plus an
org_idcolumn. No route or URL change, but the layout stays a single namespace: migrating one org becomes a query plus a per-file copy, and an unreferenced blob has no owner at all. The point of the exercise was the physical layout, so the column would have recorded the answer without delivering it. - A bucket per org. The strongest isolation, and the wrong shape at this size: bucket-per-tenant means provisioning on signup and running into per-account bucket limits, in exchange for isolation the prefix already gives. If it is ever wanted, an org-prefixed key is exactly what you split on.
- An opaque per-org prefix instead of the raw org id, to keep org identity out of URLs. That costs a column, a lookup on every upload and an indirection every time you debug a path.
orgIdis on every task the API returns, so the raw id leaks nothing new. - An
assetstable carrying visibility, instead of the lane segment. The conventional design, and it puts a database read on the hottest and most cacheable path in the system — a public read is currently a pure filesystem/S3 hit behind an immutable cache. Encoding the lane in the key keeps that, and keeps the “no assets table” decision above intact.
Uploading
Section titled “Uploading”Three paths write blobs, and all three carry the org:
| Path | Org comes from | Lane |
|---|---|---|
POST /api/assets | the session’s active org (400 without one) | the optional visibility field, pub when omitted |
MCP upload_asset | the per-request MCP server’s resolved ctx.orgId | always pub — Claude uploads images for cards that publish |
| the TTS step | context.metadata.orgId on the job | pub, preserving prior behaviour (see below) |
buildAssetKey takes visibility as a required argument, with no default. The one default in the stack is stated in the open, on the HTTP route — so a new upload path cannot silently inherit “world-readable”. That is the guardrail that matters the day AssetContentType grows a type not meant for a public page.
The TTS .wav is pub because that is what it has always been, not because anyone judged generated audio public. It is emitted as a link card’s sourceUrl and mirrored to payload.lastSpeechUrl for the editor’s inline player — a plain cross-origin <audio> tag, which carries no cookies — and a speech card can itself be published. Whether org-internal audio belongs in priv is genuinely open; flipping it is one line plus credentialed CORS on that tag.
Content type is always sniffed from the bytes, never trusted from the caller — storing bytes under a declared type means GET later serves them with that lie in the header. Size caps are per type (5 MB images, 50 MB audio).
Reading
Section titled “Reading”GET /api/assets/:org/:visibility/:key serves one blob, and the lane in the key decides who may have it.
pubanswers anyone, with no session. That is deliberate and load-bearing. viite.ai’s Astro build fetches these URLs at build time; the X and LinkedIn publishers re-fetch them to upload bytes; Facebook is handed the URL and fetches it itself; and a published landing page at/sites/:subdomainis loaded by an arbitrary visitor’s browser. The last two cannot carry a credential even in principle.privanswers only the owning org, resolved from the session (or org API key). The refusal is a 404, never a 403 — a private key must be indistinguishable from one that was never written, or the response confirms which uuids exist. Same rule the published-sites route follows for a draft site.
The read route uses the optional auth middleware: an anonymous request reaches the handler instead of being turned away at the door, and the lane decides. A bad credential is still rejected — only the absence of one is tolerated.
Threat model
Section titled “Threat model”What the public lane does protect:
- Enumeration. Keys carry a uuid v4 — 122 bits. You cannot walk the space.
- Cross-org writes. The org segment is resolved server-side from the session at upload, never from the caller, so nobody can place bytes in another tenant’s prefix.
- Cross-org pins on the publishing roots.
template_instances.values,templates.variablesandtemplates.markuprefuse a URL whose org segment is not the record’s own (foreignAssetKeys, applied by the route viarefuseForeignAssets). Not a read control — apubblob would render fine — but a deletion one:ReleaseAssetsUsecasescans the record’s org and the key’s owning org, which covers this org deleting a card holding another’s blob, but not the other org dropping its own last reference. Without the write guard, that frees a blob a live page still serves. Tasks are deliberately exempt: a card’s body is user-authored, may cite any host, and is never rendered into a public page.
What it does not protect:
- A leaked
pubURL is a working URL, for anyone, forever. There is no expiry and no revocation short of deleting the blob. Put nothing inpubthat is not intended for a public page.
Why signed / time-limited URLs are not the answer for pub: OrgSite.publishedHtml is a frozen snapshot with resolved asset URLs baked in, kept across unpublish and never regenerated. An expiry kills the images on every already-published page, with no re-render to refresh them — and the same applies to URLs baked into materialized task bodies and into viite.ai’s built static output. Expiring URLs and frozen snapshots are incompatible by construction. If a sharing requirement ever needs signed links, they belong in the private lane, where nothing is ever frozen.
Rendering a priv blob from the dashboard needs credentialed CORS — a plain cross-origin <img> or <audio> sends no cookies. buildCorsMiddleware already splits exactly this way (public-prefix paths origin-open, everything else allowlist-gated with credentials), so it is configuration, not new token infrastructure.
When to revisit this
Section titled “When to revisit this”The decision holds today largely because AssetContentType is media only — png / jpeg / webp / gif / avif / wav / mp4. Nothing private-by-nature can be uploaded here at all; documents (pdf/doc/txt) go through a different port entirely, the quotes IFileStorage.
Adding a content type that is not meant for a public page voids the reasoning above. At that point the default lane on POST /api/assets is the thing to change, not this paragraph.
Retired key shapes
Section titled “Retired key shapes”Flat <uuid>.<ext> and two-segment <orgId>/<uuid>.<ext> keys no longer parse, and the fallback that used to resolve one to the other is gone with them. Retired 2026-08-21, together with the api:migrate-assets-org-prefix sweep.
The clean break was deliberate: there were no users on prod, so nothing had to be rewritten. Later it would have meant a second pass over every stored URL — in tasks, asset_references, template_instances, templates and publishedHtml — which is exactly the migration the 2026-08 org-prefix change had to perform, and exactly what putting the lane in the address now avoids having to repeat.
Knowing what an org stores
Section titled “Knowing what an org stores”GET /api/assets/usage (authenticated, org-scoped) returns { bytes, count } for the caller’s active org, and the dashboard surfaces it as a Storage card on /settings. It is a directory listing on the filesystem and a prefixed ListObjectsV2 on S3 — cheap precisely because the org is in the key. Both lanes are counted: a private blob occupies exactly as much disk as a public one, so the quota must see it.
Moving one org to a dedicated asset server
Section titled “Moving one org to a dedicated asset server”- Copy the directory:
rsync -a <uploadDir>/<orgId>/ newhost:/data/assets/<orgId>/, oraws s3 sync s3://bucket/<orgId>/ s3://neworg-bucket/<orgId>/. Both lanes come along, since they nest inside the org. - Point that deployment’s
ASSETS_UPLOAD_DIR(orASSETS_S3_ENDPOINT+ASSETS_S3_BUCKET) at the new home. The key shape is identical either side, so nothing stored changes.
Syncing one lane on its own (<uploadDir>/<orgId>/pub/) also works, and is what the address is shaped for — a CDN in front of the public blobs would take exactly that prefix.
Deleting
Section titled “Deleting”asset_references(asset_key, task_id) is a refcount, not an ownership record. Deleting a card frees only the blobs no other card still points at — assets are genuinely shared, since promoting a card copies its source_url onto the post and one uploaded image becomes one draft per destination. References are extracted by scanning the whole serialized record rather than a field list: over-counting leaks a blob, under-counting destroys one.
But cards are not the only thing that holds a blob, so the refcount is not the whole answer. ReleaseAssetsUsecase (packages/platform-domain/src/assets/usecases/) is the single place that decides whether a blob is free, and every delete path routes through it: DELETE /api/tasks/:id and the board’s prune (via deleteTaskWithAssets), plus DELETE /api/template-instances/:id and any instance edit that swaps one image for another. Callers supply only the candidate keys; liveness is answered once, over four roots:
| Root | Why it is one |
|---|---|
asset_references | Another card points at it. |
template_instances.values | A filled-in instance renders it. |
templates.variables + templates.markup | An image variable’s defaultValue, or a hand-written <img src> that was never turned into a {{key}} hole. Every future install of the template renders it. |
OrgSite.publishedHtml | A frozen public snapshot with resolved URLs baked in, kept across unpublish and never regenerated. |
Splitting that answer across paths is a real outage, not a tidiness concern: MaterializeTemplateInstanceUsecase bakes an instance’s resolved /api/assets/... URLs into a task body, so the materialized card holds a reference to a blob the instance owns. A card-delete that consulted the refcount alone read zero and destroyed an image a live page still served. Add a new root and add it here — never a second sweep with its own list.
Bias throughout is “skip on doubt”: a root scan that fails keeps every candidate. The scan is org-scoped (blobs are org-prefixed, so a teammate’s page can legitimately hold the same one) and runs only for a record that actually held references, so pruning thousands of plain cards costs nothing extra.
That org-scoping is only sufficient because the publishing roots refuse cross-org pins on the WRITE — see the threat model. A scan asked about org B cannot see org A’s instance, so if A could pin B’s blob, B dropping its last reference would free a file A’s page still serves. The guard is what keeps the sweep from having to be global. The one exception is purgeTenant, which deletes an org’s blobs outright — there is nothing left to be reachable from.
Related
Section titled “Related”- Multi-tenancy — how org and user scoping is threaded through the rest of the platform.
apps/api/assets/— routes and the local/S3/in-memory adapters.
