Spunto CMS
Concepts

Workspaces and members

The tenant boundary — what a workspace contains, what the four roles can do, and why a stranger gets a 404 instead of a 403.

A workspace is the unit of isolation. Everything else lives inside exactly one: collections, cards, the property registry, statuses, files, API keys, webhooks and the event log.

Nothing crosses. There is no query in the product that reads two workspaces at once, and that is enforced below the application: each request runs on a Postgres connection scoped to one tenant by row-level security, with the API connecting as a role that has no power to bypass it. A missing WHERE clause cannot leak another tenant's rows, because the clause is not what's holding the line.

A visible consequence

The workspace picker shows no counters. Not because nobody wanted them — because counting cards across your workspaces would mean one transaction per workspace, and the isolation that makes the product safe is the same isolation that makes that aggregate expensive. The counters live one level down, on each workspace's own home screen.

Members and roles

A person gets into a workspace by being a member of it. There are four roles, and they are ordinal — a route that requires member also accepts admin and owner:

RoleCan
viewerRead content and views
memberEverything above, plus create and edit cards
adminEverything above, plus the registry, statuses, members, API keys and invitations
ownerEverything above, plus the workspace itself

Roles are checked on every write, on the server. A client is told its own role so it can hide buttons that would fail, but hiding a button is a courtesy, not a control.

Why you get a 404, not a 403

Ask for a workspace you are not a member of and the API answers 404 Not Found, never 403 Forbidden.

That is deliberate. A 403 would confirm the workspace exists, which is exactly the fact someone enumerating identifiers is trying to learn. The same applies to a settings section your role cannot open: not visible, not there.

So a 404 from this API means one of two things — it does not exist, or it is not yours — and the API will not tell you which.

Scope comes from membership, never from the URL

Putting a workspace id in a path does not grant anything. Every request under /workspaces/{workspaceId}/… passes two gates before reaching any route: authentication (401 without a valid bearer) and membership (404 without one).

Both are mounted on the path prefix rather than route by route, which means a route added tomorrow is protected the day it is written. Row-level security sits underneath as a second floor — it prevents crossing between tenants, but it is the membership check that decides whether you may name this one.

Two kinds of bearer

Two credentials get through the front door, and past it nothing can tell them apart:

  • a session, from a human signing in — it carries that person's access to all their workspaces, and it expires after 30 days;
  • an API key (spk_…), for software — scoped to one workspace, with a role fixed at issue time, revocable on its own.

For anything that runs unattended, use a key. A session is the wrong credential for a script: it is broader than the job needs, it cannot be revoked without logging that person out everywhere, and it will eventually expire in the middle of the night.

There is a third: an app installation token (spa_…), issued through OAuth to third-party software. Those additionally carry scopes.

Overview

Each workspace has a home screen backed by a single route, GET /workspaces/{workspaceId}/overview: how many cards sit in each status category, activity over the last fourteen days, and progress per list.

It is a route of its own rather than a side effect of some other read, because counting is the part of a content system that breaks first at scale. Keeping it separate makes its cost visible and deliberate — three queries, whatever the number of lists — instead of hiding it inside a page load that used to be cheap.

On this page