Spunto CMS
The API

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-data

The 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}/content

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

On this page