Self-registry toggle, invite links with attribution, first-user-admin

Three pieces of a single registration story.

1. **Self-registry toggle.** New generic `settings` key/value table.
   Initial key: `self_registry_enabled` (default `1`). Admin-only PATCH
   /api/settings flips it. GET /api/settings is public so the login
   screen can hide the "Opprett konto" CTA when registration is closed.

2. **Invite links.** New `invites(token, inviter_user_id, created_at,
   claimed_at, claimed_by_user_id)` table; tokens are 22-char base64url
   (~128 bits of entropy). Endpoints:
     POST   /api/invites             — create (any logged-in user)
     GET    /api/invites             — list mine
     DELETE /api/invites/:token      — cancel an unclaimed invite
   Claimed invites are kept in the DB (the audit trail of who-invited-
   whom survives) — only unclaimed ones can be cancelled.

   The signup endpoint accepts an optional `invite_token`. The signup
   handler does the claim + user-insert in a single SQLite transaction
   so we can't end up with a claimed invite pointing at a missing user.
   A concurrent claim race is closed by `UPDATE … WHERE claimed_at IS
   NULL` — only one transaction's UPDATE actually flips the column.

   New `users.invited_by` column records the inviter id so accounts have
   a traceable origin. Profile page shows the user's invites with
   "Kopier lenke" / "Avbryt" buttons; the SPA serves /invite/<token>
   into the Signup view with the token prefilled.

3. **First-user auto-admin.** The signup handler counts users *before*
   the insert; if it's the first one, `is_admin` is set on the row.
   This solves the bootstrap chicken-and-egg without an env var or
   sqlite3 step. Documented in README.

When self-registry is **off**:
  - The login screen hides "Opprett konto" and shows a "stengt" notice
  - /api/auth/signup with no invite returns 403 signup_closed
  - /api/auth/signup with a valid invite still works (and attributes)
  - /api/auth/signup with an *invalid* invite returns 403 invalid_invite

When self-registry is **on**:
  - Anyone can sign up (no invite required)
  - An invite that comes along is still consumed for attribution
  - An invalid invite is ignored — signup proceeds without attribution

26 tests still pass; typecheck clean; bundle 31.2 KB → 32.7 KB gzipped.
This commit is contained in:
Ole-Morten Duesund 2026-05-25 13:45:32 +02:00
commit 755a615f61
14 changed files with 567 additions and 37 deletions

View file

@ -22,6 +22,33 @@ export interface SignupRequest {
// verifier, recovery-complete is rejected (closing the lockout DoS).
rec_auth_salt: string;
rec_auth_verifier: string;
/**
* Optional invite token. Required when self-registry is disabled.
* When present and valid, the new user's `invited_by` is set to the
* inviter and the invite is marked claimed.
*/
invite_token?: string;
}
// --- Site settings ---------------------------------------------------------
/** Subset of settings exposed to anonymous clients (gates the signup UI). */
export interface PublicSettings {
self_registry_enabled: boolean;
}
export interface SettingsUpdateRequest {
self_registry_enabled?: boolean;
}
// --- Invites ---------------------------------------------------------------
export interface InviteEntry {
token: string;
/** Absolute URL the inviter shares: <origin>/invite/<token>. */
url: string;
created_at: number;
claimed_at: number | null;
/** Display name (or email prefix) of the user who claimed it, if any. */
claimed_by_display: string | null;
}
export interface ChallengeResponse {