Skip to content

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-….jpg

buildAssetKey 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.

  • Flat keys plus an org_id column. 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. orgId is on every task the API returns, so the raw id leaks nothing new.
  • An assets table 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.

Three paths write blobs, and all three carry the org:

PathOrg comes fromLane
POST /api/assetsthe session’s active org (400 without one)the optional visibility field, pub when omitted
MCP upload_assetthe per-request MCP server’s resolved ctx.orgIdalways pub — Claude uploads images for cards that publish
the TTS stepcontext.metadata.orgId on the jobpub, 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).

GET /api/assets/:org/:visibility/:key serves one blob, and the lane in the key decides who may have it.

  • pub answers 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/:subdomain is loaded by an arbitrary visitor’s browser. The last two cannot carry a credential even in principle.
  • priv answers 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.

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.variables and templates.markup refuse a URL whose org segment is not the record’s own (foreignAssetKeys, applied by the route via refuseForeignAssets). Not a read control — a pub blob would render fine — but a deletion one: ReleaseAssetsUsecase scans 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 pub URL is a working URL, for anyone, forever. There is no expiry and no revocation short of deleting the blob. Put nothing in pub that 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.

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.

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.

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”
  1. Copy the directory: rsync -a <uploadDir>/<orgId>/ newhost:/data/assets/<orgId>/, or aws s3 sync s3://bucket/<orgId>/ s3://neworg-bucket/<orgId>/. Both lanes come along, since they nest inside the org.
  2. Point that deployment’s ASSETS_UPLOAD_DIR (or ASSETS_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.

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:

RootWhy it is one
asset_referencesAnother card points at it.
template_instances.valuesA filled-in instance renders it.
templates.variables + templates.markupAn 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.publishedHtmlA 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.

  • 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.