ADR 0017: Identity and roles
ADR 0017: Identity and roles
ADR 0017: Identity and roles
- Status: Accepted
- Date: 2026-10-28, accepted 2026-10-29
Context
Path step 6 replaces the cookies that say who a browser is, and the shared admin password, with server-side login sessions, a site-admin role and join links (05, D1, D2, D11 in 08). The sign-in screens are the last Next pages. This ADR settles the shapes before the work starts, and amends ADR 0016 point 4.
Decision
- No rename.
guestsstays the person table andevent_gueststhe participation; code keeps the word “guest”. The glossary’s terms (Person, Participation) are used in docs only. - Login sessions are rows:
login_sessions(id hash, guest, event?, assurance, created, expires, lastSeen, device)(05’s table, withdeviceadded). Theguestcookie holds only the opaque id; revoking is a row delete. Picking a name creates aclaimedrow; an emailed code, a password or a join link averifiedone.resolveActorreads the row, and a protected guest can be acted as only through averifiedrow. A row expires 30 days after its last use (lastSeen, refreshed at most once a day), so a person signs in once per device, not once per visit. The jobs loop prunes expired rows. - Old cookies are not honoured. The
open.andverified.values read as no identity, so the upgrade signs everyone out once. The release notes say so; organizers upgrade between events. - Credentials: password, emailed login code, reset link, join link. Email is optional per
person, since a deployment need not send mail. A person may hold an optional unique
username; password sign-in takes the email or the username. The password hash stays scrypt (node:crypto, no native module); 05 says argon2id and is updated. The stored format (scrypt$salt$key) holds no cost parameters; it will carry N/r/p so cost can rise later. Passkeys and API tokens wait for a later plan. - Sign-in API under
/api/v1/auth/(request a code, log in with code or password, reset, sign out) and/api/v1/me/credentials,/api/v1/me/login-sessions. Strict rate limits and Origin checks, as for the admin API. Problem codes name a wrong credential (a wrong, expired or spent password or code are one code, so a guess learns nothing) and too many attempts. This amends ADR 0016 point 4 (“no API for passwords”): the API now exists, because the SPA’s sign-in screens use it. - Token rules. Login-session ids, codes, reset tokens and join tokens are random and stored only as sha256 hashes: 32 random bytes for ids and reset or join tokens; the emailed code is 8 characters (~40 bits, per-code salt) and retires after 100 wrong tries. Nothing can show a token again; only a new one can be made. “Request a code” and “reset” answer the same for an unknown email (no enumeration). Each sign-in issues a new row id and never upgrades a claimed row in place (session fixation). A reset link changes nothing until the new password is set, so a request for someone else’s address locks no one out. Setting or resetting a password revokes the person’s other rows.
- Roles:
siteAdminon the guest. The actor is admin only through a verified session of a site admin. The role protects the name, and a site admin cannot remove the protection. Changes an admin makes record which admin. Theorganizerparticipation role waits for a later plan; what an organizer may do per event is a product question of its own. - Admin bootstrap.
ADMIN_PASSWORDand the admin cookie go; the admin UI is always on. While no site admin exists, the server logs a single-use bootstrap link at start (hashed, 24 h; a new start replaces it), and a script prints a new one for recovery. The bootstrap page signs the visitor in as a chosen or new guest, asks for a username and a password, and grants the role. A setADMIN_PASSWORDonly logs a deprecation warning. The admin users screen grants and revokes the role, never for the last one. - Join links are personal: one per guest per event, with no expiry. A link signs its person in
(verified) and joins the event while that person has set no password since the link was made.
After that it asks for a sign-in as that person, then joins the event. An admin makes a new link
for one person (to copy into a messenger) or mails new links to all or selected participants with
an email; a new link ends the person’s previous one for that event, and the person is emailed
that a new link exists, so a scam to get someone’s link is noticed. A new link is also the
recovery when a person without email forgets the password.
/join/{token}in the SPA exchanges the token, replaces the history entry and opens the event. A QR sheet waits. - Acting: identity mode is a column on events,
open(picking a name is enough) orverified. Existing events migrate toopen(behaviour unchanged); new events default toverified(product decision, 08). A person is protected if they chose it, are a site admin, or take part in anyverifiedevent, so the strictest event wins. A domain functionassuranceRequired(mode, protection) inpackages/domainis the one copy. Use cases on one event pass that event’s mode to the acting check, so a non-member needs a verified login there too; the rest use the person’s protection. The SPA does not predict it: the snapshot carries each guest’s protected flag. - Reading is a second, separate setting, decided here and built in the next plan:
public,link(a secret event link that sets a cookie and redirects to the plain URL; rotatable;Referrer-Policy: no-referrer; the event is hidden from the root list) orparticipants(default). A kiosk gets a read-only device link that is no person. Until thenSITE_PASSWORDstays an optional gate: its screen moves to the SPA and the gate to Hono, and it goes with the read setting (D2). - Retiring Next (not identity) goes through Hono first: the proxy, media and SPA serving move
into the Hono app while Next still runs it from one catch-all route; then a Node entry replaces
Next.
/api/healthstays as an alias of/api/v1/health, because monitors and the Docker healthcheck call it.
Consequences
- The admin password and the admin cookie go; one identity model serves guests and admins.
- A stolen cookie value is useless without its row, and a person can sign out other devices.
- The upgrade signs everyone out once.
- An attendee can use one personal link for the whole event and never set a password; one who sets a password ends the link.
- Mailing or copying links makes new ones, which ends links sent before.
- Existing events keep their behaviour; a fresh event asks for verification.
- Until the read setting exists, anyone who passes the site password (or reaches a deployment without one) reads the schedule and the people directory.
Alternatives
- Rename
gueststopeople. Cleaner words, a large migration for no behaviour. - Signed cookies carrying the identity (today). No revocation, and assurance cannot be raised without a new value.
- Honour old cookies for a week. Keeps people signed in across the upgrade, for a second reader and a week of unrevocable sessions.
- argon2id now. Needs a native module or WASM; scrypt from
node:cryptois adequate. - Single-use join links that force a password. Barely safer than a link that ends with the first password, and phone-typed passwords at an event are poor UX; passkeys are the strong option later.
- Store join links readable, so an admin can copy one again. A database leak would then expose every link.
- Email as the only identifier. Excludes deployments and people without email.
- One mode setting with four levels (public, secret URL, personal link, strict). Mixes who may read with who may act; two settings are simpler.
- Per-event organizer role in this plan. Its rights are undecided; it would freeze guesses.