Files and attachments
Uploading bytes, reading them back, and why an attachment has no shareable URL.
Files are attachments on cards and images in page bodies. A file belongs to one workspace, like everything else.
The rule that shapes everything else
An attachment has no URL of its own.
Metadata is a row in the workspace's tables. The bytes live in an object store, and the API serves them itself, from a scoped route behind the same verified membership as every other read.
There is no public bucket and no signed URL. A signed URL would be a second authorisation path, weaker than the first, and the whole 404-instead-of-403 posture would be worth nothing if sharing a link were enough to get around it.
So an image block and a file property carry an identifier, never an
address. Each consumer composes the URL it knows how to serve.
Uploading
POST /workspaces/{workspaceId}/files
Content-Type: multipart/form-dataThe field is file. Optional externalSource and externalId fields make the
upload idempotent: if the workspace already holds those bytes under that
origin, the API returns the existing file with a 200 instead of storing a copy.
That is what lets an import replay without duplicating its images.
The TypeScript client has a helper so the field name lives in one place:
import { fileUploadBody } from "@spunto-cms/client"
await api.POST("/workspaces/{workspaceId}/files", {
params: { path: { workspaceId } },
body: fileUploadBody(blob, "screenshot.png"),
})Reading bytes
GET /workspaces/{workspaceId}/files/{fileId}/contentSame bearer token as everything else. Images and PDFs are served inline;
everything else comes back as an attachment download, always with
X-Content-Type-Options: nosniff and a Content-Security-Policy that permits
nothing.
The type served is not the type declared
Outside a whitelist, the API serves application/octet-stream as a download —
whatever the upload claimed.
This is not pedantry. In production the front end and the API share one origin,
and an SVG is a scriptable document: serving one inline as an image would be
serving an attacker's script from your own origin. So the File object reports
contentType as what the API will serve, and image: false for an SVG.
Trust those two fields rather than the file's name.
Card covers
A card's cover image is a file id on the card itself, coverFileId. Upload,
then write the id:
const { data: file } = await api.POST("/workspaces/{workspaceId}/files", {
params: { path: { workspaceId } },
body: fileUploadBody(blob, "cover.png"),
})
await api.PATCH("/workspaces/{workspaceId}/items/{itemId}", {
params: { path: { workspaceId, itemId } },
body: { coverFileId: file!.id },
})Only a file the API serves as an image is accepted (image: true on the File
object) — anything else is a 400. coverFileId travels on Item, so a view
page carries it too; read the bytes from the route above. Deleting the file
leaves the id in place, so treat a 404 on the bytes as "no cover".
Avatars
A member's picture is an attachment of the workspace, referenced from their membership — not from their user account.
That sounds like an odd place to put it until you try the alternative: an avatar on the account would need a rule like "you can see the face of anyone who shares a workspace with you", which would be the first read path in the system that crosses a tenant boundary. For a profile picture.
So there is nothing new in the storage layer and nothing new in the read path, and the worst case degrades to initials.
A Google profile picture, when there is one, is copied rather than served: the bytes are fetched once, where a membership is created, and become an ordinary attachment. Serving Google's address would have reopened both the public URL and the account-level face at once.