snoozestackdocs

Permissions

Access control has three layers here, and all three are visible in your repo. A function's auth mode decides who gets in; its declared capabilities decide what the code can reach; and the query it writes decides which rows this caller may touch. Nothing else is in play — no client-facing table API, no ambient credentials, no policy engine applying itself behind your back.

LayerDeclared whereWhat it gates
auth = "public" | "optional" | "required"project.toml, per functionWhether a request needs a verified session to reach the handler at all
capabilities = ["db", …]project.toml, per functionWhich surfaces exist on the handler's second argument — an undeclared one simply isn't there
The query itselfThe function's codeRow-level rules — where user_id = ? with capabilities.auth.user.id
Why use it

If your app talked to the database directly from a browser or a phone, something would have to stop one user from reading another's rows — and it couldn't be the client, because the client is the thing you don't trust. Snoozestack removes that path rather than policing it: a committed function is the only way to your data, and a function is code you write and can read — the same discipline as an API handler, minus the server to run.

The capability declaration puts the blast radius in the diff. A reviewer can see that send-digest can read the database and send push but cannot touch storage, and a compromised or buggy function can't reach what it never declared. Secrets go further: a [[secrets]] entry can name exactly which functions may read it.

The trade-off is that the row-level check is written by hand, every time. Forgetting the where user_id = … in a function that reads private data is a real bug, the same way forgetting it in a traditional API handler always was. Write the check where you write the query, and keep each function's job narrow.

The auth modes#

snoozestack/project.toml
[[functions]]
name = "my-notes"
entry = "my-notes/index.ts"
auth = "required" # no valid session → 401, handler never runs
capabilities = ["db", "auth"]
[[functions]]
name = "public-feed"
entry = "public-feed/index.ts"
auth = "public" # anyone; capabilities.auth is absent
capabilities = ["db"]

required rejects unauthenticated requests before your code runs, so the handler can rely on capabilities.auth.user being set. optional runs either way and tells you which (capabilities.auth.anonymous) — for endpoints that personalize when signed in. public is for webhooks and reads that are the same for everyone. Sessions are verified by the runtime itself — a signature check, local and offline — so this gate doesn't cost a network round trip; see Authentication.

Private per-user data
Notes, orders, messages: auth = "required", and every query the function runs carries where user_id = ? bound to capabilities.auth.user.id. The default shape, and the one most apps need.
Public read, owner write
Blog posts, product listings: a public read function serves anyone; a separate required write function checks the caller before it inserts or updates.
Shared workspaces
Multi-tenant B2B, where access follows membership rather than ownership — the function's query joins through a memberships table to check the caller belongs to the row's team, in the same SQL statement that reads the row.
Admin and background work
Back-office tools and queue/schedule-triggered jobs that legitimately see everything are themselves functions — there's no separate bypass credential to protect; they simply don't add a caller check.
SurfaceAvailability
Portal UIRead-only — the console shows each function's auth mode and capabilities; changing them is a project.toml edit
CLIsnoozestack check validates the declarations; snoozestack publish ships them
SDK / HTTPThe SDK attaches the signed-in session to functions.invoke() automatically; the function decides the rest

Access through membership#

Team and workspace apps need “you can see this row if you belong to its team,” which is a join in the function's own query rather than a separate policy to maintain:

snoozestack/functions/team-documents/index.ts
const { rows } = capabilities.db.query(
`select d.* from documents d
where d.team_id = ?
and exists (select 1 from memberships m
where m.team_id = d.team_id and m.user_id = ?)`,
[teamId, capabilities.auth.user.id],
);

Index what the join compares, the same as you would for any query that runs often.

Common gotchas#

  • auth = "required" only proves someone is signed in — it doesn't scope a query for you. A per-caller function still binds capabilities.auth.user.id into its SQL itself.
  • There is no fail-closed default at the row level — a db function that forgets the where clause returns every row to whoever got in. Prefer narrow functions over a general-purpose one that re-derives the rule for every caller.
  • Declaring a capability a function doesn't use widens its blast radius for nothing; declare what it needs, no more. Same for secrets — use [[secrets]] functions = […] to name the functions that may read each one.
  • The project key your app initializes the SDK with identifies the project, not a person — treat it as public, and never treat its presence as authorization.