Every action — viewing data, mutating it, calling an endpoint, rendering a UI control — is governed by an explicit authorization rule. The rule is decided server-side; clients receive permissions hints with each response to choose what to render but never enforce authorization themselves.
- Every endpoint in api/ — each declares an auth marker
- Every screen in screens/ — each declares an Authorization section
- Service-layer code that enforces rules independent of the request layer
Four levels, in increasing power. Each row has the powers of all rows above it.
| Level | Slug | Source |
|---|---|---|
| Anonymous | anonymous |
No session, or revoked/expired session |
| User | user |
Default for new accounts |
| Staff | staff |
Set by an administrator |
| Administrator | administrator |
Set by another administrator (or manually in seed data — there's always at least one) |
The level is stored on people.accountLevel. It's mutable only by administrators, only via POST /api/people/:slug/account-level (a deferred endpoint not yet specified). Until that endpoint exists, account-level changes happen via a hand-authored commit to the data repo by an administrator.
Authorization rules in endpoint and screen specs use these markers. The vocabulary is intentionally specific so the server can implement each as a guard.
| Marker | Predicate |
|---|---|
public |
Always passes. |
user |
Session is valid and not revoked. accountLevel != anonymous. |
self |
The current user matches the resource owner (their own profile, their own session, their own membership). |
member |
Current user has a ProjectMembership record for the target project. |
maintainer |
Current user is the project's maintainerId (or equivalent isMaintainer = true on their membership). |
poster / author |
Current user authored the resource (a buzz item, an update, a help-wanted role). |
staff |
accountLevel ∈ {staff, administrator}. |
administrator |
accountLevel = administrator. |
agent:<scope> |
The request carries an active agent grant that includes <scope>. |
When multiple markers separated by | are listed (maintainer | staff), any one satisfying suffices.
Detail endpoints (e.g., GET /api/projects/:slug) return a permissions object — a flat map of canX booleans computed for the current caller. Clients use this to decide which UI to render.
"permissions": {
"canEdit": true,
"canManageMembers": false,
"canPostUpdate": true,
...
}The hint is not authoritative. Every mutation endpoint re-evaluates the rule. A client that lies to itself ("set canEdit to true") can't bypass anything.
alreadyExpressedInterest and similar "state about the caller" fields appear alongside canX flags when relevant.
Sessions are stateless JWTs, not database rows. There is no sessions sheet. Per-request session lookups don't touch gitsheets.
- Access JWT — 15-minute lifetime. Sent on every API request in the
cfp_sessioncookie. Payload:{ sub: personId, jti: uuidv7, accountLevel, exp, iat }. - Refresh JWT — 90-day lifetime. Sent only on refresh requests in a separate
cfp_refreshcookie (path-scoped to/api/auth/refresh). Payload:{ sub: personId, jti, exp, iat }. Every refresh issues a new one, so the 90 days run from the last visit, not from sign-in. - Session hint — a
cfp_has_session=1cookie set and cleared alongside the refresh cookie, with the same lifetime, path/, and notHttpOnly. It carries no secret; it only tells the SPA a refresh is worth attempting, so anonymous visitors never trigger one.
The token cookies: HttpOnly, Secure, SameSite=Lax. Secure is dropped only when host is localhost in development.
Signing: HS256 with CFP_JWT_SIGNING_KEY (server-managed secret, rotated on a cadence). Rotation triggers re-issue of all tokens on next refresh.
GitHub OAuth callback resolves to a Person
(directly, or via /account-claim — see behaviors/account-migration.md)
│
▼
Issue access JWT (15m) + refresh JWT (90d) ─▶ Set cookies
│
▼
Subsequent requests carry access JWT
│
├─ access JWT valid → handler runs
├─ access JWT expired → 401 with `error.code = "access_token_expired"`
│ Frontend hits POST /api/auth/refresh, gets a new pair, retries
└─ refresh JWT expired → 401 with `error.code = "session_expired"` → user re-authenticates
There's a third short-lived JWT — the claim-pending JWT issued by the OAuth callback when legacy candidates exist. It has scope: "claim", lifetime 5 minutes, and is accepted only by the /api/account-claim/* endpoints. See api/account-claim.md.
The access-token TTL is intentionally short so revocation has a small blast radius. The refresh-token TTL is "session length" — 90 days of inactivity logs you out; anyone who visits at least once a season stays signed in indefinitely.
The SPA keeps the session alive. The access cookie expires 15 minutes after it was issued and the browser drops it, so a returning member's first request arrives without one. The SPA therefore:
- On load, when
/api/auth/mereports no person butcfp_has_sessionis present, callsPOST /api/auth/refreshonce and re-reads/api/auth/me. - On any
401from another API call (hint or not — a 401 means the caller tried something that needs a session), callsPOST /api/auth/refreshonce (concurrent 401s share one refresh) and retries the request; if the refresh fails, the401stands and the member is treated as signed out.
Signing in is the slowest thing a member does on this site, and a lapsed session silently turns their next action into a sign-in detour. Stay signed in unless they sign out or don't come back for a season.
Refresh writes nothing to the public store. It issues the new pair and swaps the private session-metadata entry; the old refresh token is not revoked — it stays valid until it expires, the same exposure as the token that replaces it (see Why not refresh-rotation tracking). A revocation per refresh would be a public commit per active member every quarter hour.
Explicit sign-out (or staff revoke) writes the JWT's jti to the Revocation sheet with the token's original expiresAt. On every authenticated request, the API checks the in-memory revokedJtis: Set<jti> set (built from the Revocation sheet at boot, updated synchronously on every revoke).
A periodic in-process task sweeps the Revocation sheet for entries whose expiresAt < now and deletes them — revoked tokens that have naturally expired no longer need to be remembered.
This gives us:
- Survives restart — Revocation sheet is persisted, in-memory set is rebuilt at boot.
- Cheap reads — revocation check is a
Set.has(jti). - Cheap writes — only on explicit sign-out, which is rare. No per-request writes anywhere.
To sign out all devices for a person, write a Revocation entry per active jti we've issued for them — but since we don't store issued JWTs, we instead write a Revocation entry with a sentinel jti = "*" plus the personId, and the revocation check additionally rejects any JWT whose iat is before that sentinel revocation's createdAt. Functionally equivalent to "rotate this user's signing scope as of now."
(That sentinel pattern is implementation guidance, not part of the on-disk schema. The Revocation record shape can accommodate it via jti = "*" + personId.)
A common pattern is to store the latest refresh-token jti per person and reject older ones, detecting refresh-token reuse as a compromise signal. v1 skips this. If we observe refresh-token-reuse incidents in practice, we add it then; it's a localized addition to the refresh endpoint's logic, no schema changes needed.
401 unauthenticatedif no valid session (cookie missing, JWT invalid, JWT revoked, or refresh required) — frontend redirects to/login?return=<current path>403 forbiddenif the session is valid but the caller lacks the required marker — frontend shows an inline error or a 403 page; no redirect
A grant is the one way a program reaches the API as itself rather than as a person: an opaque bearer token an administrator mints for an agent account, with a fixed list of scopes and an expiry of at most 180 days. It exists so an automated moderator never needs a person's session, a browser sign-in, or staff powers.
- Presented as
Authorization: Bearer cfpa_…. The session middleware hashes it (SHA-256), looks the hash up in the in-memory grant table (loaded from the private store at boot, updated on every mint and revoke), and checks: not revoked, not expired, the agent's person record exists, is not deactivated, and isaccountLevel = user. Any failure →401 unauthenticated(error.code = "grant_invalid"), never a fall-through to anonymous, so a broken token is loud. A request with both a grant and a session cookie is refused the same way. - What it satisfies. Only
agent:<scope>markers for its own scopes. For every other marker —user,self,staff, and the optional-session behaviour ofpublicreads — the caller is anonymous. A grant cannot edit a profile, join a project, refresh into a cookie session, or reach any endpoint that does not name its scope. - Who it is. The request's
personis the agent account, so commits are authored by it and self-checks (no voting on yourself) apply. The commit addsActor-Kind: agentandGrant-Id: <id>(storage.md). - Revocation is immediate and needs no signing-key rotation: the grant table is the source of truth on every request, unlike a stateless JWT.
- Limits. Agent requests count against the per-account rate limits like anyone's; endpoints add their own (agent spam votes: api/moderation.md → Agent votes; agent purges: api/moderation.md → Agent purges).
- Deleting is its own scope.
moderation:purgeis never implied by another scope and is the only scope that deletes anything. It does not make an agent an administrator: it reaches one endpoint, for members under seven days old who posted spam, with evidence the API checks, at most five a day. - Secret handling. The token is shown once at mint and never stored,
logged (the
authorizationheader is redacted from request logs), or written to either data store; only its hash is kept, in the private store.
Why not a staff account with a long-lived session: the session JWT lives in
cookies minted by a browser sign-in, its refresh token is not rotated, and it
carries the full staff level (every staff endpoint, every member's email,
deactivating anyone). Votes cast that way would also be recorded as
human-<slug> — false in a public record. A grant is narrower on every count.
Likewise an agent never gets administrator to reach the existing purge: that
would let it purge anyone, of any age, with no stated reason.
- The session cookie is
SameSite=Lax, so cross-origin POSTs from third parties can't carry it. This is sufficient for a same-origin SPA. - If/when the API is opened to a different origin or a separate consumer (e.g., a mobile client), switch to:
- A CSRF token issued at login, returned in a header on each mutating request, validated server-side
- v1 does not implement a header-based CSRF token. The decision is documented so it doesn't become an accidental hole when the deploy topology changes.
The commit log of the data repo is the audit log. There is no separate audit sheet or table.
Every mutation — staff action or self-service edit — lands as a structured commit with author, timestamp, full diff, and trailers. See behaviors/storage.md for the commit-message + trailer convention.
Staff-relevant actions that get audit attention by virtue of being commits:
- Account-level changes (admin grants/revokes staff)
- Project soft-deletes and restores
- Tag merges and deletions
- Help-wanted role transitions when performed by staff (not by the project's own maintainer)
- Member removals when performed by staff
Common audit queries:
| Query | Command |
|---|---|
| Recent staff actions | git log --grep='^Actor-Account-Level: \(staff|administrator\)$' |
| Everything an actor did | git log --author='chris@example.com' |
| All soft-deletes | git log --grep='^Action: \w\+\.soft-delete$' |
| History of one record | git log -- people/janedoe.toml |
A "Recent staff activity" UI is deferred — staff query via git log or the data repo's web interface (GitHub) until then.
GET /api/people/:slugreturns404 not_foundfor soft-deleted people when the caller isn't staff — the same response as a never-existed slug.- Once the GitHub OAuth + account-claim flow is specified, claim-attempt failures (email match misses, password match misses) should also use a uniform response that doesn't distinguish "no such account" from "wrong proof." That's a constraint on the not-yet-written flow, captured here so it isn't forgotten.
Out of scope for v1. The closest mechanism is requesting deletion from staff. A self-serve account-deletion flow is tracked in deferred.md.