From 5abe13166a8ddaeeae85b351129462118274bcf2 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 29 Apr 2026 01:57:58 +0200 Subject: [PATCH] docs(systems): document invite-links + split registration gate --- docs/systems/admin.md | 68 +++++++++++++++++++++++- docs/systems/api.md | 88 ++++++++++++++++++++++++++++--- docs/systems/auth.md | 52 ++++++++++++++---- docs/systems/client-federation.md | 20 +++++++ docs/systems/database.md | 39 +++++++++++++- 5 files changed, 248 insertions(+), 19 deletions(-) diff --git a/docs/systems/admin.md b/docs/systems/admin.md index 925cd2bb..d1b3b939 100644 --- a/docs/systems/admin.md +++ b/docs/systems/admin.md @@ -9,10 +9,13 @@ Source files: - `packages/server/src/utils/storageJanitor.ts` -- Storage stats, orphan detection, cleanup - `packages/web/src/stores/settingsStore.ts` -- Zustand store for instance/streaming settings - `packages/web/src/components/modals/instanceSettingsPanels/GeneralPanel.tsx` -- General settings UI +- `packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx` -- Registration toggles + invite-link CRUD UI - `packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx` -- Federation peers panel (peering, approval queue, peer status, rotation, reset) - `packages/web/src/components/modals/instanceSettingsPanels/StoragePanel.tsx` -- Storage management UI - `packages/web/src/components/modals/instanceSettingsPanels/StreamingPanel.tsx` -- Streaming config UI - `packages/web/src/components/modals/instanceSettingsPanels/UsersPanel.tsx` -- User management UI +- `packages/server/src/routes/invites.ts` -- Admin invite-link CRUD endpoints +- `packages/server/src/utils/inviteService.ts` -- Token generation, derived status, atomic redemption transaction - `packages/shared/src/types.ts` -- Shared type interfaces - `packages/shared/src/constants.ts` -- Streaming constants (resolutions, framerates, bitrate matrix) @@ -432,12 +435,75 @@ All panels live under `packages/web/src/components/modals/instanceSettingsPanels #### GeneralPanel -Manages: instance name, registration toggle, discovery toggle, GIF API key, federation relay toggle/TTL. +Manages: instance name, discovery toggle, GIF API key, federation relay toggle/TTL. - Instance name input: max 32 chars, enforced client-side via `slice(0, 32)` - GIF key: password input, separate dirty tracking (`gifKeyDirty`). Only sent on save if modified. "Clear key" button sets empty string. - Federation relay toggle and TTL input: drive `federationRelayEnabled` and `federationRelayTtlDays` instance settings. +The registration toggles (`registrationOpen` / `federatedRegistrationOpen`) and the invite-link manager live in [RegistrationPanel](#registrationpanel). + +#### RegistrationPanel + +Owns the two independent registration gates and the admin invite-link CRUD surface. Mounted in the instance settings sidebar between **General** and **Users**. + +**Toggles** (top of panel) — bound to `settingsStore.instanceSettings.registrationOpen` and `.federatedRegistrationOpen`. Save bar appears at the bottom of the panel when either toggle differs from the synced value (existing pattern from `GeneralPanel`): + +- **Public registration** (`registrationOpen`) — "Allow anyone to create a local account from `/register`. When off, only invite links work for new local accounts." +- **Federated registration** (`federatedRegistrationOpen`) — "Allow users from other instances to create a federated account here via their Connections settings. Existing federated accounts log in normally." + +**Invite Links** (below the toggles) — segmented `[Active] [Archived]` tabs (local component state, default `active`). Tab switch refetches via `GET /api/admin/invites?status=...`. A `tabRef` discards stale in-flight fetches when the admin switches tabs mid-load. `[+ Create link]` button opens the Create modal. + +**Invite row** — name (left, primary text), usage indicator (`usedCount / maxUses` or `usedCount uses · unlimited`, color-coded amber at >=80%), status pill on archived tab (`Expired` rose / `Exhausted` amber / `Revoked` txt-tertiary), expiry summary subline (active: `Expires in 4 days` / `No expiration`; archived: `Expired Apr 25` / `Exhausted Apr 27` / `Revoked Apr 28`), creator + relative-time created. + +**Row actions** branch on derived status: +- **Active rows:** `Copy link`, `Edit`, `Revoke`, kebab → `Delete permanently`, `View redemptions`. +- **Archived rows:** `Reinstate`, kebab → `Delete permanently`, `View redemptions`. + +**Modals:** + +- **Create Invite** — Name (1-64 chars), Max uses (radio: Unlimited / `[N >= 1]`), Expires (preset: `1 hour` / `24 hours` / `7 days` / `30 days` / `Never` / `Custom…`). Defaults: `maxUses: null`, `expiresAt: now + 7 days`. On success the URL is auto-copied to clipboard and the new row animates in at the top of the active list. +- **Edit Invite** — same shape as Create, pre-filled. Hidden for `revoked` rows (Reinstate is the only path back). +- **Reinstate — Variant A** (was `revoked`): rotates the token (`tokenRotated: true`). Modal copy: "This will generate a new link. The previously revoked URL stays inactive." Required to bump `maxUses` and/or `expiresAt` so the resulting row derives status `active` (server returns 400 otherwise). +- **Reinstate — Variant B** (was `expired` or `exhausted`): preserves the same token (`tokenRotated: false`). Modal copy: "The same link will start working again. Anyone who saved the URL will be able to use it." Same bump-to-active validation. +- **Delete confirmation** — uses the existing `ConfirmDialog` with `variant="danger"`, copy: "Delete `` permanently? This cannot be undone. Redemption history for this link will also be removed. If you only want to stop the link from working, use Revoke instead — that preserves the redemption record." (Spec §4.2 originally proposed type-to-confirm, but the codebase uses the existing `ConfirmDialog` precedent for high-blast-radius admin actions; type-to-confirm was not introduced as a one-off pattern.) + +**Redemption viewer** — opens via `View redemptions` action. Shows `usedCount of maxUses` header (or `usedCount uses · unlimited`), then one row per redemption: `registrantUsername` (left) + `redeemedAt` formatted (right). When `currentUsername !== registrantUsername` (post-rename) or `isDeleted === true`, the registrant name is annotated `alice (now Anastasia)` / `alice (now Deleted User)` — snapshot stable, live state visible. Clicking a non-deleted row opens the user's profile (`UserPopover`). If the invite is revoked, a banner at the top notes "The redemptions above represent users who registered before revocation." + +**Invites outlive their creator's account.** `invite_links.createdBy` has no CASCADE — when an admin is tombstoned, their invites stay live and any current admin can manage them. The list joins `users` to surface `createdByUsername`, which resolves to `'Deleted User'` when the creator has `isDeleted = 1` (matching the `sanitizeUser` convention). + +**State ownership.** Invite CRUD is **not** in `settingsStore` — it's transient panel state owned by `RegistrationPanel` (`useState` for `tab`, `invites`, `invitesLoading`, modal flags). Pattern matches `UsersPanel`. Reasoning: invites are page-scoped, not session-scoped — caching them globally would just create staleness bugs when the panel reopens. + +**Type shapes** (from `packages/shared/src/types.ts`): + +```typescript +type InviteLinkSummary = { + id: string; + token: string; + name: string; + status: 'active' | 'expired' | 'exhausted' | 'revoked'; // derived; never stored + maxUses: number | null; + usedCount: number; + expiresAt: number | null; + revokedAt: number | null; + createdBy: string; + createdByUsername: string | null; // 'Deleted User' when creator's isDeleted = 1 + createdAt: number; + url: string; // server-built `https:///register?invite=` — clients MUST NOT assemble +}; + +type InviteRedemption = { + id: string; + userId: string | null; // null only on hard-delete (defensive — tombstone keeps row populated) + registrantUsername: string; // snapshot at registration moment + currentUsername: string | null; + isDeleted: boolean; + redeemedAt: number; +}; +``` + +See [api.md → Admin: Invite Management](api.md#admin-invite-management-routesinvitests) for full endpoint signatures and the [auth.md → Invite Tokens](auth.md#invite-tokens) section for the redemption transaction + audit trail. + #### FederationPanel Manages: federation peers list, pending approval requests (inbound + outbound), manual peering initiation, secret rotation, peer reset. diff --git a/docs/systems/api.md b/docs/systems/api.md index f84412e9..7d9f0792 100644 --- a/docs/systems/api.md +++ b/docs/systems/api.md @@ -7,11 +7,28 @@ Source files: `packages/server/src/routes/*.ts` ## Auth (`routes/auth.ts`) — public, rate-limited ``` -POST /auth/register { username, password, displayName?, avatarColor?, homeInstance?, homeUserId? } → { token, user } +POST /auth/register { username, password, displayName?, avatarColor?, homeInstance?, homeUserId?, inviteToken? } → { token, user } GET /auth/check-username ?username= → { available, reason? } +GET /auth/check-invite ?token= → CheckInviteResponse POST /auth/login { username, password } → { token, user } ``` +**`POST /auth/register` gating** — branches on whether `homeInstance` is set: +- **Federated path** (`homeInstance` set): gated solely by `instance_settings.federatedRegistrationOpen`. `inviteToken` is ignored entirely (not validated, not consumed). 403 `Federated registration is closed on this instance` when closed. Existing federated stubs (relay-created, `passwordHash = '!federation-replicated'`) upgrade in place — login is never blocked by this gate. +- **Local path** (no `homeInstance`): + - When `registrationOpen` is true: `inviteToken` is silently ignored (no row touched, no `usedCount` increment). + - When `registrationOpen` is false: `inviteToken` is required. The token is pre-validated, then the user INSERT + `usedCount` increment + `invite_redemptions` row INSERT all run in a single transaction (`inviteService.redeemInvite`). 403 `Registration is closed. An invite is required.` (no token) or `Invalid or expired invite` (token rejected at any stage, including a concurrent-redemption race re-check inside the transaction). + +**`GET /auth/check-invite`** — public, rate-limited 30/min/IP. Always returns 200; the body discriminates: + +```typescript +type CheckInviteResponse = + | { valid: true; name: string } // active token; name surfaces for UX + | { valid: false; reason: 'expired' | 'exhausted' | 'invalid' }; +``` + +`revoked`, malformed (non-22-char-base64url), and not-in-DB tokens all collapse to `'invalid'` (enumeration shield). `name` is returned **only** in the valid case. + ## Users (`routes/users.ts`) — auth required ``` GET /users/@me → { user } @@ -189,18 +206,20 @@ Permissions checked: CONNECT, SPEAK, STREAM (space channels). DM calls: always f ## Instance (`routes/instance.ts`) — public ``` -GET /instance/info → { name, version, registrationOpen } +GET /instance/info → { name, version, registrationOpen, federatedRegistrationOpen } ``` +`federatedRegistrationOpen` is a UX hint consumed by the Connections add-instance pre-flight (see `client-federation.md`). The 403 from `POST /auth/register` remains the security boundary. ## Settings (`routes/settings.ts`) ``` GET /settings/streaming (auth) → { streamingLimits } PATCH /settings/streaming (admin) → { streamingLimits } -GET /settings/instance (admin) → { instanceName, registrationOpen, discoveryEnabled, ... } -PATCH /settings/instance (admin) { instanceName?, registrationOpen?, discoveryEnabled?, - gifApiKey?, maxUploadSizeMb?, federationRelayEnabled?, - federationRelayTtlDays? } → { settings } +GET /settings/instance (admin) → { instanceName, registrationOpen, federatedRegistrationOpen, discoveryEnabled, ... } +PATCH /settings/instance (admin) { instanceName?, registrationOpen?, federatedRegistrationOpen?, + discoveryEnabled?, gifApiKey?, maxUploadSizeMb?, + federationRelayEnabled?, federationRelayTtlDays? } → { settings } ``` +`registrationOpen` and `federatedRegistrationOpen` are **independent** toggles. PATCH validates `federatedRegistrationOpen` is `boolean` if provided; rejects 400 otherwise. `registrationOpen` is stored as a nullable column (null = fall back to `config.registrationOpen` env default); `federatedRegistrationOpen` is NOT NULL with default 1. ## Admin (`routes/admin.ts`) — admin required ``` @@ -215,6 +234,63 @@ POST /admin/users/:id/reset-password → { temporaryPasswo DELETE /admin/users/:id → { success } ``` +## Admin: Invite Management (`routes/invites.ts`) — admin required + +All endpoints sit behind `[authenticate, requireAdmin]`. Mutating endpoints wrap their read-modify-write in a SQLite transaction with an in-txn re-fetch + status re-derive; any state mismatch returns 409. Service layer: `packages/server/src/utils/inviteService.ts` (`InviteValidationError` → 400, `InviteNotFoundError` → 404, `InviteStateConflictError` → 409). + +``` +POST /admin/invites { name, maxUses, expiresAt } → InviteLinkSummary (201) +GET /admin/invites ?status=active|archived (default: active) → { invites: InviteLinkSummary[] } +PATCH /admin/invites/:id { name?, maxUses?, expiresAt? } → InviteLinkSummary +POST /admin/invites/:id/revoke → { invite: InviteLinkSummary } +POST /admin/invites/:id/reinstate { maxUses?, expiresAt? } → { invite: InviteLinkSummary, tokenRotated: boolean } +DELETE /admin/invites/:id → { success: true } +GET /admin/invites/:id/redemptions → { redemptions: InviteRedemption[] } +``` + +**`POST /admin/invites`** — `name` 1-64 chars trimmed; `maxUses` null (unlimited) or positive integer; `expiresAt` null (never) or epoch ms strictly greater than `Date.now()`. 400 on shape violation. + +**`GET /admin/invites?status=`** — `active` returns rows whose derived status is `active`; `archived` returns `expired | exhausted | revoked`. Sort `createdAt DESC`. Joins `users` to surface `createdByUsername` (`'Deleted User'` if creator's `isDeleted = 1`). + +**`PATCH /admin/invites/:id`** — partial body. 400 if `maxUses` is a positive integer less than the current `usedCount` (would retroactively exhaust — admin should revoke instead). 409 if the invite is currently `revoked` (status conflict — reinstate first). + +**`POST /admin/invites/:id/revoke`** — sets `revokedAt = Date.now()`. 409 if already revoked. + +**`POST /admin/invites/:id/reinstate`** — branches on the row's pre-reinstate derived status: +- **Path A — was `revoked`**: rotates the token (`crypto.randomBytes(16).toString('base64url')`), clears `revokedAt`, applies any provided `maxUses`/`expiresAt` overrides. Response includes `tokenRotated: true`. 400 if the resulting row would still derive non-`active` (caller must bump enough). +- **Path B — was `expired` or `exhausted`**: token preserved. Applies overrides. Response `tokenRotated: false`. 400 same rule. +- **Path C — already `active`**: 409 "Invite is already active." Pure no-op rejection. + +**`DELETE /admin/invites/:id`** — hard-delete. CASCADE removes all `invite_redemptions` rows for this invite. Allowed in any status. 404 if not found. + +**`GET /admin/invites/:id/redemptions`** — sort `redeemedAt DESC`. Each row includes the registration-moment snapshot (`registrantUsername`) plus the live joined state (`currentUsername` / `isDeleted`) so the UI can render `alice (now Deleted User)` for renamed/tombstoned users. + +```typescript +type InviteLinkSummary = { + id: string; + token: string; + name: string; + status: 'active' | 'expired' | 'exhausted' | 'revoked'; // derived + maxUses: number | null; + usedCount: number; + expiresAt: number | null; + revokedAt: number | null; + createdBy: string; + createdByUsername: string | null; // 'Deleted User' if creator tombstoned + createdAt: number; + url: string; // server-built `https:///register?invite=` — clients MUST NOT assemble +}; + +type InviteRedemption = { + id: string; + userId: string | null; // null only on hard-delete (defensive — tombstone keeps row) + registrantUsername: string; // snapshot at registration moment + currentUsername: string | null; + isDeleted: boolean; + redeemedAt: number; +}; +``` + ## Federation (`routes/federation.ts`) ``` POST /federation/peer/initiate (admin) { remoteOrigin } → peer created diff --git a/docs/systems/auth.md b/docs/systems/auth.md index be71f6e3..810ece90 100644 --- a/docs/systems/auth.md +++ b/docs/systems/auth.md @@ -1,7 +1,9 @@ # Authentication & Session System Source files: -- `packages/server/src/routes/auth.ts` -- Registration, login, username availability endpoints +- `packages/server/src/routes/auth.ts` -- Registration, login, username availability, invite-token check endpoints +- `packages/server/src/routes/invites.ts` -- Admin invite-link CRUD (create / list / patch / revoke / reinstate / delete / redemptions) +- `packages/server/src/utils/inviteService.ts` -- Invite token generation, status derivation, atomic `redeemInvite()` transaction - `packages/server/src/routes/users.ts` -- Password change, account deletion endpoints (lines 58-156) - `packages/server/src/routes/admin.ts` -- Admin password reset endpoint (lines 232-265) - `packages/server/src/utils/auth.ts` -- Password hashing, JWT sign/verify, `authenticate` preHandler, `requireAdmin` @@ -135,21 +137,47 @@ DB value overrides env when explicitly set by admin. When closed, a valid `invit **Invariants** (spec §1.3): -- **Login is never gated** by either toggle. Both gates affect *creation only*. Existing accounts remain loginable regardless of policy. -- The federated stub upgrade flow (below) is gated by `federatedRegistrationOpen`, never by an invite token. +- **Login-unaffected invariant.** Neither toggle gates `POST /api/auth/login` for any user. Existing federated accounts always log in regardless of `federatedRegistrationOpen`; existing local accounts always log in regardless of `registrationOpen`. Both gates affect **creation only**. This is why the Connections add-instance form keeps its submit button enabled even when the target instance has `federatedRegistrationOpen = false` (see `client-federation.md`): the request runs through `instanceStore`'s register-then-login fall-through, and the login leg succeeds for users who already have a federated account on that instance. +- The federated stub upgrade flow (below) is gated by `federatedRegistrationOpen`, never by an invite token. Tokens only unlock the local anonymous-signup path. **Toggle matrix** (spec §5.6): -| `registrationOpen` | `federatedRegistrationOpen` | Local register | Federated register | -|---|---|---|---| -| true | true | open | allowed | -| true | false | open | 403 | -| false | true | invite-required | allowed | -| false | false | invite-required | 403 | +| `registrationOpen` | `federatedRegistrationOpen` | Local register | Federated register | Connections UI behavior | +|---|---|---|---|---| +| true | true | open | allowed | normal | +| true | false | open | 403 | warning banner; submit enabled (login fall-through) | +| false | true | invite-required | allowed | normal | +| false | false | invite-required | 403 | warning banner; submit enabled (login fall-through) | +| false (any) | (any) | invite bypasses | NOT bypassable by token | — | + +S2S DM stub creation (relay path, never `/register`) is gated only by federation peering settings — neither toggle affects it. ### Invite Tokens -When `registrationOpen` is false, the local-signup path accepts an `inviteToken` field on the register body. Token format: 22-char base64url (16 random bytes). Lifecycle and admin CRUD live in `inviteService.ts` and `routes/invites.ts` -- see `docs/systems/admin.md` for the panel UX and the full status state machine. +When `registrationOpen` is false, the local-signup path accepts an `inviteToken` field on the register body. Token format: 22-char base64url (`crypto.randomBytes(16).toString('base64url')` — 128 bits of entropy; collision probability against the existing space is `~2^-122`, with the DB UNIQUE on `invite_links.token` as the safety net + retry-up-to-3 in the create handler). Admin CRUD lives in `inviteService.ts` and `routes/invites.ts` -- see `docs/systems/admin.md` for the panel UX and the full status state machine. + +**Lifecycle:** + +``` +create → active ──(usedCount = maxUses)─→ exhausted ┐ + │ │ + │ ┌──(expiresAt < now)──→ expired ──────┤ + │ │ │ + ↓ ↓ │ + revoke ──(revokedAt set)──→ revoked │ reinstate + │ ←┘ (Path A: token rotates; + │ Path B: same token) + └──→ active again + │ + DELETE /admin/invites/:id + │ + ↓ + hard-delete (CASCADE redemptions) +``` + +Status is **derived** at read time from `(revokedAt, expiresAt, usedCount, maxUses)` — no stored column. Reinstate branches on the pre-reinstate status: revoked → token rotates (`tokenRotated: true`); expired/exhausted → token preserved (`tokenRotated: false`); already-active → 409. + +**Audit trail (`invite_redemptions`):** every successful redemption inserts one row with `inviteId` (FK CASCADE — admin hard-delete drops the audit), `userId` (FK SET NULL — defensive against future hard-delete; tombstone keeps it populated), `registrantUsername` (snapshot at registration moment, preserves forensic value when the user is later renamed or tombstoned `!deleted:{uid}`), and `redeemedAt`. Atomic redemption (spec §2.4): @@ -580,6 +608,7 @@ If `user` is a replicated alias of `homeUser` (via `isSelf`), returns `homeUser` |----------|-----|--------| | `POST /api/auth/register` | 10 | 2 min | | `GET /api/auth/check-username` | 30 | 1 min | +| `GET /api/auth/check-invite` | 30 | 1 min | | `POST /api/auth/login` | 15 | 2 min | | `POST /api/users/@me/change-password` | 5 | 15 min | | `DELETE /api/users/@me` | 3 | 15 min | @@ -594,7 +623,8 @@ All keyed by `request.ip`. |------------|---------|---------|-------| | `jwtSecret` | `JWT_SECRET` | (required) | Min 32 chars, startup crash if shorter | | `jwtExpiresIn` | `JWT_EXPIRES_IN` | `'30d'` | Passed to `jsonwebtoken` `expiresIn` option | -| `registrationOpen` | `REGISTRATION_OPEN` | `true` | Overridden by `instanceSettings.registrationOpen` in DB | +| `registrationOpen` | `REGISTRATION_OPEN` | `true` | Overridden by `instanceSettings.registrationOpen` in DB (null DB row = env fallback). Local anonymous signup gate. | +| (no env) | -- | `true` | `instanceSettings.federatedRegistrationOpen` is DB-only (NOT NULL DEFAULT 1) — no env override. Federated identity replication gate. | --- diff --git a/docs/systems/client-federation.md b/docs/systems/client-federation.md index 7a40f1ca..08a50082 100644 --- a/docs/systems/client-federation.md +++ b/docs/systems/client-federation.md @@ -286,6 +286,26 @@ The **Connections** panel (in user settings) allows managing remote instance con - **Remote Instances** — each shows status (connected/disconnected/error), hostname, username. Actions: Reconnect, Re-authenticate, Sync Password, Disconnect. - **Add Instance** — multi-step form: enter hostname → verify password → register/login → connected. +### Add-Instance Pre-Flight: `federatedRegistrationOpen` + +The hostname-probe step calls `GET /api/instance/info` on the target. The response carries two registration fields: + +```typescript +{ name, version, registrationOpen: boolean, federatedRegistrationOpen: boolean } +``` + +`federatedRegistrationOpen` is the gate for **creating a federated `username@thisInstance` account** via the Connections flow. When the probe returns `federatedRegistrationOpen === false`, `ConnectedInstances.tsx` (the AddInstanceFlow's password step) renders an amber-tinted banner above the password input: + +> "This instance has disabled new federated registrations. Existing accounts can still sign in." + +**The submit button stays enabled.** This is the [login-unaffected invariant](auth.md#3-registration-flow) made operational on the client. The flow runs through `instanceStore`'s register-then-login fall-through: +- A user **without** an existing federated account on the target — register attempts 403 with `Federated registration is closed on this instance`; login attempts then fail with the existing "no account" error; the user sees the post-error toast. +- A user **with** an existing federated account on the target — register 403s, then login succeeds against their existing credentials. Working path preserved for legitimate re-login. + +Disabling submit would extend the gate into login territory and soft-lock users with existing accounts on a closed instance — exactly the failure mode the invariant prevents. The 403 server-side stays as the security boundary; the banner is a UX hint. + +The probe response is not cached client-side beyond the in-flight request, so toggle flips on the target are observed on the next add-instance attempt without explicit invalidation. + ### Identity Deletion Each remote instance row exposes an identity deletion flow with three modes: diff --git a/docs/systems/database.md b/docs/systems/database.md index c64b5ea6..59461d7f 100644 --- a/docs/systems/database.md +++ b/docs/systems/database.md @@ -319,6 +319,42 @@ PK: (spaceId, userId, restrictionType) --- +## Registration Invites + +### invite_links +Admin-managed registration invite tokens. Status (`active` / `expired` / `exhausted` / `revoked`) is **derived** at read time from `revokedAt` + `expiresAt` + `usedCount`/`maxUses`; there is no stored status column. See `packages/server/src/utils/inviteService.ts` (`inviteStatus()`). + +| Column | Type | Default | Notes | +|--------|------|---------|-------| +| id | text PK | | Snowflake | +| token | text UNIQUE NOT NULL | | 22-char base64url (`crypto.randomBytes(16).toString('base64url')`). Rotated on revoked → reinstate. UNIQUE constraint provides the lookup index. | +| name | text NOT NULL | | Admin-facing label, 1-64 chars trimmed. | +| createdBy | text NOT NULL | | FK → users.id (no CASCADE — admin tombstone keeps the row resolvable). | +| createdAt | integer NOT NULL | | Epoch ms | +| maxUses | integer | | NULL = unlimited; positive integer otherwise. | +| usedCount | integer NOT NULL | 0 | Incremented atomically inside the redemption transaction. | +| expiresAt | integer | | Epoch ms; NULL = never expires. | +| revokedAt | integer | | Epoch ms; NULL = not revoked. Set by revoke endpoint, cleared by reinstate. | + +**Index:** `idx_invite_links_created_at` on `(createdAt)`. + +### invite_redemptions +One row per successful invite-token consumption. Surrogate ID supports future per-redemption metadata without schema churn. + +| Column | Type | Notes | +|--------|------|-------| +| id | text PK | Snowflake | +| inviteId | text NOT NULL | FK → invite_links.id ON DELETE CASCADE — hard-deleting an invite drops its redemption history with it. | +| userId | text | FK → users.id ON DELETE SET NULL — defensive against future hard-delete paths; tombstone (soft-delete) keeps the row populated. | +| registrantUsername | text NOT NULL | Snapshot of username at the registration moment. Preserves forensic value when the user is later renamed or tombstoned (`!deleted:{uid}`). | +| redeemedAt | integer NOT NULL | Epoch ms | + +**Indexes:** `idx_invite_redemptions_invite_id` on `(inviteId)`, `idx_invite_redemptions_user_id` on `(userId)`. + +The user INSERT, `usedCount` increment, and redemption row INSERT all run in a single SQLite transaction (`inviteService.redeemInvite()`), which re-derives status under the transaction to close the TOCTOU window between `/check-invite` and `/register`. + +--- + ## Instance Settings (singleton, id=1) | Column | Type | Default | Notes | @@ -334,7 +370,8 @@ PK: (spaceId, userId, restrictionType) | allowedFramerates | text NOT NULL | `'30,45,60'` | CSV | | maxResolution | integer NOT NULL | 1080 | | | maxFramerate | integer NOT NULL | 60 | | -| registrationOpen | integer | | null = use env | +| registrationOpen | integer | | Local-anonymous-signup gate. null = use env (`config.registrationOpen`); 0/1 = explicit admin override. | +| federatedRegistrationOpen | integer NOT NULL | 1 | Independent gate for federated identity replication via Connections (`POST /api/auth/register` with `homeInstance` set). Existing federated accounts always log in regardless of this value. | | gifApiKey | text | | Klipy API key | | bitrateMatrixOverrides | text | | JSON sparse overrides | | allowCustomBitrate | integer NOT NULL | 1 | |