5 · Security
“Secure by default” here means three concrete things: a fresh install has no unsafe defaults to fix; every sensitive operation is decided by one policy the tests can name; and private data is private by construction of the audience model, not by remembering a filter.
Threat model
| Actor | Wants | Countered by |
|---|---|---|
| Another attendee | Act as me, read my marks/votes/meetings, edit my session | Assurance levels and protection; audience model; edit policies with version checks |
| Someone with the event link only | Read the schedule and the people directory | Participation required for any read beyond the event’s publicSchedule setting; join code / links |
| A malicious link or page | CSRF a mutation, steal a session | SameSite cookies, Origin check, httpOnly, CSP, no token in URLs after the join redirect |
| A bot with a leaked token | Bulk-read or bulk-write | Scoped tokens, rate limits, revocation, token events in the audit log |
| A hostile upload | Stored XSS, oversized files | Re-encode to fixed formats, no SVG, size caps, hash names, immutable serving, CSP |
| A self-hoster’s mistake | Open admin, default secret | No admin password; secrets generated on first run; bootstrap link consumed once |
| The project itself | Accidental data leak in a new endpoint | Contracts define response shapes; snapshot and feed use stored audiences; tests assert per-role views |
Identity and assurance
A Person may hold zero or more credentials: password (argon2id), passkeys (WebAuthn), and a verified email for codes. Join tokens are per person per event, single-use, expiring, delivered by the organizer as a link or QR code.
A browser login session is a server-side row (
login_sessions(id, person, event?, assurance, created, expires, device);sessionsis the schedule’s table) referenced by an httpOnly, Secure, SameSite=Lax cookie. Nothing about the person is in the cookie; revocation is a row delete. Login sessions are listed and revocable in settings.Assurance is per login session:
claimed(the person picked their name),verified(join token, emailed code, password or passkey). A verified session is the only way to act as a person who has protected their name.The event’s identity mode sets the minimum for acting:
Mode Picking a name Verification Private features (marks, meetings, anonymous RSVPs) openenough to act optional, per person (“protect my name”) available, with a one-time notice that a claimed name is not a lock verifiedshows the verification step required; passwordless by default (join link, code, passkey) available Organizers choose the mode per event. A new event defaults to
verified: the organizer imports the participants and sends everyone their join link (or a QR sheet), and can lower the mode toopenfor an event that wants zero friction. Nobody has to remember a password in either mode, because a join link or an emailed code is always an option.
- Roles:
siteAdminon the person;organizerorattendeeon the participation. Host is derived from the session or proposal. Roles are data, never inferred from a password. - API tokens for bots and automation: hashed at rest, scoped (
read:event,write:sessions,admin), tied to a person or to the site, revocable, every use ofadminscope recorded as a change with audienceorganizers.
Authorization
- Every use case calls a policy before it mutates, with the actor the middleware resolved. A use case without a policy call fails a lint rule (07).
- UI gating is a convenience that uses the same policy; it is never the control.
- Read authorization is the audience model:
audienceOfdecides at write time who may ever see a change,canViewdecides per query. The snapshot is assembled throughcanView. Tests build a fixed world and assert the snapshot for each role (attendee, host, organizer, site admin, bot) as a golden file, so a new field that leaks shows up as a diff. - Private subjects (marks, meetings, votes, notifications, channel bindings) have no repository method that returns rows for anyone but the owner; the “list all” method does not exist, so it cannot be called by mistake.
Transport and browser hardening
- Cookies: httpOnly, Secure, SameSite=Lax, path-scoped. Mutations additionally
require
Sec-Fetch-Sitesame-origin or a matchingOrigin, and token calls require theAuthorizationheader (never a cookie), so a token cannot be CSRF’d and a cookie cannot be used cross-site. - CSP with no inline scripts (the SPA is hashed static files),
frame-ancestors 'none',object-src 'none'; HSTS at the reverse proxy, documented. - Rate limits: strict on
/auth/*and/join, moderate per actor elsewhere; responses carryRetry-After. - Join links land on a page that exchanges the token for a session and then redirects, so the token never sits in a referer or a history entry the next person on a shared phone can reuse.
Input and output
- Every request body and query is parsed by its contract before the use case sees it; unknown fields are rejected, strings have caps, markdown is rendered with a fixed allowlist on the client and never as raw HTML.
- Every response is produced through its contract, which is a positive allowlist of fields. A repository row never reaches the wire.
- Uploads are re-encoded (sharp) to WebP/JPEG at fixed sizes, capped, stored under
a content hash, served with
Content-Typefixed andX-Content-Type-Options: nosniff.
Audit and observability
- The change log is the audit trail: who, what, when, from which correlation. Organizers can see it per event; site admins per site.
- Logs are structured, carry request and correlation ids, and hold no personal data beyond ids. Errors optionally go to a self-hosted error tracker.
Privacy
- Data minimization in the schema: no field without a screen that shows it.
- A person can export their data and delete their account; deletion
anonymizes their changes (actor becomes
deleted) rather than breaking the log, and removes profile, credentials, marks, meetings, bindings. - A per-site data collection statement page is generated from the schema’s data classes (#801), so it cannot drift.
- Retention: change payload pruning, delivery logs and idempotency keys expire; all windows are settings with safe defaults.
Dependencies and build
- Lockfile committed,
bun auditin CI, automated bump PRs, a single pinned runtime version in the Dockerfile. The image runs as a non-root user with a read-only filesystem except the data directory. - Secrets never appear in the image or in compose defaults; the compose file ships
with the variables listed and empty, and the server refuses to start with a
public URL of
localhostin production mode.