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.
| Layer | Declared where | What it gates |
|---|---|---|
auth = "public" | "optional" | "required" | project.toml, per function | Whether a request needs a verified session to reach the handler at all |
capabilities = ["db", …] | project.toml, per function | Which surfaces exist on the handler's second argument — an undeclared one simply isn't there |
| The query itself | The function's code | Row-level rules — where user_id = ? with capabilities.auth.user.id |
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#
[[functions]]name = "my-notes"entry = "my-notes/index.ts"auth = "required" # no valid session → 401, handler never runscapabilities = ["db", "auth"] [[functions]]name = "public-feed"entry = "public-feed/index.ts"auth = "public" # anyone; capabilities.auth is absentcapabilities = ["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 carrieswhere user_id = ?bound tocapabilities.auth.user.id. The default shape, and the one most apps need. - Public read, owner write
- Blog posts, product listings: a
publicread function serves anyone; a separaterequiredwrite 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
membershipstable 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.
| Surface | Availability |
|---|---|
| Portal UI | Read-only — the console shows each function's auth mode and capabilities; changing them is a project.toml edit |
| CLI | snoozestack check validates the declarations; snoozestack publish ships them |
| SDK / HTTP | The 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:
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 bindscapabilities.auth.user.idinto its SQL itself.- There is no fail-closed default at the row level — a
dbfunction that forgets thewhereclause 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.