diff --git a/docs/systems/admin.md b/docs/systems/admin.md index c551687c..46d62734 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) @@ -53,7 +56,8 @@ Settings are split into two API surfaces: | Field | Type | DB Column | Validation | Notes | |-------|------|-----------|------------|-------| | instanceName | string | instanceName | 1-32 chars, trimmed | Default: `'Backspace'` | -| registrationOpen | boolean | registrationOpen | boolean | DB null = use env `REGISTRATION_OPEN` (default true) | +| registrationOpen | boolean | registrationOpen | boolean | Local-account registration. DB null = use env `REGISTRATION_OPEN` (default true) | +| federatedRegistrationOpen | boolean | federatedRegistrationOpen | boolean | Federated-account creation against this instance. NOT NULL DEFAULT 1. Controls whether remote users can create `username@thisInstance` accounts via Connections (see auth.md + client-federation.md) | | discoveryEnabled | boolean | discoveryEnabled | boolean | Controls space Explore page | | gifApiKey | string? | gifApiKey | string or empty to clear | Returned masked as `****{last4}` for security | | gifEnabled | boolean? | (derived) | -- | Read-only; true when gifApiKey is non-null | @@ -164,11 +168,14 @@ No authentication. Returns: name: string; // instanceSettings.instanceName ?? 'Backspace' version: string; // Hardcoded '1.0.0' in instance.ts registrationOpen: boolean; // DB setting overrides env if non-null + federatedRegistrationOpen: boolean; // NOT NULL DEFAULT 1; gates federated-account creation } ``` Registration resolution order: `instance_settings.registrationOpen` (if not null) > `config.registrationOpen` (from `REGISTRATION_OPEN` env, default true). +`federatedRegistrationOpen` is consumed by the Connections UI (client-federation.md) to decide whether to surface the "create federated account on this instance" affordance. + ### General Instance Settings ``` @@ -428,12 +435,76 @@ 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; + lastRedeemedAt: number | null; // epoch ms of most recent redemption; null when usedCount = 0 + 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..b6eeb73f 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,64 @@ 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; + lastRedeemedAt: number | null; // epoch ms of most recent redemption; null when usedCount = 0 + 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 79dad638..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` @@ -118,11 +120,82 @@ There is **no token blocklist**. The only revocation mechanism is the `passwordC ### Registration Gate -Registration open/closed is determined by: +The `/api/auth/register` route splits its gate by request shape (spec §1.2). There are **two independent toggles** plus an **invite-token bypass** for the local path. + +**Local anonymous signup** (no `homeInstance` in body): + 1. `instanceSettings.registrationOpen` (DB, id=1) -- if not null, this takes priority 2. `config.registrationOpen` (env `REGISTRATION_OPEN`, default `true`) -- fallback -Both are checked. DB value overrides env when explicitly set by admin. +DB value overrides env when explicitly set by admin. When closed, a valid `inviteToken` bypasses the gate and is **atomically consumed** alongside the user insert (see "Invite Tokens" below). When open, an `inviteToken` field is **silently ignored** (no validation, no consumption). + +**Federated identity replication** (request body has `homeInstance`): + +- Gated solely by `instanceSettings.federatedRegistrationOpen` (NOT NULL DEFAULT 1). +- `inviteToken` is **ignored entirely** on this path -- tokens never unlock federated creation, even when supplied. +- Closed → 403 `"Federated registration is closed on this instance"`. + +**Invariants** (spec §1.3): + +- **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 | 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 (`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): + +``` +db.transaction(() => { + // 1. Re-fetch invite by token under txn (closes TOCTOU vs /check-invite) + // 2. Reject if status !== 'active' → throw InviteUnavailableError → 403 + // 3. INSERT user row + // 4. UPDATE invite_links SET usedCount = usedCount + 1 + // 5. INSERT invite_redemptions row (forensic audit, snapshots username) +}) +``` + +If any step throws (concurrent revoke, last-slot race, username collision against the unique index), the entire transaction rolls back -- `usedCount` is never incremented on a failed registration. The route catches `InviteUnavailableError` from `redeemInvite()` and surfaces it as 403 `"Invalid or expired invite"`. + +- **Federated stub upgrade and federated new-account paths do NOT enter `redeemInvite`.** They are gated only by `federatedRegistrationOpen` and never consume tokens, even if a token is provided in the request body. This is the structural enforcement of the spec §1.3 invariant "tokens never unlock federated creation". + +The `/api/auth/check-invite` debounced UX endpoint pre-validates a token from the register page; the in-txn re-derive inside `redeemInvite()` is the authoritative enforcement point. ### First-User Admin Promotion @@ -144,18 +217,21 @@ If `requestedAvatarColor` is provided and is in `AVATAR_COLORS`, use it. Otherwi ### Registration Steps 1. Validate inputs (username format, password length) -2. Check registration is open -3. **Federated stub upgrade check** (if `homeInstance` is set): call `findFederatedUser` to look for an existing relay-created stub. If found and upgradeable, upgrade it instead of creating a new record (see below). -4. Check username uniqueness (exact match on lowercased username) -5. Hash password (bcrypt, 12 rounds) -6. Generate Snowflake ID -7. Insert user row (status defaults to `'offline'` at the schema level; it is set to `'online'` only when the client establishes a WebSocket via the WS auth path in `ws/handler.ts`). Admin flag set if first user. -8. Sign JWT with `{ userId, username }` -9. Return `{ token, user }` (user sanitized via `sanitizeUser(user, true)`) +2. Read both registration gates from `instance_settings` +3. **Branch by request shape** (spec §1.2): + - If `homeInstance` set → reject with 403 unless `federatedRegistrationOpen === true`. `inviteToken` ignored on this path. + - Else (local) → if `registrationOpen` is false, require a valid `inviteToken`; otherwise reject with 403. Pre-flight token check rejects obvious-invalid tokens before bcrypt. +4. **Federated stub upgrade check** (if `homeInstance` is set): call `findFederatedUser` to look for an existing relay-created stub. If found and upgradeable, upgrade it instead of creating a new record (see below). +5. Check username uniqueness (exact match on lowercased username) +6. Hash password (bcrypt, 12 rounds) +7. Generate Snowflake ID +8. Insert user row (status defaults to `'offline'` at the schema level; it is set to `'online'` only when the client establishes a WebSocket via the WS auth path in `ws/handler.ts`). Admin flag set if first user. **When the local-closed-with-token path is in play**, the insert runs inside `redeemInvite()`'s transaction so the user row, the `usedCount` bump, and the `invite_redemptions` row commit atomically (or all roll back). +9. Sign JWT with `{ userId, username }` +10. Return `{ token, user }` (user sanitized via `sanitizeUser(user, true)`) ### Federated Stub Upgrade -When a user registers with `homeInstance` set (federated registration via friend-connect), the registration path checks for an existing relay-created stub using `findFederatedUser`. If found and the stub has `passwordHash = '!federation-replicated'` (not a real account), the stub is upgraded: +When a user registers with `homeInstance` set (federated registration via friend-connect), the registration path checks for an existing relay-created stub using `findFederatedUser`. The stub-upgrade flow is **always gated by `federatedRegistrationOpen`**, never by an invite token (spec §1.3). If found and the stub has `passwordHash = '!federation-replicated'` (not a real account), the stub is upgraded: - `passwordHash` is set to the new bcrypt hash (enables login) - `username` is updated to the registration's chosen username (replaces placeholder like `291255103060533248@nova.ddns.net` with `nova@nova.ddns.net`) @@ -532,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 | @@ -546,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 | | diff --git a/packages/server/drizzle/0004_slim_mentallo.sql b/packages/server/drizzle/0004_slim_mentallo.sql new file mode 100644 index 00000000..911dca59 --- /dev/null +++ b/packages/server/drizzle/0004_slim_mentallo.sql @@ -0,0 +1,28 @@ +CREATE TABLE `invite_links` ( + `id` text PRIMARY KEY NOT NULL, + `token` text NOT NULL, + `name` text NOT NULL, + `created_by` text NOT NULL, + `created_at` integer NOT NULL, + `max_uses` integer, + `used_count` integer DEFAULT 0 NOT NULL, + `expires_at` integer, + `revoked_at` integer, + FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `invite_redemptions` ( + `id` text PRIMARY KEY NOT NULL, + `invite_id` text NOT NULL, + `user_id` text, + `registrant_username` text NOT NULL, + `redeemed_at` integer NOT NULL, + FOREIGN KEY (`invite_id`) REFERENCES `invite_links`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +ALTER TABLE `instance_settings` ADD `federated_registration_open` integer DEFAULT 1 NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX `invite_links_token_unique` ON `invite_links` (`token`);--> statement-breakpoint +CREATE INDEX `idx_invite_links_created_at` ON `invite_links` (`created_at`);--> statement-breakpoint +CREATE INDEX `idx_invite_redemptions_invite_id` ON `invite_redemptions` (`invite_id`);--> statement-breakpoint +CREATE INDEX `idx_invite_redemptions_user_id` ON `invite_redemptions` (`user_id`); \ No newline at end of file diff --git a/packages/server/drizzle/meta/0004_snapshot.json b/packages/server/drizzle/meta/0004_snapshot.json new file mode 100644 index 00000000..87eeea02 --- /dev/null +++ b/packages/server/drizzle/meta/0004_snapshot.json @@ -0,0 +1,3641 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "f0f55202-ecbe-499c-8269-8cf1222c085b", + "prevId": "5ae0d7c7-5409-464d-b8d2-8af70cf6601f", + "tables": { + "attachments": { + "name": "attachments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dm_message_id": { + "name": "dm_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uploader_id": { + "name": "uploader_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mimetype": { + "name": "mimetype", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thumbnail_filename": { + "name": "thumbnail_filename", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "federation_status": { + "name": "federation_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "federation_meta": { + "name": "federation_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_attachments_message_id": { + "name": "idx_attachments_message_id", + "columns": [ + "message_id" + ], + "isUnique": false + }, + "idx_attachments_dm_message_id": { + "name": "idx_attachments_dm_message_id", + "columns": [ + "dm_message_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "attachments_message_id_messages_id_fk": { + "name": "attachments_message_id_messages_id_fk", + "tableFrom": "attachments", + "tableTo": "messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "attachments_dm_message_id_dm_messages_id_fk": { + "name": "attachments_dm_message_id_dm_messages_id_fk", + "tableFrom": "attachments", + "tableTo": "dm_messages", + "columnsFrom": [ + "dm_message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "bans": { + "name": "bans", + "columns": { + "space_id": { + "name": "space_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "banned_by": { + "name": "banned_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_bans_space_id": { + "name": "idx_bans_space_id", + "columns": [ + "space_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bans_space_id_spaces_id_fk": { + "name": "bans_space_id_spaces_id_fk", + "tableFrom": "bans", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bans_user_id_users_id_fk": { + "name": "bans_user_id_users_id_fk", + "tableFrom": "bans", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bans_banned_by_users_id_fk": { + "name": "bans_banned_by_users_id_fk", + "tableFrom": "bans", + "tableTo": "users", + "columnsFrom": [ + "banned_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bans_space_id_user_id_pk": { + "columns": [ + "space_id", + "user_id" + ], + "name": "bans_space_id_user_id_pk" + } + }, + "uniqueConstraints": {} + }, + "category_overrides": { + "name": "category_overrides", + "columns": { + "category_id": { + "name": "category_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "allow": { + "name": "allow", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'0'" + }, + "deny": { + "name": "deny", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'0'" + } + }, + "indexes": { + "idx_category_overrides_category_id": { + "name": "idx_category_overrides_category_id", + "columns": [ + "category_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "category_overrides_category_id_channel_categories_id_fk": { + "name": "category_overrides_category_id_channel_categories_id_fk", + "tableFrom": "category_overrides", + "tableTo": "channel_categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "category_overrides_category_id_target_type_target_id_pk": { + "columns": [ + "category_id", + "target_type", + "target_id" + ], + "name": "category_overrides_category_id_target_type_target_id_pk" + } + }, + "uniqueConstraints": {} + }, + "channel_categories": { + "name": "channel_categories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "space_id": { + "name": "space_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_channel_categories_space_id": { + "name": "idx_channel_categories_space_id", + "columns": [ + "space_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "channel_categories_space_id_spaces_id_fk": { + "name": "channel_categories_space_id_spaces_id_fk", + "tableFrom": "channel_categories", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "channel_overrides": { + "name": "channel_overrides", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "allow": { + "name": "allow", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'0'" + }, + "deny": { + "name": "deny", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'0'" + } + }, + "indexes": { + "idx_channel_overrides_channel_id": { + "name": "idx_channel_overrides_channel_id", + "columns": [ + "channel_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "channel_overrides_channel_id_channels_id_fk": { + "name": "channel_overrides_channel_id_channels_id_fk", + "tableFrom": "channel_overrides", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_overrides_channel_id_target_type_target_id_pk": { + "columns": [ + "channel_id", + "target_type", + "target_id" + ], + "name": "channel_overrides_channel_id_target_type_target_id_pk" + } + }, + "uniqueConstraints": {} + }, + "channels": { + "name": "channels", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "space_id": { + "name": "space_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "topic": { + "name": "topic", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "category_id": { + "name": "category_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_channels_space_id": { + "name": "idx_channels_space_id", + "columns": [ + "space_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "channels_space_id_spaces_id_fk": { + "name": "channels_space_id_spaces_id_fk", + "tableFrom": "channels", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "dm_channels": { + "name": "dm_channels", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "federated_id": { + "name": "federated_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_home_user_id": { + "name": "owner_home_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_home_instance": { + "name": "owner_home_instance", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_dm_federated": { + "name": "idx_dm_federated", + "columns": [ + "federated_id" + ], + "isUnique": true, + "where": "federated_id IS NOT NULL" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "dm_members": { + "name": "dm_members", + "columns": { + "dm_channel_id": { + "name": "dm_channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "closed": { + "name": "closed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_dm_members_user_id": { + "name": "idx_dm_members_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "dm_members_dm_channel_id_dm_channels_id_fk": { + "name": "dm_members_dm_channel_id_dm_channels_id_fk", + "tableFrom": "dm_members", + "tableTo": "dm_channels", + "columnsFrom": [ + "dm_channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dm_members_user_id_users_id_fk": { + "name": "dm_members_user_id_users_id_fk", + "tableFrom": "dm_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "dm_members_dm_channel_id_user_id_pk": { + "columns": [ + "dm_channel_id", + "user_id" + ], + "name": "dm_members_dm_channel_id_user_id_pk" + } + }, + "uniqueConstraints": {} + }, + "dm_messages": { + "name": "dm_messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "dm_channel_id": { + "name": "dm_channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reply_to_id": { + "name": "reply_to_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "edited_at": { + "name": "edited_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_instance": { + "name": "source_instance", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_message_id": { + "name": "source_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encryption_version": { + "name": "encryption_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_dm_messages_dm_channel_id": { + "name": "idx_dm_messages_dm_channel_id", + "columns": [ + "dm_channel_id" + ], + "isUnique": false + }, + "idx_dm_messages_user_id": { + "name": "idx_dm_messages_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_dm_messages_source_unique": { + "name": "idx_dm_messages_source_unique", + "columns": [ + "source_instance", + "source_message_id" + ], + "isUnique": true, + "where": "source_instance IS NOT NULL" + } + }, + "foreignKeys": { + "dm_messages_dm_channel_id_dm_channels_id_fk": { + "name": "dm_messages_dm_channel_id_dm_channels_id_fk", + "tableFrom": "dm_messages", + "tableTo": "dm_channels", + "columnsFrom": [ + "dm_channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dm_messages_user_id_users_id_fk": { + "name": "dm_messages_user_id_users_id_fk", + "tableFrom": "dm_messages", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "dm_messages_reply_to_id_dm_messages_id_fk": { + "name": "dm_messages_reply_to_id_dm_messages_id_fk", + "tableFrom": "dm_messages", + "tableTo": "dm_messages", + "columnsFrom": [ + "reply_to_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "dm_reactions": { + "name": "dm_reactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "dm_message_id": { + "name": "dm_message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_dm_reactions_dm_message_id": { + "name": "idx_dm_reactions_dm_message_id", + "columns": [ + "dm_message_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "dm_reactions_dm_message_id_dm_messages_id_fk": { + "name": "dm_reactions_dm_message_id_dm_messages_id_fk", + "tableFrom": "dm_reactions", + "tableTo": "dm_messages", + "columnsFrom": [ + "dm_message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dm_reactions_user_id_users_id_fk": { + "name": "dm_reactions_user_id_users_id_fk", + "tableFrom": "dm_reactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "embeds": { + "name": "embeds", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dm_message_id": { + "name": "dm_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "embed_type": { + "name": "embed_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "embed_url": { + "name": "embed_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_embeds_message_id": { + "name": "idx_embeds_message_id", + "columns": [ + "message_id" + ], + "isUnique": false + }, + "idx_embeds_dm_message_id": { + "name": "idx_embeds_dm_message_id", + "columns": [ + "dm_message_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "embeds_message_id_messages_id_fk": { + "name": "embeds_message_id_messages_id_fk", + "tableFrom": "embeds", + "tableTo": "messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embeds_dm_message_id_dm_messages_id_fk": { + "name": "embeds_dm_message_id_dm_messages_id_fk", + "tableFrom": "embeds", + "tableTo": "dm_messages", + "columnsFrom": [ + "dm_message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "federation_file_queue": { + "name": "federation_file_queue", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "peer_origin": { + "name": "peer_origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dm_message_id": { + "name": "dm_message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_filename": { + "name": "target_filename", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mimetype": { + "name": "mimetype", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "federation_mutation_log": { + "name": "federation_mutation_log", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_type": { + "name": "context_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'dm'" + }, + "mutation_type": { + "name": "mutation_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mutated_at": { + "name": "mutated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_mutation_log_time": { + "name": "idx_mutation_log_time", + "columns": [ + "mutated_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "federation_outbox": { + "name": "federation_outbox", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "peer_id": { + "name": "peer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_type": { + "name": "context_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'dm'" + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encryption_version": { + "name": "encryption_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_outbox_retry": { + "name": "idx_outbox_retry", + "columns": [ + "next_retry_at" + ], + "isUnique": false + }, + "federation_outbox_peer_id_entity_id_unique": { + "name": "federation_outbox_peer_id_entity_id_unique", + "columns": [ + "peer_id", + "entity_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "federation_outbox_peer_id_federation_peers_id_fk": { + "name": "federation_outbox_peer_id_federation_peers_id_fk", + "tableFrom": "federation_outbox", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "federation_peers": { + "name": "federation_peers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_name": { + "name": "instance_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hmac_secret": { + "name": "hmac_secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_failure_at": { + "name": "last_failure_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "consecutive_auth_failures": { + "name": "consecutive_auth_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "remote_max_upload_size": { + "name": "remote_max_upload_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "nonce_supported": { + "name": "nonce_supported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "pending_hmac_secret": { + "name": "pending_hmac_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secret_rotation_at": { + "name": "secret_rotation_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secret_rotated_at": { + "name": "secret_rotated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auto_rotate_interval_days": { + "name": "auto_rotate_interval_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 90 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approval_token": { + "name": "approval_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "federation_peers_origin_unique": { + "name": "federation_peers_origin_unique", + "columns": [ + "origin" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "friend_requests": { + "name": "friend_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "from_id": { + "name": "from_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_id": { + "name": "to_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "relay_message_id": { + "name": "relay_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_friend_requests_to_id": { + "name": "idx_friend_requests_to_id", + "columns": [ + "to_id" + ], + "isUnique": false + }, + "idx_friend_requests_from_id": { + "name": "idx_friend_requests_from_id", + "columns": [ + "from_id" + ], + "isUnique": false + }, + "idx_friend_requests_relay_message_id": { + "name": "idx_friend_requests_relay_message_id", + "columns": [ + "relay_message_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "friend_requests_from_id_users_id_fk": { + "name": "friend_requests_from_id_users_id_fk", + "tableFrom": "friend_requests", + "tableTo": "users", + "columnsFrom": [ + "from_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "friend_requests_to_id_users_id_fk": { + "name": "friend_requests_to_id_users_id_fk", + "tableFrom": "friend_requests", + "tableTo": "users", + "columnsFrom": [ + "to_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "friends": { + "name": "friends", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "friend_id": { + "name": "friend_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_friends_user_id": { + "name": "idx_friends_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_friends_friend_id": { + "name": "idx_friends_friend_id", + "columns": [ + "friend_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "friends_user_id_users_id_fk": { + "name": "friends_user_id_users_id_fk", + "tableFrom": "friends", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "friends_friend_id_users_id_fk": { + "name": "friends_friend_id_users_id_fk", + "tableFrom": "friends", + "tableTo": "users", + "columnsFrom": [ + "friend_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "friends_user_id_friend_id_pk": { + "columns": [ + "user_id", + "friend_id" + ], + "name": "friends_user_id_friend_id_pk" + } + }, + "uniqueConstraints": {} + }, + "instance_settings": { + "name": "instance_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "instance_name": { + "name": "instance_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'Backspace'" + }, + "worker_id": { + "name": "worker_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "discovery_enabled": { + "name": "discovery_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "max_bitrate_kbps": { + "name": "max_bitrate_kbps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 20000 + }, + "min_bitrate_kbps": { + "name": "min_bitrate_kbps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 500 + }, + "bitrate_step_kbps": { + "name": "bitrate_step_kbps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 500 + }, + "allowed_resolutions": { + "name": "allowed_resolutions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'540,720,1080'" + }, + "allowed_framerates": { + "name": "allowed_framerates", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'30,45,60'" + }, + "max_resolution": { + "name": "max_resolution", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1080 + }, + "max_framerate": { + "name": "max_framerate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 60 + }, + "registration_open": { + "name": "registration_open", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "federated_registration_open": { + "name": "federated_registration_open", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "gif_api_key": { + "name": "gif_api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bitrate_matrix_overrides": { + "name": "bitrate_matrix_overrides", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_custom_bitrate": { + "name": "allow_custom_bitrate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "max_upload_size_bytes": { + "name": "max_upload_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "federation_relay_enabled": { + "name": "federation_relay_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "federation_relay_ttl_days": { + "name": "federation_relay_ttl_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "default_auto_rotate_interval_days": { + "name": "default_auto_rotate_interval_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 90 + }, + "auto_accept_peering": { + "name": "auto_accept_peering", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "invite_links": { + "name": "invite_links", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "max_uses": { + "name": "max_uses", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "used_count": { + "name": "used_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "invite_links_token_unique": { + "name": "invite_links_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "idx_invite_links_created_at": { + "name": "idx_invite_links_created_at", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "invite_links_created_by_users_id_fk": { + "name": "invite_links_created_by_users_id_fk", + "tableFrom": "invite_links", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "invite_redemptions": { + "name": "invite_redemptions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "invite_id": { + "name": "invite_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "registrant_username": { + "name": "registrant_username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redeemed_at": { + "name": "redeemed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_invite_redemptions_invite_id": { + "name": "idx_invite_redemptions_invite_id", + "columns": [ + "invite_id" + ], + "isUnique": false + }, + "idx_invite_redemptions_user_id": { + "name": "idx_invite_redemptions_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "invite_redemptions_invite_id_invite_links_id_fk": { + "name": "invite_redemptions_invite_id_invite_links_id_fk", + "tableFrom": "invite_redemptions", + "tableTo": "invite_links", + "columnsFrom": [ + "invite_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invite_redemptions_user_id_users_id_fk": { + "name": "invite_redemptions_user_id_users_id_fk", + "tableFrom": "invite_redemptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "join_requests": { + "name": "join_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "space_id": { + "name": "space_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "decided_at": { + "name": "decided_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_join_requests_space_id_status": { + "name": "idx_join_requests_space_id_status", + "columns": [ + "space_id", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "join_requests_space_id_spaces_id_fk": { + "name": "join_requests_space_id_spaces_id_fk", + "tableFrom": "join_requests", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "join_requests_user_id_users_id_fk": { + "name": "join_requests_user_id_users_id_fk", + "tableFrom": "join_requests", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "join_requests_decided_by_users_id_fk": { + "name": "join_requests_decided_by_users_id_fk", + "tableFrom": "join_requests", + "tableTo": "users", + "columnsFrom": [ + "decided_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "member_roles": { + "name": "member_roles", + "columns": { + "space_id": { + "name": "space_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_member_roles_user_id_space_id": { + "name": "idx_member_roles_user_id_space_id", + "columns": [ + "user_id", + "space_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "member_roles_space_id_spaces_id_fk": { + "name": "member_roles_space_id_spaces_id_fk", + "tableFrom": "member_roles", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_roles_user_id_users_id_fk": { + "name": "member_roles_user_id_users_id_fk", + "tableFrom": "member_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_roles_role_id_roles_id_fk": { + "name": "member_roles_role_id_roles_id_fk", + "tableFrom": "member_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "member_roles_space_id_user_id_role_id_pk": { + "columns": [ + "space_id", + "user_id", + "role_id" + ], + "name": "member_roles_space_id_user_id_role_id_pk" + } + }, + "uniqueConstraints": {} + }, + "messages": { + "name": "messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reply_to_id": { + "name": "reply_to_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "edited_at": { + "name": "edited_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_messages_channel_id": { + "name": "idx_messages_channel_id", + "columns": [ + "channel_id" + ], + "isUnique": false + }, + "idx_messages_user_id": { + "name": "idx_messages_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "messages_channel_id_channels_id_fk": { + "name": "messages_channel_id_channels_id_fk", + "tableFrom": "messages", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_user_id_users_id_fk": { + "name": "messages_user_id_users_id_fk", + "tableFrom": "messages", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "messages_reply_to_id_messages_id_fk": { + "name": "messages_reply_to_id_messages_id_fk", + "tableFrom": "messages", + "tableTo": "messages", + "columnsFrom": [ + "reply_to_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "peer_approval_notifications": { + "name": "peer_approval_notifications", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "peer_origin": { + "name": "peer_origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger_reason": { + "name": "trigger_reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger_target": { + "name": "trigger_target", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "read_at": { + "name": "read_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_peer_approval_notifications_user_id": { + "name": "idx_peer_approval_notifications_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "peer_approval_notifications_user_id_users_id_fk": { + "name": "peer_approval_notifications_user_id_users_id_fk", + "tableFrom": "peer_approval_notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "peer_approval_requests": { + "name": "peer_approval_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'inbound'" + }, + "instance_name": { + "name": "instance_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hmac_secret": { + "name": "hmac_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "requested_at": { + "name": "requested_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approval_token": { + "name": "approval_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "peer_approval_requests_origin_direction_unique": { + "name": "peer_approval_requests_origin_direction_unique", + "columns": [ + "origin", + "direction" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "peer_approval_subscribers": { + "name": "peer_approval_subscribers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger_reason": { + "name": "trigger_reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger_target": { + "name": "trigger_target", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_peer_approval_subscribers_user_id": { + "name": "idx_peer_approval_subscribers_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "peer_approval_subscribers_request_id_user_id_trigger_reason_trigger_target_unique": { + "name": "peer_approval_subscribers_request_id_user_id_trigger_reason_trigger_target_unique", + "columns": [ + "request_id", + "user_id", + "trigger_reason", + "trigger_target" + ], + "isUnique": true + } + }, + "foreignKeys": { + "peer_approval_subscribers_request_id_peer_approval_requests_id_fk": { + "name": "peer_approval_subscribers_request_id_peer_approval_requests_id_fk", + "tableFrom": "peer_approval_subscribers", + "tableTo": "peer_approval_requests", + "columnsFrom": [ + "request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "peer_approval_subscribers_user_id_users_id_fk": { + "name": "peer_approval_subscribers_user_id_users_id_fk", + "tableFrom": "peer_approval_subscribers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "reactions": { + "name": "reactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_reactions_message_id": { + "name": "idx_reactions_message_id", + "columns": [ + "message_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "reactions_message_id_messages_id_fk": { + "name": "reactions_message_id_messages_id_fk", + "tableFrom": "reactions", + "tableTo": "messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reactions_user_id_users_id_fk": { + "name": "reactions_user_id_users_id_fk", + "tableFrom": "reactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "read_states": { + "name": "read_states", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_read_message_id": { + "name": "last_read_message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_read_states_user_id": { + "name": "idx_read_states_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "read_states_user_id_users_id_fk": { + "name": "read_states_user_id_users_id_fk", + "tableFrom": "read_states", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "read_states_user_id_channel_id_pk": { + "columns": [ + "user_id", + "channel_id" + ], + "name": "read_states_user_id_channel_id_pk" + } + }, + "uniqueConstraints": {} + }, + "roles": { + "name": "roles", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "space_id": { + "name": "space_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'#b9bbbe'" + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_roles_space_id": { + "name": "idx_roles_space_id", + "columns": [ + "space_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "roles_space_id_spaces_id_fk": { + "name": "roles_space_id_spaces_id_fk", + "tableFrom": "roles", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "space_folder_members": { + "name": "space_folder_members", + "columns": { + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "space_id": { + "name": "space_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "space_folder_members_folder_id_space_folders_id_fk": { + "name": "space_folder_members_folder_id_space_folders_id_fk", + "tableFrom": "space_folder_members", + "tableTo": "space_folders", + "columnsFrom": [ + "folder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "space_folder_members_folder_id_space_id_pk": { + "columns": [ + "folder_id", + "space_id" + ], + "name": "space_folder_members_folder_id_space_id_pk" + } + }, + "uniqueConstraints": {} + }, + "space_folders": { + "name": "space_folders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "space_folders_user_id_users_id_fk": { + "name": "space_folders_user_id_users_id_fk", + "tableFrom": "space_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "space_members": { + "name": "space_members", + "columns": { + "space_id": { + "name": "space_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nickname": { + "name": "nickname", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "joined_at": { + "name": "joined_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_space_members_user_id": { + "name": "idx_space_members_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "space_members_space_id_spaces_id_fk": { + "name": "space_members_space_id_spaces_id_fk", + "tableFrom": "space_members", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "space_members_user_id_users_id_fk": { + "name": "space_members_user_id_users_id_fk", + "tableFrom": "space_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "space_members_space_id_user_id_pk": { + "columns": [ + "space_id", + "user_id" + ], + "name": "space_members_space_id_user_id_pk" + } + }, + "uniqueConstraints": {} + }, + "spaces": { + "name": "spaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "banner": { + "name": "banner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_color": { + "name": "avatar_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invite_code": { + "name": "invite_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'private'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "spaces_invite_code_unique": { + "name": "spaces_invite_code_unique", + "columns": [ + "invite_code" + ], + "isUnique": true + } + }, + "foreignKeys": { + "spaces_owner_id_users_id_fk": { + "name": "spaces_owner_id_users_id_fk", + "tableFrom": "spaces", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "user_federation_registry": { + "name": "user_federation_registry", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "remote_user_id": { + "name": "remote_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'connected'" + }, + "added_at": { + "name": "added_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_connected_at": { + "name": "last_connected_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disconnected_at": { + "name": "disconnected_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_federation_registry_user_id_users_id_fk": { + "name": "user_federation_registry_user_id_users_id_fk", + "tableFrom": "user_federation_registry", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_federation_registry_user_id_origin_pk": { + "columns": [ + "user_id", + "origin" + ], + "name": "user_federation_registry_user_id_origin_pk" + } + }, + "uniqueConstraints": {} + }, + "user_space_layout": { + "name": "user_space_layout", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "layout": { + "name": "layout", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_space_layout_user_id_users_id_fk": { + "name": "user_space_layout_user_id_users_id_fk", + "tableFrom": "user_space_layout", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'offline'" + }, + "custom_status": { + "name": "custom_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "home_instance": { + "name": "home_instance", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "home_user_id": { + "name": "home_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "replicated_instances": { + "name": "replicated_instances", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'[]'" + }, + "banner": { + "name": "banner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_color": { + "name": "avatar_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_deleted": { + "name": "is_deleted", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "discoverable": { + "name": "discoverable", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "profile_updated_at": { + "name": "profile_updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password_changed_at": { + "name": "password_changed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "show_activity": { + "name": "show_activity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "federation_registry_updated_at": { + "name": "federation_registry_updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "voice_restrictions": { + "name": "voice_restrictions", + "columns": { + "space_id": { + "name": "space_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "restriction_type": { + "name": "restriction_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "moderator_id": { + "name": "moderator_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_voice_restrictions_space_id": { + "name": "idx_voice_restrictions_space_id", + "columns": [ + "space_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "voice_restrictions_space_id_spaces_id_fk": { + "name": "voice_restrictions_space_id_spaces_id_fk", + "tableFrom": "voice_restrictions", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "voice_restrictions_user_id_users_id_fk": { + "name": "voice_restrictions_user_id_users_id_fk", + "tableFrom": "voice_restrictions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "voice_restrictions_moderator_id_users_id_fk": { + "name": "voice_restrictions_moderator_id_users_id_fk", + "tableFrom": "voice_restrictions", + "tableTo": "users", + "columnsFrom": [ + "moderator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "voice_restrictions_space_id_user_id_restriction_type_pk": { + "columns": [ + "space_id", + "user_id", + "restriction_type" + ], + "name": "voice_restrictions_space_id_user_id_restriction_type_pk" + } + }, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/server/drizzle/meta/_journal.json b/packages/server/drizzle/meta/_journal.json index 3880987d..8b4b7ab2 100644 --- a/packages/server/drizzle/meta/_journal.json +++ b/packages/server/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1777229997210, "tag": "0003_brave_inhumans", "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1777395066272, + "tag": "0004_slim_mentallo", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index a97ab2ec..f06c78cb 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -310,6 +310,7 @@ export const instanceSettings = sqliteTable('instance_settings', { maxResolution: integer('max_resolution').notNull().default(1080), maxFramerate: integer('max_framerate').notNull().default(60), registrationOpen: integer('registration_open'), // null = use env var default, 0/1 = explicit + federatedRegistrationOpen: integer('federated_registration_open').notNull().default(1), gifApiKey: text('gif_api_key'), bitrateMatrixOverrides: text('bitrate_matrix_overrides'), allowCustomBitrate: integer('allow_custom_bitrate').notNull().default(1), @@ -481,3 +482,28 @@ export const userFederationRegistry = sqliteTable('user_federation_registry', { }, (table) => ({ pk: primaryKey({ columns: [table.userId, table.origin] }), })); + +export const inviteLinks = sqliteTable('invite_links', { + id: text('id').primaryKey(), + token: text('token').notNull().unique(), + name: text('name').notNull(), + createdBy: text('created_by').notNull().references(() => users.id), + createdAt: integer('created_at').notNull(), + maxUses: integer('max_uses'), + usedCount: integer('used_count').notNull().default(0), + expiresAt: integer('expires_at'), + revokedAt: integer('revoked_at'), +}, (table) => ({ + createdAtIdx: index('idx_invite_links_created_at').on(table.createdAt), +})); + +export const inviteRedemptions = sqliteTable('invite_redemptions', { + id: text('id').primaryKey(), + inviteId: text('invite_id').notNull().references(() => inviteLinks.id, { onDelete: 'cascade' }), + userId: text('user_id').references(() => users.id, { onDelete: 'set null' }), + registrantUsername: text('registrant_username').notNull(), + redeemedAt: integer('redeemed_at').notNull(), +}, (table) => ({ + inviteIdx: index('idx_invite_redemptions_invite_id').on(table.inviteId), + userIdx: index('idx_invite_redemptions_user_id').on(table.userId), +})); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 2105840a..2c1d566a 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -20,6 +20,7 @@ import { socialRoutes } from './routes/social.js'; import { settingsRoutes } from './routes/settings.js'; import { utilRoutes } from './routes/utils.js'; import { instanceRoutes } from './routes/instance.js'; +import { invitesRoutes } from './routes/invites.js'; import { exploreRoutes } from './routes/explore.js'; import { searchRoutes } from './routes/search.js'; import { adminRoutes } from './routes/admin.js'; @@ -103,6 +104,7 @@ async function main(): Promise { await app.register(settingsRoutes); await app.register(utilRoutes); await app.register(instanceRoutes); + await app.register(invitesRoutes); await app.register(exploreRoutes); await app.register(searchRoutes); await app.register(adminRoutes); diff --git a/packages/server/src/routes/auth.test.ts b/packages/server/src/routes/auth.test.ts new file mode 100644 index 00000000..c51f17a0 --- /dev/null +++ b/packages/server/src/routes/auth.test.ts @@ -0,0 +1,490 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { eq } from 'drizzle-orm'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; +import { setWorkerId } from '../utils/snowflake.js'; + +setWorkerId(2); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Module-level mutable state — see invites.test.ts for the rationale on why +// the `getDb` mock closes over a getter rather than the binding directly. +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; +let app: FastifyInstance; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +function applyMigrations(db: Database.Database): void { + const migrationsDir = path.resolve(__dirname, '../../drizzle'); + const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort(); + for (const f of files) { + const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8'); + const statements = sqlText.split(/-->\s*statement-breakpoint/); + for (const stmt of statements) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +async function buildApp(): Promise { + const { authRoutes } = await import('./auth.js'); + const f = Fastify(); + await f.register(authRoutes); + return f; +} + +const ADMIN_ID = 'admin-1'; + +beforeEach(async () => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + + // The invite_links rows we'll create reference users(id) via createdBy. Seed + // a single admin to act as creator across all tests. + testDb.insert(schema.users).values({ + id: ADMIN_ID, + username: 'admin', + passwordHash: 'x', + isAdmin: 1, + createdAt: Date.now(), + }).run(); + + app = await buildApp(); +}); + +describe('GET /api/auth/check-invite', () => { + it('returns valid: true with name for active token', async () => { + const token = 'a'.repeat(22); + testDb.insert(schema.inviteLinks).values({ + id: 'inv-1', + token, + name: 'Friends batch 1', + createdBy: ADMIN_ID, + createdAt: Date.now(), + maxUses: 10, + usedCount: 0, + expiresAt: null, + revokedAt: null, + }).run(); + + const res = await app.inject({ + method: 'GET', + url: `/api/auth/check-invite?token=${token}`, + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.valid).toBe(true); + expect(body.name).toBe('Friends batch 1'); + expect(body.reason).toBeUndefined(); + }); + + it("returns valid: false, reason: 'expired' for past expiresAt", async () => { + const token = 'b'.repeat(22); + testDb.insert(schema.inviteLinks).values({ + id: 'inv-2', + token, + name: 'Old link', + createdBy: ADMIN_ID, + createdAt: Date.now() - 10_000, + maxUses: 10, + usedCount: 0, + expiresAt: Date.now() - 1_000, + revokedAt: null, + }).run(); + + const res = await app.inject({ + method: 'GET', + url: `/api/auth/check-invite?token=${token}`, + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.valid).toBe(false); + expect(body.reason).toBe('expired'); + expect(body.name).toBeUndefined(); + }); + + it("returns valid: false, reason: 'exhausted' for used-up invite", async () => { + const token = 'c'.repeat(22); + testDb.insert(schema.inviteLinks).values({ + id: 'inv-3', + token, + name: 'Burned', + createdBy: ADMIN_ID, + createdAt: Date.now(), + maxUses: 1, + usedCount: 1, + expiresAt: null, + revokedAt: null, + }).run(); + + const res = await app.inject({ + method: 'GET', + url: `/api/auth/check-invite?token=${token}`, + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.valid).toBe(false); + expect(body.reason).toBe('exhausted'); + expect(body.name).toBeUndefined(); + }); + + it("returns valid: false, reason: 'invalid' for revoked invite (collapsed shield)", async () => { + const token = 'd'.repeat(22); + testDb.insert(schema.inviteLinks).values({ + id: 'inv-4', + token, + name: 'Revoked', + createdBy: ADMIN_ID, + createdAt: Date.now(), + maxUses: 10, + usedCount: 0, + expiresAt: null, + revokedAt: Date.now(), + }).run(); + + const res = await app.inject({ + method: 'GET', + url: `/api/auth/check-invite?token=${token}`, + }); + expect(res.statusCode).toBe(200); + // toEqual locks the byte-identical-response contract: the enumeration + // shield depends on revoked/unknown/malformed all returning the SAME + // body, not just bodies that happen to satisfy individual assertions. + expect(res.json()).toEqual({ valid: false, reason: 'invalid' }); + }); + + it("returns valid: false, reason: 'invalid' for unknown token", async () => { + // 22-char base64url string that is not in the DB. + const token = 'Z'.repeat(22); + const res = await app.inject({ + method: 'GET', + url: `/api/auth/check-invite?token=${token}`, + }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ valid: false, reason: 'invalid' }); + }); + + it("returns valid: false, reason: 'invalid' for malformed token", async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/auth/check-invite?token=tooshort', + }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ valid: false, reason: 'invalid' }); + }); + + it('returns byte-identical bodies across all invalid permutations', async () => { + // The enumeration shield depends on revoked/unknown/malformed/missing + // all returning the SAME body. Object equality (toEqual) catches any + // future code path that adds an extra field on one branch but not others. + const cases = [ + '/api/auth/check-invite', + '/api/auth/check-invite?token=', + '/api/auth/check-invite?token=tooshort', + `/api/auth/check-invite?token=${'Z'.repeat(22)}`, + ]; + for (const url of cases) { + const res = await app.inject({ method: 'GET', url }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ valid: false, reason: 'invalid' }); + } + }); +}); + +describe('POST /api/auth/register — federation gate split', () => { + beforeEach(() => { + // Ensure a fresh instance_settings singleton row with both gates default-true. + // The test harness's applyMigrations creates the table but does not seed the + // id=1 row (production does so via migrate.ts:ensureDefaults on first boot). + // Each test then mutates the toggles it cares about. + testDb.delete(schema.instanceSettings).run(); + testDb.insert(schema.instanceSettings).values({ + id: 1, + registrationOpen: 1, + federatedRegistrationOpen: 1, + updatedAt: Date.now(), + }).run(); + }); + + it('open registration: register without token succeeds; token field ignored if present', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { username: 'alice', password: 'password123' }, + }); + expect(res.statusCode).toBe(201); + + // Try with bogus token — still succeeds, token ignored + const res2 = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { username: 'bob', password: 'password123', inviteToken: 'fakefakefakefakefakeXX' }, + }); + expect(res2.statusCode).toBe(201); + }); + + it('closed registration without token: 403 "An invite is required"', async () => { + testDb.update(schema.instanceSettings) + .set({ registrationOpen: 0 }) + .where(eq(schema.instanceSettings.id, 1)) + .run(); + + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { username: 'newalice', password: 'password123' }, + }); + expect(res.statusCode).toBe(403); + expect(res.json().error).toContain('invite is required'); + }); + + it('closed registration with valid token: succeeds, usedCount incremented, redemption written', async () => { + testDb.update(schema.instanceSettings) + .set({ registrationOpen: 0 }) + .where(eq(schema.instanceSettings.id, 1)) + .run(); + const token = 'abcdefghijklmnopqrstuv'; + testDb.insert(schema.inviteLinks).values({ + id: 'inv-redeem-1', + token, + name: 'F', + createdBy: ADMIN_ID, + createdAt: Date.now(), + maxUses: 5, + usedCount: 0, + expiresAt: null, + revokedAt: null, + }).run(); + + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { username: 'newalice', password: 'password123', inviteToken: token }, + }); + expect(res.statusCode).toBe(201); + + const inv = testDb.select().from(schema.inviteLinks) + .where(eq(schema.inviteLinks.id, 'inv-redeem-1')).get(); + expect(inv?.usedCount).toBe(1); + const redemptions = testDb.select().from(schema.inviteRedemptions) + .where(eq(schema.inviteRedemptions.inviteId, 'inv-redeem-1')).all(); + expect(redemptions).toHaveLength(1); + expect(redemptions[0]?.registrantUsername).toBe('newalice'); + }); + + it('closed registration with invalid token: 403 "Invalid or expired invite"', async () => { + testDb.update(schema.instanceSettings) + .set({ registrationOpen: 0 }) + .where(eq(schema.instanceSettings.id, 1)) + .run(); + + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { username: 'newalice', password: 'password123', inviteToken: 'fakefakefakefakefakeXX' }, + }); + expect(res.statusCode).toBe(403); + expect(res.json().error).toContain('Invalid or expired'); + }); + + it('open registration with token: usedCount NOT incremented', async () => { + const token = 'abcdefghijklmnopqrstuv'; + testDb.insert(schema.inviteLinks).values({ + id: 'inv-ignore-1', + token, + name: 'F', + createdBy: ADMIN_ID, + createdAt: Date.now(), + maxUses: 5, + usedCount: 0, + expiresAt: null, + revokedAt: null, + }).run(); + + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { username: 'newalice', password: 'password123', inviteToken: token }, + }); + expect(res.statusCode).toBe(201); + + const inv = testDb.select().from(schema.inviteLinks) + .where(eq(schema.inviteLinks.id, 'inv-ignore-1')).get(); + expect(inv?.usedCount).toBe(0); + + const redemptions = testDb.select().from(schema.inviteRedemptions) + .where(eq(schema.inviteRedemptions.inviteId, 'inv-ignore-1')).all(); + expect(redemptions).toHaveLength(0); + }); + + it('federated registration: blocked when federatedRegistrationOpen=false', async () => { + testDb.update(schema.instanceSettings) + .set({ federatedRegistrationOpen: 0 }) + .where(eq(schema.instanceSettings.id, 1)) + .run(); + + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { + username: 'alice@otherhost', + password: 'password123', + homeInstance: 'otherhost', + homeUserId: 'remote-id', + }, + }); + expect(res.statusCode).toBe(403); + expect(res.json().error).toContain('Federated registration'); + }); + + it('federated registration: blocked even with valid token when federatedRegistrationOpen=false', async () => { + testDb.update(schema.instanceSettings) + .set({ federatedRegistrationOpen: 0 }) + .where(eq(schema.instanceSettings.id, 1)) + .run(); + const token = 'abcdefghijklmnopqrstuv'; + testDb.insert(schema.inviteLinks).values({ + id: 'inv-fed-blocked', + token, + name: 'F', + createdBy: ADMIN_ID, + createdAt: Date.now(), + maxUses: 5, + usedCount: 0, + expiresAt: null, + revokedAt: null, + }).run(); + + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { + username: 'alice@otherhost', + password: 'password123', + homeInstance: 'otherhost', + homeUserId: 'remote-id', + inviteToken: token, + }, + }); + expect(res.statusCode).toBe(403); + expect(res.json().error).toContain('Federated registration'); + + // Token MUST NOT be consumed + const inv = testDb.select().from(schema.inviteLinks) + .where(eq(schema.inviteLinks.id, 'inv-fed-blocked')).get(); + expect(inv?.usedCount).toBe(0); + }); + + it('federated registration: token IGNORED even if provided (no usedCount increment)', async () => { + testDb.update(schema.instanceSettings) + .set({ registrationOpen: 0, federatedRegistrationOpen: 1 }) + .where(eq(schema.instanceSettings.id, 1)) + .run(); + const token = 'abcdefghijklmnopqrstuv'; + testDb.insert(schema.inviteLinks).values({ + id: 'inv-fed-ignore', + token, + name: 'F', + createdBy: ADMIN_ID, + createdAt: Date.now(), + maxUses: 5, + usedCount: 0, + expiresAt: null, + revokedAt: null, + }).run(); + + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { + username: 'alice@otherhost', + password: 'password123', + homeInstance: 'otherhost', + homeUserId: 'remote-id', + inviteToken: token, + }, + }); + expect(res.statusCode).toBe(201); + + const inv = testDb.select().from(schema.inviteLinks) + .where(eq(schema.inviteLinks.id, 'inv-fed-ignore')).get(); + expect(inv?.usedCount).toBe(0); + + const redemptions = testDb.select().from(schema.inviteRedemptions) + .where(eq(schema.inviteRedemptions.inviteId, 'inv-fed-ignore')).all(); + expect(redemptions).toHaveLength(0); + }); + + it('closed registration: token last-slot race → 403 (in-txn re-derive)', async () => { + testDb.update(schema.instanceSettings) + .set({ registrationOpen: 0 }) + .where(eq(schema.instanceSettings.id, 1)) + .run(); + const token = 'abcdefghijklmnopqrstuv'; + testDb.insert(schema.inviteLinks).values({ + id: 'inv-exhausted', + token, + name: 'F', + createdBy: ADMIN_ID, + createdAt: Date.now(), + maxUses: 1, + usedCount: 1, // already at the cap + expiresAt: null, + revokedAt: null, + }).run(); + + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { username: 'newalice', password: 'password123', inviteToken: token }, + }); + expect(res.statusCode).toBe(403); + }); + + it('open registration: revoked/expired token is silently ignored, registration still succeeds', async () => { + // Spec §5.7: when registration is open, the token field is not even + // validated. A revoked token in the request body must NOT block signup + // and must NOT be consumed. + const adminId = ADMIN_ID; + const token = 'r'.repeat(22); + testDb.insert(schema.inviteLinks).values({ + id: 'inv-revoked', + token, + name: 'revoked', + createdBy: adminId, + createdAt: Date.now(), + maxUses: 10, + usedCount: 0, + expiresAt: null, + revokedAt: Date.now(), // revoked! + }).run(); + + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { username: 'eve', password: 'password123', inviteToken: token }, + }); + expect(res.statusCode).toBe(201); + + // Revoked invite still revoked — usedCount unchanged, no redemption row. + const inv = testDb.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, 'inv-revoked')).get(); + expect(inv?.usedCount).toBe(0); + expect(inv?.revokedAt).not.toBeNull(); + const reds = testDb.select().from(schema.inviteRedemptions).where(eq(schema.inviteRedemptions.inviteId, 'inv-revoked')).all(); + expect(reds).toHaveLength(0); + }); +}); diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts index be717e38..da3f6473 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -8,6 +8,7 @@ import type { RegisterRequest, LoginRequest, AuthResponse } from '@backspace/sha import { AVATAR_COLORS } from '@backspace/shared'; import { sanitizeUser } from '../utils/sanitize.js'; import { findFederatedUser } from './federation.js'; +import { getInviteByToken, inviteStatus, redeemInvite, InviteUnavailableError } from '../utils/inviteService.js'; export async function authRoutes(app: FastifyInstance): Promise { app.post<{ Body: RegisterRequest }>('/api/auth/register', { @@ -75,13 +76,52 @@ export async function authRoutes(app: FastifyInstance): Promise { const db = getDb(); - // Check registration: DB setting overrides env var if explicitly set by admin + // Read both gates from instance_settings. + // - registrationOpen: nullable column; null falls back to env var (config.registrationOpen). + // Admin-explicit 0/1 overrides env. Gates LOCAL anonymous signup. + // - federatedRegistrationOpen: NOT NULL DEFAULT 1 column. Gates FEDERATED identity + // replication (homeInstance set). Independent of registrationOpen by spec §1.2. const instanceRow = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get(); const registrationOpen = instanceRow?.registrationOpen !== null && instanceRow?.registrationOpen !== undefined ? instanceRow.registrationOpen === 1 : config.registrationOpen; - if (!registrationOpen) { - return reply.code(403).send({ error: 'Registration is currently closed', statusCode: 403 }); + // instanceRow is guaranteed by ensureDefaults() (migrate.ts) to have id=1 + // post-boot, with federatedRegistrationOpen NOT NULL DEFAULT 1. The optional + // chain is defensive against the impossible-in-production case of a missing + // row (e.g., a hand-cleared DB) — falls open-closed rather than open-open + // for federation, which is the safer default. + const federatedRegistrationOpen = instanceRow?.federatedRegistrationOpen === 1; + + // Optional invite token. Only meaningful for the local-closed path; ignored + // entirely on the federated path (spec §1.3, §5.6) and on the local-open path + // (spec §5.7). + const inviteToken = typeof request.body.inviteToken === 'string' + ? request.body.inviteToken + : undefined; + + if (homeInstance) { + // Federated path: token IGNORED entirely. Gate is federatedRegistrationOpen. + if (!federatedRegistrationOpen) { + return reply.code(403).send({ error: 'Federated registration is closed on this instance', statusCode: 403 }); + } + // Fall through to existing federated stub upgrade / new federated user logic below. + } else { + // Local path: registrationOpen is the primary gate. A valid invite token + // bypasses it when closed. When open, the token is silently ignored. + if (!registrationOpen) { + if (!inviteToken) { + return reply.code(403).send({ error: 'Registration is closed. An invite is required.', statusCode: 403 }); + } + // Pre-flight check: reject obviously-invalid tokens before any expensive + // work (bcrypt). The final enforcement still happens inside the redemption + // transaction below — this only short-circuits the easy reject path. + const inviteRow = getInviteByToken(inviteToken); + if (!inviteRow || inviteStatus(inviteRow) !== 'active') { + return reply.code(403).send({ error: 'Invalid or expired invite', statusCode: 403 }); + } + } + // If registrationOpen is true: inviteToken is silently ignored — no validation, + // no consumption (spec §5.7). } const passwordHash = await hashPassword(password); @@ -174,7 +214,7 @@ export async function authRoutes(app: FastifyInstance): Promise { // which would otherwise produce a permanently stuck-online row that no // disconnect timer can clean up. The WS handshake will flip it to // 'online' once a real socket attaches. - db.insert(schema.users).values({ + const userRow = { id: userId, username: trimmedUsername, displayName: displayName?.trim() || null, @@ -184,7 +224,35 @@ export async function authRoutes(app: FastifyInstance): Promise { homeUserId: (homeInstance && homeUserId && typeof homeUserId === 'string') ? homeUserId : null, avatarColor, createdAt: now, - }).run(); + }; + + // Only the LOCAL-CLOSED-WITH-VALID-TOKEN path consumes an invite. The federated + // paths (handled above and in the stub-upgrade block) and the local-open path + // never touch the invite_links table. + const consumesInvite = !homeInstance && !registrationOpen && !!inviteToken; + + if (consumesInvite) { + // Atomic redemption: the user INSERT, the usedCount bump, and the + // invite_redemptions row all run inside one SQLite transaction. If any + // step throws (token consumed by a concurrent request, username collision + // bumping into the unique index, etc.) the entire transaction rolls back — + // we never burn a redemption on a failed registration. + try { + redeemInvite(inviteToken!, () => { + db.insert(schema.users).values(userRow).run(); + return { id: userId, username: trimmedUsername }; + }); + } catch (err) { + if (err instanceof InviteUnavailableError) { + // Concurrent revoke / last-slot race / expiry-while-typing all surface here. + return reply.code(403).send({ error: 'Invalid or expired invite', statusCode: 403 }); + } + throw err; + } + } else { + // Standard local-open or federated-new-user path: plain user insert. + db.insert(schema.users).values(userRow).run(); + } const user = db.select().from(schema.users).where(eq(schema.users.id, userId)).get(); if (!user) { @@ -239,6 +307,48 @@ export async function authRoutes(app: FastifyInstance): Promise { return reply.code(200).send({ available: !existing }); }); + // Public — used by RegisterPage to debounce-validate invite tokens during + // typing. The status -> response mapping enforces a "collapsed enumeration + // shield": revoked, not-found, and malformed tokens all collapse to + // `'invalid'` so this endpoint can't be used to distinguish them. Only + // `expired` and `exhausted` surface as themselves because those are + // legitimate UX hints ("ask the admin to extend it") rather than + // existence/state leaks. The `name` field is returned ONLY in the valid + // case — invalid responses must not leak any invite metadata. + // Status code is always 200; the response body discriminates. + app.get<{ Querystring: { token?: string } }>('/api/auth/check-invite', { + config: { + rateLimit: { + max: 30, + timeWindow: '1 minute', + keyGenerator: (request: any) => request.ip, + }, + }, + }, async (request, reply) => { + const token = request.query.token; + if (!token || typeof token !== 'string') { + return reply.code(200).send({ valid: false, reason: 'invalid' }); + } + + // getInviteByToken pre-validates the 22-char base64url shape before + // hitting the DB; malformed inputs return null here, so the same branch + // covers both "wrong shape" and "shape ok, not in DB". + const row = getInviteByToken(token); + if (!row) { + return reply.code(200).send({ valid: false, reason: 'invalid' }); + } + + const status = inviteStatus(row); + if (status === 'active') { + return reply.code(200).send({ valid: true, name: row.name }); + } + if (status === 'expired' || status === 'exhausted') { + return reply.code(200).send({ valid: false, reason: status }); + } + // status === 'revoked' — collapsed to 'invalid' (no enumeration leak) + return reply.code(200).send({ valid: false, reason: 'invalid' }); + }); + app.post<{ Body: LoginRequest }>('/api/auth/login', { config: { rateLimit: { diff --git a/packages/server/src/routes/instance.test.ts b/packages/server/src/routes/instance.test.ts new file mode 100644 index 00000000..d951413e --- /dev/null +++ b/packages/server/src/routes/instance.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { eq } from 'drizzle-orm'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; +import { setWorkerId } from '../utils/snowflake.js'; + +setWorkerId(3); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Module-level mutable state — see invites.test.ts for the rationale on why +// the `getDb` mock closes over a getter rather than the binding directly. +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; +let app: FastifyInstance; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +function applyMigrations(db: Database.Database): void { + const migrationsDir = path.resolve(__dirname, '../../drizzle'); + const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort(); + for (const f of files) { + const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8'); + const statements = sqlText.split(/-->\s*statement-breakpoint/); + for (const stmt of statements) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +async function buildApp(): Promise { + const { instanceRoutes } = await import('./instance.js'); + const f = Fastify(); + await f.register(instanceRoutes); + return f; +} + +beforeEach(async () => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + + // Seed the singleton instance_settings row mirroring ensureDefaults() — + // tests don't run the boot-time helper, so we insert manually with the + // schema-default values for the new federatedRegistrationOpen column. + testDb.insert(schema.instanceSettings).values({ + id: 1, + updatedAt: Date.now(), + }).run(); + + app = await buildApp(); +}); + +describe('GET /api/instance/info', () => { + it('includes federatedRegistrationOpen (default true)', async () => { + const res = await app.inject({ method: 'GET', url: '/api/instance/info' }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.federatedRegistrationOpen).toBe(true); + }); + + it('reflects federatedRegistrationOpen=false when toggled off', async () => { + testDb.update(schema.instanceSettings) + .set({ federatedRegistrationOpen: 0 }) + .where(eq(schema.instanceSettings.id, 1)) + .run(); + + const res = await app.inject({ method: 'GET', url: '/api/instance/info' }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.federatedRegistrationOpen).toBe(false); + }); + + it('returns the full contract: name, version, registrationOpen, federatedRegistrationOpen', async () => { + const res = await app.inject({ method: 'GET', url: '/api/instance/info' }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(typeof body.name).toBe('string'); + expect(typeof body.version).toBe('string'); + expect(typeof body.registrationOpen).toBe('boolean'); + expect(typeof body.federatedRegistrationOpen).toBe('boolean'); + }); +}); diff --git a/packages/server/src/routes/instance.ts b/packages/server/src/routes/instance.ts index 8a235b94..11f09a39 100644 --- a/packages/server/src/routes/instance.ts +++ b/packages/server/src/routes/instance.ts @@ -22,6 +22,7 @@ export async function instanceRoutes(app: FastifyInstance): Promise { name: instanceName, version: BACKSPACE_VERSION, registrationOpen, + federatedRegistrationOpen: settings?.federatedRegistrationOpen === 1, }; return reply.code(200).send(response); diff --git a/packages/server/src/routes/invites.test.ts b/packages/server/src/routes/invites.test.ts new file mode 100644 index 00000000..4e5a086a --- /dev/null +++ b/packages/server/src/routes/invites.test.ts @@ -0,0 +1,357 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { eq } from 'drizzle-orm'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; +import { setWorkerId } from '../utils/snowflake.js'; + +setWorkerId(1); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Module-level mutable state. Each describe's beforeEach reassigns +// `sqlite`/`testDb`/`app`; the `getDb: () => testDb` getter in the mock +// closes over the current binding, so reassignment is observed. The +// `callerIsAdmin` flag lets a test flip the admin guard to verify 403s. +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; +let app: FastifyInstance; +let callerIsAdmin = true; +const CALLER_ID = 'admin-1'; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +vi.mock('../utils/auth.js', () => ({ + authenticate: async (req: { userId?: string }) => { + req.userId = CALLER_ID; + }, + requireAdmin: async ( + _req: { userId?: string }, + reply: { code: (n: number) => { send: (b: unknown) => unknown } }, + ) => { + if (!callerIsAdmin) { + return reply.code(403).send({ error: 'Admin required', statusCode: 403 }); + } + }, +})); + +function applyMigrations(db: Database.Database): void { + const migrationsDir = path.resolve(__dirname, '../../drizzle'); + const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort(); + for (const f of files) { + const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8'); + const statements = sqlText.split(/-->\s*statement-breakpoint/); + for (const stmt of statements) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +async function buildApp(): Promise { + const { invitesRoutes } = await import('./invites.js'); + const f = Fastify(); + await f.register(invitesRoutes); + return f; +} + +beforeEach(async () => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + callerIsAdmin = true; + + testDb.insert(schema.users).values({ + id: CALLER_ID, + username: 'admin', + passwordHash: 'x', + isAdmin: 1, + createdAt: Date.now(), + }).run(); + + app = await buildApp(); +}); + +describe('POST /api/admin/invites', () => { + it('creates an invite and returns summary', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/admin/invites', + payload: { name: 'Friends', maxUses: 10, expiresAt: Date.now() + 86_400_000 }, + }); + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.name).toBe('Friends'); + expect(body.token).toMatch(/^[A-Za-z0-9_-]{22}$/); + expect(body.status).toBe('active'); + expect(body.url).toContain('/register?invite='); + }); + + it('rejects empty name with 400', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/admin/invites', + payload: { name: '', maxUses: null, expiresAt: null }, + }); + expect(res.statusCode).toBe(400); + }); + + it('returns 403 for non-admin', async () => { + callerIsAdmin = false; + const res = await app.inject({ + method: 'POST', + url: '/api/admin/invites', + payload: { name: 'x', maxUses: null, expiresAt: null }, + }); + expect(res.statusCode).toBe(403); + }); +}); + +describe('GET /api/admin/invites', () => { + it('returns active invites by default', async () => { + await app.inject({ + method: 'POST', + url: '/api/admin/invites', + payload: { name: 'a', maxUses: null, expiresAt: null }, + }); + const res = await app.inject({ method: 'GET', url: '/api/admin/invites' }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.invites).toHaveLength(1); + expect(body.invites[0].name).toBe('a'); + }); + + it('returns archived invites with status=archived', async () => { + const created = await app.inject({ + method: 'POST', + url: '/api/admin/invites', + payload: { name: 'a', maxUses: null, expiresAt: null }, + }); + const id = created.json().id; + await app.inject({ method: 'POST', url: `/api/admin/invites/${id}/revoke` }); + + const active = await app.inject({ method: 'GET', url: '/api/admin/invites?status=active' }); + expect(active.json().invites).toHaveLength(0); + + const archived = await app.inject({ method: 'GET', url: '/api/admin/invites?status=archived' }); + expect(archived.json().invites).toHaveLength(1); + expect(archived.json().invites[0].status).toBe('revoked'); + }); + + it('createdByUsername surfaces "Deleted User" after admin tombstone', async () => { + await app.inject({ + method: 'POST', + url: '/api/admin/invites', + payload: { name: 'a', maxUses: null, expiresAt: null }, + }); + // Tombstone the admin who created the invite. The list endpoint uses a + // LEFT JOIN against users; foldUsername should map isDeleted=1 to + // 'Deleted User'. This exercises the JOIN-based code path, not the + // per-row resolveCreatorUsername fallback. + testDb.update(schema.users) + .set({ isDeleted: 1, username: '!deleted:' + CALLER_ID }) + .where(eq(schema.users.id, CALLER_ID)) + .run(); + + const res = await app.inject({ method: 'GET', url: '/api/admin/invites' }); + expect(res.json().invites[0].createdByUsername).toBe('Deleted User'); + }); +}); + +describe('PATCH /api/admin/invites/:id', () => { + it('updates name', async () => { + const created = await app.inject({ + method: 'POST', + url: '/api/admin/invites', + payload: { name: 'old', maxUses: null, expiresAt: null }, + }); + const id = created.json().id; + const res = await app.inject({ + method: 'PATCH', + url: `/api/admin/invites/${id}`, + payload: { name: 'new' }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().name).toBe('new'); + }); + + it('returns 409 for revoked invite', async () => { + const created = await app.inject({ + method: 'POST', + url: '/api/admin/invites', + payload: { name: 'a', maxUses: null, expiresAt: null }, + }); + const id = created.json().id; + await app.inject({ method: 'POST', url: `/api/admin/invites/${id}/revoke` }); + const res = await app.inject({ + method: 'PATCH', + url: `/api/admin/invites/${id}`, + payload: { name: 'x' }, + }); + expect(res.statusCode).toBe(409); + }); + + it('returns 404 for missing id', async () => { + const res = await app.inject({ + method: 'PATCH', + url: '/api/admin/invites/nope', + payload: { name: 'x' }, + }); + expect(res.statusCode).toBe(404); + }); +}); + +describe('POST /api/admin/invites/:id/revoke', () => { + it('revokes and returns summary', async () => { + const created = await app.inject({ + method: 'POST', + url: '/api/admin/invites', + payload: { name: 'a', maxUses: null, expiresAt: null }, + }); + const id = created.json().id; + const res = await app.inject({ method: 'POST', url: `/api/admin/invites/${id}/revoke` }); + expect(res.statusCode).toBe(200); + expect(res.json().invite.status).toBe('revoked'); + }); + + it('returns 409 on already-revoked', async () => { + const created = await app.inject({ + method: 'POST', + url: '/api/admin/invites', + payload: { name: 'a', maxUses: null, expiresAt: null }, + }); + const id = created.json().id; + await app.inject({ method: 'POST', url: `/api/admin/invites/${id}/revoke` }); + const res = await app.inject({ method: 'POST', url: `/api/admin/invites/${id}/revoke` }); + expect(res.statusCode).toBe(409); + }); +}); + +describe('POST /api/admin/invites/:id/reinstate', () => { + it('rotates token on revoked path', async () => { + const created = await app.inject({ + method: 'POST', + url: '/api/admin/invites', + payload: { name: 'a', maxUses: 1, expiresAt: null }, + }); + const id = created.json().id; + const originalToken = created.json().token; + await app.inject({ method: 'POST', url: `/api/admin/invites/${id}/revoke` }); + + const res = await app.inject({ + method: 'POST', + url: `/api/admin/invites/${id}/reinstate`, + payload: { maxUses: 5 }, + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.tokenRotated).toBe(true); + expect(body.invite.token).not.toBe(originalToken); + expect(body.invite.status).toBe('active'); + }); + + it('preserves token on exhausted path', async () => { + const created = await app.inject({ + method: 'POST', + url: '/api/admin/invites', + payload: { name: 'a', maxUses: 1, expiresAt: null }, + }); + const id = created.json().id; + const originalToken = created.json().token; + // Force the row to exhausted by bumping usedCount to maxUses. + testDb.update(schema.inviteLinks) + .set({ usedCount: 1 }) + .where(eq(schema.inviteLinks.id, id)) + .run(); + + const res = await app.inject({ + method: 'POST', + url: `/api/admin/invites/${id}/reinstate`, + payload: { maxUses: 5 }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().tokenRotated).toBe(false); + expect(res.json().invite.token).toBe(originalToken); + }); + + it('returns 409 on already-active', async () => { + const created = await app.inject({ + method: 'POST', + url: '/api/admin/invites', + payload: { name: 'a', maxUses: null, expiresAt: null }, + }); + const res = await app.inject({ + method: 'POST', + url: `/api/admin/invites/${created.json().id}/reinstate`, + payload: { maxUses: 100 }, + }); + expect(res.statusCode).toBe(409); + }); +}); + +describe('DELETE /api/admin/invites/:id', () => { + it('hard-deletes and cascades redemptions', async () => { + const created = await app.inject({ + method: 'POST', + url: '/api/admin/invites', + payload: { name: 'a', maxUses: null, expiresAt: null }, + }); + const id = created.json().id; + testDb.insert(schema.inviteRedemptions).values({ + id: 'r1', + inviteId: id, + userId: null, + registrantUsername: 'g', + redeemedAt: Date.now(), + }).run(); + + const res = await app.inject({ method: 'DELETE', url: `/api/admin/invites/${id}` }); + expect(res.statusCode).toBe(200); + expect(res.json().success).toBe(true); + expect( + testDb.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get(), + ).toBeUndefined(); + expect( + testDb.select().from(schema.inviteRedemptions).where(eq(schema.inviteRedemptions.inviteId, id)).all(), + ).toHaveLength(0); + }); +}); + +describe('GET /api/admin/invites/:id/redemptions', () => { + it('returns redemption list', async () => { + const created = await app.inject({ + method: 'POST', + url: '/api/admin/invites', + payload: { name: 'a', maxUses: null, expiresAt: null }, + }); + const id = created.json().id; + testDb.insert(schema.users).values({ + id: 'u1', + username: 'alice', + passwordHash: 'x', + createdAt: Date.now(), + }).run(); + testDb.insert(schema.inviteRedemptions).values({ + id: 'r1', + inviteId: id, + userId: 'u1', + registrantUsername: 'alice', + redeemedAt: Date.now(), + }).run(); + + const res = await app.inject({ method: 'GET', url: `/api/admin/invites/${id}/redemptions` }); + expect(res.statusCode).toBe(200); + expect(res.json().redemptions).toHaveLength(1); + expect(res.json().redemptions[0].registrantUsername).toBe('alice'); + expect(res.json().redemptions[0].currentUsername).toBe('alice'); + }); +}); diff --git a/packages/server/src/routes/invites.ts b/packages/server/src/routes/invites.ts new file mode 100644 index 00000000..0c47b9ea --- /dev/null +++ b/packages/server/src/routes/invites.ts @@ -0,0 +1,137 @@ +import type { FastifyInstance } from 'fastify'; +import { authenticate, requireAdmin } from '../utils/auth.js'; +import { + createInvite, + listInvites, + patchInvite, + revokeInvite, + reinstateInvite, + deleteInvite, + listRedemptions, + InviteNotFoundError, + InviteStateConflictError, + InviteValidationError, +} from '../utils/inviteService.js'; +import type { + CreateInviteRequest, + UpdateInviteRequest, + ReinstateInviteRequest, +} from '@backspace/shared'; + +/** + * Admin CRUD routes for invite links. All endpoints sit behind the + * `[authenticate, requireAdmin]` preHandler chain — invites are an + * instance-local moderation surface and never reach federation. + * + * The handlers are thin wrappers: they parse the request shape, delegate + * to `inviteService` (which owns validation, transactions, and status + * derivation), and translate the typed service errors into HTTP statuses: + * + * - InviteValidationError → 400 Bad Request + * - InviteNotFoundError → 404 Not Found + * - InviteStateConflictError → 409 Conflict + * - anything else → propagates to Fastify's 500 handler + * + * Response shapes mirror spec §3.1 — see `docs/superpowers/specs/2026-04-28-registration-invites-design.md`. + */ +export async function invitesRoutes(app: FastifyInstance): Promise { + app.post<{ Body: CreateInviteRequest }>('/api/admin/invites', { + preHandler: [authenticate, requireAdmin], + }, async (request, reply) => { + try { + const invite = createInvite(request.body, request.userId); + return reply.code(201).send(invite); + } catch (err) { + if (err instanceof InviteValidationError) { + return reply.code(400).send({ error: err.message, statusCode: 400 }); + } + throw err; + } + }); + + app.get<{ Querystring: { status?: string } }>('/api/admin/invites', { + preHandler: [authenticate, requireAdmin], + }, async (request, reply) => { + const status = request.query.status === 'archived' ? 'archived' : 'active'; + const invites = listInvites(status); + return reply.code(200).send({ invites }); + }); + + app.patch<{ Params: { id: string }; Body: UpdateInviteRequest }>('/api/admin/invites/:id', { + preHandler: [authenticate, requireAdmin], + }, async (request, reply) => { + try { + const invite = patchInvite(request.params.id, request.body ?? {}); + return reply.code(200).send(invite); + } catch (err) { + if (err instanceof InviteNotFoundError) { + return reply.code(404).send({ error: err.message, statusCode: 404 }); + } + if (err instanceof InviteStateConflictError) { + return reply.code(409).send({ error: err.message, statusCode: 409 }); + } + if (err instanceof InviteValidationError) { + return reply.code(400).send({ error: err.message, statusCode: 400 }); + } + throw err; + } + }); + + app.post<{ Params: { id: string } }>('/api/admin/invites/:id/revoke', { + preHandler: [authenticate, requireAdmin], + }, async (request, reply) => { + try { + const invite = revokeInvite(request.params.id); + return reply.code(200).send({ invite }); + } catch (err) { + if (err instanceof InviteNotFoundError) { + return reply.code(404).send({ error: err.message, statusCode: 404 }); + } + if (err instanceof InviteStateConflictError) { + return reply.code(409).send({ error: err.message, statusCode: 409 }); + } + throw err; + } + }); + + app.post<{ Params: { id: string }; Body: ReinstateInviteRequest }>('/api/admin/invites/:id/reinstate', { + preHandler: [authenticate, requireAdmin], + }, async (request, reply) => { + try { + const result = reinstateInvite(request.params.id, request.body ?? {}); + return reply.code(200).send(result); + } catch (err) { + if (err instanceof InviteNotFoundError) { + return reply.code(404).send({ error: err.message, statusCode: 404 }); + } + if (err instanceof InviteStateConflictError) { + return reply.code(409).send({ error: err.message, statusCode: 409 }); + } + if (err instanceof InviteValidationError) { + return reply.code(400).send({ error: err.message, statusCode: 400 }); + } + throw err; + } + }); + + app.delete<{ Params: { id: string } }>('/api/admin/invites/:id', { + preHandler: [authenticate, requireAdmin], + }, async (request, reply) => { + try { + deleteInvite(request.params.id); + return reply.code(200).send({ success: true }); + } catch (err) { + if (err instanceof InviteNotFoundError) { + return reply.code(404).send({ error: err.message, statusCode: 404 }); + } + throw err; + } + }); + + app.get<{ Params: { id: string } }>('/api/admin/invites/:id/redemptions', { + preHandler: [authenticate, requireAdmin], + }, async (request, reply) => { + const redemptions = listRedemptions(request.params.id); + return reply.code(200).send({ redemptions }); + }); +} diff --git a/packages/server/src/routes/settings.test.ts b/packages/server/src/routes/settings.test.ts new file mode 100644 index 00000000..010ff532 --- /dev/null +++ b/packages/server/src/routes/settings.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { eq } from 'drizzle-orm'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; +import { setWorkerId } from '../utils/snowflake.js'; + +setWorkerId(4); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Module-level mutable state — see invites.test.ts for the rationale on why +// the `getDb` mock closes over a getter rather than the binding directly. +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; +let app: FastifyInstance; +const ADMIN_ID = 'admin-1'; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +vi.mock('../utils/auth.js', () => ({ + authenticate: async (req: { userId?: string }) => { + req.userId = ADMIN_ID; + }, + requireAdmin: async () => { + // tests run as admin + }, +})); + +function applyMigrations(db: Database.Database): void { + const migrationsDir = path.resolve(__dirname, '../../drizzle'); + const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort(); + for (const f of files) { + const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8'); + const statements = sqlText.split(/-->\s*statement-breakpoint/); + for (const stmt of statements) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +async function buildApp(): Promise { + const { settingsRoutes } = await import('./settings.js'); + const f = Fastify(); + await f.register(settingsRoutes); + return f; +} + +beforeEach(async () => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + + // Seed the singleton instance_settings row (mirrors ensureDefaults at boot). + testDb.insert(schema.instanceSettings).values({ + id: 1, + updatedAt: Date.now(), + }).run(); + + // Seed the admin user — settings routes require an authenticated admin. + testDb.insert(schema.users).values({ + id: ADMIN_ID, + username: 'admin', + passwordHash: 'x', + isAdmin: 1, + createdAt: Date.now(), + }).run(); + + app = await buildApp(); +}); + +describe('GET /api/settings/instance', () => { + it('surfaces federatedRegistrationOpen (default true)', async () => { + const res = await app.inject({ method: 'GET', url: '/api/settings/instance' }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.federatedRegistrationOpen).toBe(true); + }); + + it('reflects federatedRegistrationOpen=false when toggled off in DB', async () => { + testDb.update(schema.instanceSettings) + .set({ federatedRegistrationOpen: 0 }) + .where(eq(schema.instanceSettings.id, 1)) + .run(); + + const res = await app.inject({ method: 'GET', url: '/api/settings/instance' }); + expect(res.statusCode).toBe(200); + expect(res.json().federatedRegistrationOpen).toBe(false); + }); +}); + +describe('PATCH /api/settings/instance — federatedRegistrationOpen', () => { + it('accepts federatedRegistrationOpen=false and persists it', async () => { + const res = await app.inject({ + method: 'PATCH', + url: '/api/settings/instance', + payload: { federatedRegistrationOpen: false }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().federatedRegistrationOpen).toBe(false); + + // Verify persistence + const row = testDb.select().from(schema.instanceSettings) + .where(eq(schema.instanceSettings.id, 1)).get(); + expect(row?.federatedRegistrationOpen).toBe(0); + }); + + it('accepts federatedRegistrationOpen=true (re-enable)', async () => { + testDb.update(schema.instanceSettings) + .set({ federatedRegistrationOpen: 0 }) + .where(eq(schema.instanceSettings.id, 1)) + .run(); + + const res = await app.inject({ + method: 'PATCH', + url: '/api/settings/instance', + payload: { federatedRegistrationOpen: true }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().federatedRegistrationOpen).toBe(true); + + const row = testDb.select().from(schema.instanceSettings) + .where(eq(schema.instanceSettings.id, 1)).get(); + expect(row?.federatedRegistrationOpen).toBe(1); + }); + + it('rejects non-boolean federatedRegistrationOpen with 400', async () => { + const res = await app.inject({ + method: 'PATCH', + url: '/api/settings/instance', + payload: { federatedRegistrationOpen: 'yes' }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toMatch(/federatedRegistrationOpen/); + }); + + it('leaves federatedRegistrationOpen unchanged when omitted from payload', async () => { + // First toggle the DB column to false. If the field's value comes from the + // schema default (1) instead of the actual DB row, this test would still + // pass for the wrong reason. Toggling to non-default then asserting the + // non-default survives a partial PATCH proves real preservation. + testDb.update(schema.instanceSettings) + .set({ federatedRegistrationOpen: 0 }) + .where(eq(schema.instanceSettings.id, 1)) + .run(); + + const res = await app.inject({ + method: 'PATCH', + url: '/api/settings/instance', + payload: { instanceName: 'NewName' }, + }); + expect(res.statusCode).toBe(200); + // Field still false (the partial PATCH did not touch it) + expect(res.json().federatedRegistrationOpen).toBe(false); + expect(res.json().instanceName).toBe('NewName'); + + // Verify against the DB directly to rule out a response-shape-only fix + const row = testDb.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get(); + expect(row?.federatedRegistrationOpen).toBe(0); + }); +}); diff --git a/packages/server/src/routes/settings.ts b/packages/server/src/routes/settings.ts index 74e0caa7..1db29c7c 100644 --- a/packages/server/src/routes/settings.ts +++ b/packages/server/src/routes/settings.ts @@ -185,6 +185,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise { const response: InstanceAdminSettings = { instanceName: row.instanceName ?? 'Backspace', registrationOpen: row.registrationOpen !== null ? row.registrationOpen === 1 : config.registrationOpen, + federatedRegistrationOpen: row.federatedRegistrationOpen === 1, discoveryEnabled: row.discoveryEnabled === 1, gifApiKey: gifKey ? `****${gifKey.slice(-4)}` : undefined, gifEnabled: !!gifKey, @@ -216,6 +217,13 @@ export async function settingsRoutes(app: FastifyInstance): Promise { updateData.registrationOpen = body.registrationOpen ? 1 : 0; } + if (body.federatedRegistrationOpen !== undefined) { + if (typeof body.federatedRegistrationOpen !== 'boolean') { + return reply.code(400).send({ error: 'federatedRegistrationOpen must be boolean', statusCode: 400 }); + } + updateData.federatedRegistrationOpen = body.federatedRegistrationOpen ? 1 : 0; + } + if (body.discoveryEnabled !== undefined) { updateData.discoveryEnabled = body.discoveryEnabled ? 1 : 0; } @@ -276,6 +284,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise { const response: InstanceAdminSettings = { instanceName: updatedRow.instanceName ?? 'Backspace', registrationOpen: updatedRow.registrationOpen !== null ? updatedRow.registrationOpen === 1 : config.registrationOpen, + federatedRegistrationOpen: updatedRow.federatedRegistrationOpen === 1, discoveryEnabled: updatedRow.discoveryEnabled === 1, gifApiKey: updatedGifKey ? `****${updatedGifKey.slice(-4)}` : undefined, gifEnabled: !!updatedGifKey, diff --git a/packages/server/src/utils/inviteService.test.ts b/packages/server/src/utils/inviteService.test.ts new file mode 100644 index 00000000..be1dac35 --- /dev/null +++ b/packages/server/src/utils/inviteService.test.ts @@ -0,0 +1,732 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import * as schema from '../db/schema.js'; +import { setWorkerId } from './snowflake.js'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +setWorkerId(1); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +import { inviteStatus, generateInviteToken, createInvite, getInviteByToken, listInvites, listRedemptions, InviteValidationError } from './inviteService.js'; +import { patchInvite, revokeInvite, InviteStateConflictError, InviteNotFoundError } from './inviteService.js'; +import { reinstateInvite } from './inviteService.js'; +import { redeemInvite, deleteInvite, InviteUnavailableError } from './inviteService.js'; +import { eq } from 'drizzle-orm'; + +function applyMigrations(db: Database.Database): void { + const migrationsDir = path.resolve(__dirname, '../../drizzle'); + const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort(); + for (const f of files) { + const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8'); + const statements = sqlText.split(/-->\s*statement-breakpoint/); + for (const stmt of statements) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +function seedAdmin(): string { + const adminId = 'admin-user-1'; + testDb.insert(schema.users).values({ + id: adminId, + username: 'admin', + passwordHash: 'x', + isAdmin: 1, + createdAt: Date.now(), + }).run(); + return adminId; +} + +describe('inviteStatus', () => { + const base = { revokedAt: null, expiresAt: null, maxUses: null, usedCount: 0 }; + + it('returns active for a fresh invite', () => { + expect(inviteStatus(base)).toBe('active'); + }); + + it('returns revoked when revokedAt is set', () => { + expect(inviteStatus({ ...base, revokedAt: 100 })).toBe('revoked'); + }); + + it('returns expired when expiresAt is past', () => { + expect(inviteStatus({ ...base, expiresAt: Date.now() - 1000 })).toBe('expired'); + }); + + it('returns active when expiresAt is future', () => { + expect(inviteStatus({ ...base, expiresAt: Date.now() + 100_000 })).toBe('active'); + }); + + it('returns exhausted when usedCount >= maxUses', () => { + expect(inviteStatus({ ...base, maxUses: 5, usedCount: 5 })).toBe('exhausted'); + expect(inviteStatus({ ...base, maxUses: 5, usedCount: 6 })).toBe('exhausted'); + }); + + it('returns active when usedCount < maxUses', () => { + expect(inviteStatus({ ...base, maxUses: 5, usedCount: 4 })).toBe('active'); + }); + + it('revoked beats expired', () => { + expect(inviteStatus({ ...base, revokedAt: 100, expiresAt: Date.now() - 1000 })).toBe('revoked'); + }); + + it('expired beats exhausted', () => { + expect(inviteStatus({ ...base, expiresAt: Date.now() - 1000, maxUses: 5, usedCount: 5 })).toBe('expired'); + }); + + it('treats maxUses null as unlimited', () => { + expect(inviteStatus({ ...base, maxUses: null, usedCount: 1_000_000 })).toBe('active'); + }); +}); + +describe('generateInviteToken', () => { + it('returns 22-char base64url string', () => { + const t = generateInviteToken(); + expect(t).toMatch(/^[A-Za-z0-9_-]{22}$/); + }); + + it('returns different tokens on subsequent calls', () => { + const a = generateInviteToken(); + const b = generateInviteToken(); + expect(a).not.toBe(b); + }); +}); + +describe('createInvite', () => { + beforeEach(() => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + }); + + it('creates an invite with required fields', () => { + const adminId = seedAdmin(); + const invite = createInvite({ name: 'Friends batch 1', maxUses: 10, expiresAt: Date.now() + 86_400_000 }, adminId); + expect(invite.name).toBe('Friends batch 1'); + expect(invite.maxUses).toBe(10); + expect(invite.usedCount).toBe(0); + expect(invite.revokedAt).toBeNull(); + expect(invite.token).toMatch(/^[A-Za-z0-9_-]{22}$/); + expect(invite.status).toBe('active'); + expect(invite.createdBy).toBe(adminId); + expect(invite.createdByUsername).toBe('admin'); + }); + + it('accepts null maxUses (unlimited)', () => { + const adminId = seedAdmin(); + const invite = createInvite({ name: 'unlimited', maxUses: null, expiresAt: null }, adminId); + expect(invite.maxUses).toBeNull(); + expect(invite.expiresAt).toBeNull(); + expect(invite.status).toBe('active'); + }); + + it('rejects empty name', () => { + const adminId = seedAdmin(); + expect(() => createInvite({ name: '', maxUses: null, expiresAt: null }, adminId)).toThrow(InviteValidationError); + }); + + it('rejects name longer than 64 chars', () => { + const adminId = seedAdmin(); + expect(() => createInvite({ name: 'x'.repeat(65), maxUses: null, expiresAt: null }, adminId)).toThrow(InviteValidationError); + }); + + it('rejects non-positive maxUses', () => { + const adminId = seedAdmin(); + expect(() => createInvite({ name: 'a', maxUses: 0, expiresAt: null }, adminId)).toThrow(InviteValidationError); + expect(() => createInvite({ name: 'a', maxUses: -1, expiresAt: null }, adminId)).toThrow(InviteValidationError); + }); + + it('rejects past expiresAt', () => { + const adminId = seedAdmin(); + expect(() => createInvite({ name: 'a', maxUses: null, expiresAt: Date.now() - 1000 }, adminId)).toThrow(InviteValidationError); + }); +}); + +describe('getInviteByToken', () => { + beforeEach(() => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + }); + + it('returns the invite when token matches', () => { + const adminId = seedAdmin(); + const created = createInvite({ name: 'a', maxUses: null, expiresAt: null }, adminId); + const found = getInviteByToken(created.token); + expect(found?.id).toBe(created.id); + }); + + it('returns null when token has invalid format', () => { + expect(getInviteByToken('tooshort')).toBeNull(); + expect(getInviteByToken('nonexistent_token_aaaaaa')).toBeNull(); // 24 chars + }); + + it('returns null when token is well-formed but not in DB', () => { + expect(getInviteByToken('aaaaaaaaaaaaaaaaaaaaaa')).toBeNull(); // 22 chars, valid format + }); +}); + +describe('listInvites', () => { + beforeEach(() => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + }); + + it('returns active invites only with status=active', () => { + const adminId = seedAdmin(); + const a = createInvite({ name: 'active1', maxUses: null, expiresAt: null }, adminId); + const b = createInvite({ name: 'active2', maxUses: 1, expiresAt: null }, adminId); + // Manually exhaust b + testDb.update(schema.inviteLinks).set({ usedCount: 1 }).where(eq(schema.inviteLinks.id, b.id)).run(); + + const list = listInvites('active'); + expect(list.map(i => i.id)).toEqual([a.id]); + }); + + it('returns archived invites only with status=archived', () => { + const adminId = seedAdmin(); + createInvite({ name: 'active', maxUses: null, expiresAt: null }, adminId); + const exhausted = createInvite({ name: 'exhausted', maxUses: 1, expiresAt: null }, adminId); + testDb.update(schema.inviteLinks).set({ usedCount: 1 }).where(eq(schema.inviteLinks.id, exhausted.id)).run(); + const revoked = createInvite({ name: 'revoked', maxUses: null, expiresAt: null }, adminId); + testDb.update(schema.inviteLinks).set({ revokedAt: Date.now() }).where(eq(schema.inviteLinks.id, revoked.id)).run(); + + const list = listInvites('archived'); + expect(list.map(i => i.id).sort()).toEqual([exhausted.id, revoked.id].sort()); + expect(list.find(i => i.id === exhausted.id)?.status).toBe('exhausted'); + expect(list.find(i => i.id === revoked.id)?.status).toBe('revoked'); + }); + + it('JOIN surfaces createdByUsername; tombstoned creator -> "Deleted User"', () => { + const adminId = seedAdmin(); + createInvite({ name: 'i1', maxUses: null, expiresAt: null }, adminId); + // Tombstone admin + testDb.update(schema.users).set({ isDeleted: 1, username: '!deleted:' + adminId }).where(eq(schema.users.id, adminId)).run(); + + const list = listInvites('active'); + expect(list[0]?.createdByUsername).toBe('Deleted User'); + }); + + it('sorts by createdAt DESC', async () => { + const adminId = seedAdmin(); + const a = createInvite({ name: 'first', maxUses: null, expiresAt: null }, adminId); + await new Promise(r => setTimeout(r, 5)); + const b = createInvite({ name: 'second', maxUses: null, expiresAt: null }, adminId); + const list = listInvites('active'); + expect(list.map(i => i.id)).toEqual([b.id, a.id]); + }); +}); + +describe('listRedemptions', () => { + beforeEach(() => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + }); + + it('returns redemption rows with currentUsername joined', () => { + const adminId = seedAdmin(); + const invite = createInvite({ name: 'i', maxUses: null, expiresAt: null }, adminId); + const userId = 'user-1'; + testDb.insert(schema.users).values({ id: userId, username: 'alice', passwordHash: 'x', createdAt: Date.now() }).run(); + testDb.insert(schema.inviteRedemptions).values({ + id: 'red-1', + inviteId: invite.id, + userId, + registrantUsername: 'alice', + redeemedAt: Date.now(), + }).run(); + + const list = listRedemptions(invite.id); + expect(list).toHaveLength(1); + expect(list[0]?.registrantUsername).toBe('alice'); + expect(list[0]?.currentUsername).toBe('alice'); + expect(list[0]?.isDeleted).toBe(false); + }); + + it('marks tombstoned users with currentUsername="Deleted User" and isDeleted=true', () => { + const adminId = seedAdmin(); + const invite = createInvite({ name: 'i', maxUses: null, expiresAt: null }, adminId); + const userId = 'user-2'; + testDb.insert(schema.users).values({ id: userId, username: 'bob', passwordHash: 'x', isDeleted: 1, createdAt: Date.now() }).run(); + testDb.insert(schema.inviteRedemptions).values({ + id: 'red-2', + inviteId: invite.id, + userId, + registrantUsername: 'bob', + redeemedAt: Date.now(), + }).run(); + + const list = listRedemptions(invite.id); + expect(list[0]?.currentUsername).toBe('Deleted User'); + expect(list[0]?.isDeleted).toBe(true); + }); + + it('handles null userId (hard-deleted user)', () => { + const adminId = seedAdmin(); + const invite = createInvite({ name: 'i', maxUses: null, expiresAt: null }, adminId); + testDb.insert(schema.inviteRedemptions).values({ + id: 'red-3', + inviteId: invite.id, + userId: null, + registrantUsername: 'ghost', + redeemedAt: Date.now(), + }).run(); + + const list = listRedemptions(invite.id); + expect(list[0]?.userId).toBeNull(); + expect(list[0]?.currentUsername).toBeNull(); + expect(list[0]?.isDeleted).toBe(false); + }); +}); + +describe('patchInvite', () => { + beforeEach(() => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + }); + + it('updates name', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'old', maxUses: null, expiresAt: null }, adminId); + const updated = patchInvite(inv.id, { name: 'new' }); + expect(updated.name).toBe('new'); + }); + + it('updates maxUses', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: 5, expiresAt: null }, adminId); + const updated = patchInvite(inv.id, { maxUses: 20 }); + expect(updated.maxUses).toBe(20); + }); + + it('rejects maxUses below current usedCount', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: 10, expiresAt: null }, adminId); + testDb.update(schema.inviteLinks).set({ usedCount: 7 }).where(eq(schema.inviteLinks.id, inv.id)).run(); + expect(() => patchInvite(inv.id, { maxUses: 5 })).toThrow(InviteValidationError); + }); + + it('throws InviteNotFoundError when id not found', () => { + expect(() => patchInvite('nonexistent', { name: 'x' })).toThrow(InviteNotFoundError); + }); + + it('throws InviteStateConflictError when invite is revoked', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: null, expiresAt: null }, adminId); + testDb.update(schema.inviteLinks).set({ revokedAt: Date.now() }).where(eq(schema.inviteLinks.id, inv.id)).run(); + expect(() => patchInvite(inv.id, { name: 'x' })).toThrow(InviteStateConflictError); + }); + + it('allows expiresAt to be moved to past (effective soft-shut)', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: null, expiresAt: Date.now() + 100_000 }, adminId); + const past = Date.now() - 1000; + const updated = patchInvite(inv.id, { expiresAt: past }); + expect(updated.expiresAt).toBe(past); + expect(updated.status).toBe('expired'); + }); +}); + +describe('revokeInvite', () => { + beforeEach(() => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + }); + + it('sets revokedAt and returns revoked summary', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: null, expiresAt: null }, adminId); + const revoked = revokeInvite(inv.id); + expect(revoked.status).toBe('revoked'); + expect(revoked.revokedAt).toBeGreaterThan(0); + }); + + it('throws InviteStateConflictError on already-revoked', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: null, expiresAt: null }, adminId); + revokeInvite(inv.id); + expect(() => revokeInvite(inv.id)).toThrow(InviteStateConflictError); + }); + + it('throws InviteNotFoundError on missing id', () => { + expect(() => revokeInvite('nonexistent')).toThrow(InviteNotFoundError); + }); +}); + +describe('reinstateInvite', () => { + beforeEach(() => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + }); + + it('Path A — was revoked: rotates token, clears revokedAt, applies bumps', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: 5, expiresAt: null }, adminId); + const originalToken = inv.token; + revokeInvite(inv.id); + + const result = reinstateInvite(inv.id, { maxUses: 10 }); + expect(result.tokenRotated).toBe(true); + expect(result.invite.token).not.toBe(originalToken); + expect(result.invite.token).toMatch(/^[A-Za-z0-9_-]{22}$/); + expect(result.invite.revokedAt).toBeNull(); + expect(result.invite.maxUses).toBe(10); + expect(result.invite.status).toBe('active'); + }); + + it('Path B — exhausted: keeps same token, applies maxUses bump', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: 1, expiresAt: null }, adminId); + // Exhaust it + testDb.update(schema.inviteLinks).set({ usedCount: 1 }).where(eq(schema.inviteLinks.id, inv.id)).run(); + + const result = reinstateInvite(inv.id, { maxUses: 5 }); + expect(result.tokenRotated).toBe(false); + expect(result.invite.token).toBe(inv.token); + expect(result.invite.maxUses).toBe(5); + expect(result.invite.status).toBe('active'); + }); + + it('Path B — expired: keeps same token, bumps expiresAt', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: null, expiresAt: Date.now() + 100_000 }, adminId); + // Move to past to expire + testDb.update(schema.inviteLinks).set({ expiresAt: Date.now() - 1000 }).where(eq(schema.inviteLinks.id, inv.id)).run(); + + const future = Date.now() + 86_400_000; + const result = reinstateInvite(inv.id, { expiresAt: future }); + expect(result.tokenRotated).toBe(false); + expect(result.invite.token).toBe(inv.token); + expect(result.invite.expiresAt).toBe(future); + expect(result.invite.status).toBe('active'); + }); + + it('Path C — already-active: throws InviteStateConflictError', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: null, expiresAt: null }, adminId); + expect(() => reinstateInvite(inv.id, {})).toThrow(InviteStateConflictError); + }); + + it('throws InviteValidationError when result is still non-active (caller did not bump enough)', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: 1, expiresAt: null }, adminId); + // Exhaust it + testDb.update(schema.inviteLinks).set({ usedCount: 1 }).where(eq(schema.inviteLinks.id, inv.id)).run(); + + // Caller did not bump maxUses → still exhausted → must throw + expect(() => reinstateInvite(inv.id, {})).toThrow(InviteValidationError); + + // Verify the txn rolled back: row still has maxUses=1 (no partial update) + const row = testDb.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, inv.id)).get(); + expect(row?.maxUses).toBe(1); + }); + + it('Path A — rolls back token rotation when caller did not bump enough', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: 1, expiresAt: null }, adminId); + const originalToken = inv.token; + // Exhaust then revoke + testDb.update(schema.inviteLinks).set({ usedCount: 1 }).where(eq(schema.inviteLinks.id, inv.id)).run(); + revokeInvite(inv.id); + + // Try to reinstate without bumping maxUses — would-be Path A but post-state check rejects + expect(() => reinstateInvite(inv.id, {})).toThrow(InviteValidationError); + + // Verify rollback: original token preserved, revokedAt still set, usedCount still at limit + const row = testDb.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, inv.id)).get(); + expect(row?.token).toBe(originalToken); + expect(row?.revokedAt).not.toBeNull(); + expect(row?.usedCount).toBe(1); + }); +}); + +describe('redeemInvite', () => { + beforeEach(() => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + }); + + it('happy path: increments usedCount, writes redemption row, calls insertUser callback', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: 5, expiresAt: null }, adminId); + const newUserId = 'new-user-1'; + const newUsername = 'alice'; + + let insertCalled = false; + const result = redeemInvite(inv.token, () => { + insertCalled = true; + // Caller's INSERT — uses the outer testDb handle (mocked getDb), which + // is correctly serialized into the same logical txn by better-sqlite3. + testDb.insert(schema.users).values({ + id: newUserId, + username: newUsername, + passwordHash: 'x', + createdAt: Date.now(), + }).run(); + return { id: newUserId, username: newUsername }; + }); + + expect(insertCalled).toBe(true); + expect(result.id).toBe(newUserId); + expect(result.username).toBe(newUsername); + + // user row exists + const user = testDb.select().from(schema.users).where(eq(schema.users.id, newUserId)).get(); + expect(user?.username).toBe(newUsername); + + // usedCount incremented + const row = testDb.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, inv.id)).get(); + expect(row?.usedCount).toBe(1); + + // redemption row written + const redemptions = testDb.select().from(schema.inviteRedemptions).where(eq(schema.inviteRedemptions.inviteId, inv.id)).all(); + expect(redemptions).toHaveLength(1); + expect(redemptions[0]?.userId).toBe(newUserId); + expect(redemptions[0]?.registrantUsername).toBe(newUsername); + }); + + it('throws InviteUnavailableError when status is not active under txn (exhausted)', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: 1, expiresAt: null }, adminId); + // Simulate "another concurrent registration consumed the last slot just before this call" + testDb.update(schema.inviteLinks).set({ usedCount: 1 }).where(eq(schema.inviteLinks.id, inv.id)).run(); + + let insertCalled = false; + expect(() => redeemInvite(inv.token, () => { + insertCalled = true; + return { id: 'x', username: 'x' }; + })).toThrow(InviteUnavailableError); + + expect(insertCalled).toBe(false); + // No redemption row + const redemptions = testDb.select().from(schema.inviteRedemptions).where(eq(schema.inviteRedemptions.inviteId, inv.id)).all(); + expect(redemptions).toHaveLength(0); + }); + + it('does not bump usedCount or write a redemption when insertUser throws synchronously', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: 5, expiresAt: null }, adminId); + + expect(() => redeemInvite(inv.token, () => { + throw new Error('username taken'); + })).toThrow('username taken'); + + // usedCount unchanged + const row = testDb.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, inv.id)).get(); + expect(row?.usedCount).toBe(0); + + // No redemption row + const redemptions = testDb.select().from(schema.inviteRedemptions).where(eq(schema.inviteRedemptions.inviteId, inv.id)).all(); + expect(redemptions).toHaveLength(0); + }); + + it('rolls back insertUser writes when a later step throws (true SQLite ROLLBACK)', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: 5, expiresAt: null }, adminId); + const newUserId = 'rollback-user-1'; + + // The callback writes a real users row, then throws AFTER the write. + // If the txn truly rolls back, the users row must not exist after the call. + expect(() => redeemInvite(inv.token, () => { + testDb.insert(schema.users).values({ + id: newUserId, + username: 'will-be-rolled-back', + passwordHash: 'x', + createdAt: Date.now(), + }).run(); + throw new Error('post-insert failure'); + })).toThrow('post-insert failure'); + + // Proves SQLite ROLLBACK reverted the user insert AND the would-be usedCount bump + const u = testDb.select().from(schema.users).where(eq(schema.users.id, newUserId)).get(); + expect(u).toBeUndefined(); + + const after = testDb.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, inv.id)).get(); + expect(after?.usedCount).toBe(0); + + const redemptions = testDb.select().from(schema.inviteRedemptions).where(eq(schema.inviteRedemptions.inviteId, inv.id)).all(); + expect(redemptions).toHaveLength(0); + }); + + it('throws InviteUnavailableError when token not found', () => { + expect(() => redeemInvite('aaaaaaaaaaaaaaaaaaaaaa', () => ({ id: 'x', username: 'x' }))).toThrow(InviteUnavailableError); + }); + + it('throws InviteUnavailableError on revoked invite', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: null, expiresAt: null }, adminId); + revokeInvite(inv.id); + + let insertCalled = false; + expect(() => redeemInvite(inv.token, () => { + insertCalled = true; + return { id: 'x', username: 'x' }; + })).toThrow(InviteUnavailableError); + + expect(insertCalled).toBe(false); + }); +}); + +describe('lastRedeemedAt', () => { + beforeEach(() => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + }); + + it('listInvites returns null lastRedeemedAt when invite has zero redemptions', () => { + const adminId = seedAdmin(); + createInvite({ name: 'no-redeems', maxUses: null, expiresAt: null }, adminId); + const list = listInvites('active'); + expect(list).toHaveLength(1); + expect(list[0]?.lastRedeemedAt).toBeNull(); + }); + + it('listInvites returns the max redeemed_at when invite has multiple redemptions', () => { + const adminId = seedAdmin(); + const invite = createInvite({ name: 'multi', maxUses: null, expiresAt: null }, adminId); + + const t1 = Date.now() - 2000; + const t2 = Date.now() - 1000; + const t3 = Date.now(); + testDb.insert(schema.users).values({ id: 'u1', username: 'alice', passwordHash: 'x', createdAt: t1 }).run(); + testDb.insert(schema.users).values({ id: 'u2', username: 'bob', passwordHash: 'x', createdAt: t2 }).run(); + testDb.insert(schema.users).values({ id: 'u3', username: 'carol', passwordHash: 'x', createdAt: t3 }).run(); + testDb.insert(schema.inviteRedemptions).values({ id: 'r1', inviteId: invite.id, userId: 'u1', registrantUsername: 'alice', redeemedAt: t1 }).run(); + testDb.insert(schema.inviteRedemptions).values({ id: 'r2', inviteId: invite.id, userId: 'u2', registrantUsername: 'bob', redeemedAt: t3 }).run(); + testDb.insert(schema.inviteRedemptions).values({ id: 'r3', inviteId: invite.id, userId: 'u3', registrantUsername: 'carol', redeemedAt: t2 }).run(); + + const list = listInvites('active'); + expect(list).toHaveLength(1); + expect(list[0]?.lastRedeemedAt).toBe(t3); + }); + + it('listInvites correctly attributes lastRedeemedAt to the right invite when multiple invites exist', () => { + const adminId = seedAdmin(); + const invA = createInvite({ name: 'A', maxUses: null, expiresAt: null }, adminId); + const invB = createInvite({ name: 'B', maxUses: null, expiresAt: null }, adminId); + + const tA = Date.now() - 5000; + const tB = Date.now() - 1000; + testDb.insert(schema.users).values({ id: 'uA', username: 'alice', passwordHash: 'x', createdAt: tA }).run(); + testDb.insert(schema.users).values({ id: 'uB', username: 'bob', passwordHash: 'x', createdAt: tB }).run(); + testDb.insert(schema.inviteRedemptions).values({ id: 'rA', inviteId: invA.id, userId: 'uA', registrantUsername: 'alice', redeemedAt: tA }).run(); + testDb.insert(schema.inviteRedemptions).values({ id: 'rB', inviteId: invB.id, userId: 'uB', registrantUsername: 'bob', redeemedAt: tB }).run(); + + const list = listInvites('active'); + const summaryA = list.find(i => i.id === invA.id); + const summaryB = list.find(i => i.id === invB.id); + expect(summaryA?.lastRedeemedAt).toBe(tA); + expect(summaryB?.lastRedeemedAt).toBe(tB); + }); + + it('getInviteByToken returns the raw row (not InviteLinkSummary) — lastRedeemedAt is not on the raw row type', () => { + // getInviteByToken intentionally returns the raw $inferSelect row for use + // by the registration flow, not an InviteLinkSummary. Confirm the raw row + // exists and can be fetched — the absence of lastRedeemedAt on the type is + // enforced by TypeScript at compile time. + const adminId = seedAdmin(); + const created = createInvite({ name: 'a', maxUses: null, expiresAt: null }, adminId); + const rawRow = getInviteByToken(created.token); + expect(rawRow).not.toBeNull(); + expect(rawRow?.id).toBe(created.id); + }); + + it('after redeemInvite, a subsequent listInvites reflects the updated lastRedeemedAt', () => { + const adminId = seedAdmin(); + const invite = createInvite({ name: 'redeem-test', maxUses: 5, expiresAt: null }, adminId); + + // Confirm null before any redemption + const before = listInvites('active'); + expect(before.find(i => i.id === invite.id)?.lastRedeemedAt).toBeNull(); + + const newUserId = 'redeemer-2'; + const tBefore = Date.now(); + redeemInvite(invite.token, () => { + testDb.insert(schema.users).values({ + id: newUserId, + username: 'redeemer2', + passwordHash: 'x', + createdAt: Date.now(), + }).run(); + return { id: newUserId, username: 'redeemer2' }; + }); + const tAfter = Date.now(); + + const after = listInvites('active'); + const summary = after.find(i => i.id === invite.id); + expect(summary?.lastRedeemedAt).not.toBeNull(); + expect(summary?.lastRedeemedAt).toBeGreaterThanOrEqual(tBefore); + expect(summary?.lastRedeemedAt).toBeLessThanOrEqual(tAfter); + }); +}); + +describe('deleteInvite', () => { + beforeEach(() => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + }); + + it('deletes the invite and CASCADE-removes redemption rows', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: null, expiresAt: null }, adminId); + + // Seed a redemption referencing this invite + const userId = 'redeemer-1'; + testDb.insert(schema.users).values({ + id: userId, username: 'redeemer', passwordHash: 'x', createdAt: Date.now(), + }).run(); + testDb.insert(schema.inviteRedemptions).values({ + id: 'red-del-1', + inviteId: inv.id, + userId, + registrantUsername: 'redeemer', + redeemedAt: Date.now(), + }).run(); + + deleteInvite(inv.id); + + // Invite row gone + const inviteRow = testDb.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, inv.id)).get(); + expect(inviteRow).toBeUndefined(); + + // Redemption row CASCADE-deleted + const redemptions = testDb.select().from(schema.inviteRedemptions).where(eq(schema.inviteRedemptions.inviteId, inv.id)).all(); + expect(redemptions).toHaveLength(0); + }); + + it('throws InviteNotFoundError when id not found', () => { + expect(() => deleteInvite('nonexistent')).toThrow(InviteNotFoundError); + }); +}); diff --git a/packages/server/src/utils/inviteService.ts b/packages/server/src/utils/inviteService.ts new file mode 100644 index 00000000..57cb1b1e --- /dev/null +++ b/packages/server/src/utils/inviteService.ts @@ -0,0 +1,600 @@ +import crypto from 'node:crypto'; +import { eq, desc, sql } from 'drizzle-orm'; +import { getDb, schema } from '../db/index.js'; +import { generateSnowflake } from './snowflake.js'; +import { config } from '../config.js'; +import type { + InviteLinkSummary, + CreateInviteRequest, + InviteRedemption, + UpdateInviteRequest, + ReinstateInviteRequest, + ReinstateInviteResponse, +} from '@backspace/shared'; + +/** + * Derived status of an invite link. Mirrors the `InviteStatus` union exported + * from `@backspace/shared` (kept side-by-side intentionally — the shared union + * defines the API contract, this local one drives internal service logic, and + * keeping them independent lets a drift surface as a real type error). + */ +export type InviteStatus = 'active' | 'expired' | 'exhausted' | 'revoked'; + +/** + * Minimal row shape needed to derive an invite's status. Matches the relevant + * columns of `invite_links` (revokedAt, expiresAt, maxUses, usedCount). + */ +export interface InviteStatusInput { + revokedAt: number | null; + expiresAt: number | null; + maxUses: number | null; + usedCount: number; +} + +/** + * Derive the current status of an invite from its row. Precedence order: + * revoked > expired > exhausted > active. A `maxUses` of `null` means + * unlimited; an `expiresAt` of `null` means no expiry. + */ +export function inviteStatus(row: InviteStatusInput): InviteStatus { + if (row.revokedAt !== null) return 'revoked'; + if (row.expiresAt !== null && row.expiresAt < Date.now()) return 'expired'; + if (row.maxUses !== null && row.usedCount >= row.maxUses) return 'exhausted'; + return 'active'; +} + +/** + * Generate a fresh invite token: 16 random bytes encoded as base64url, which + * yields a 22-character URL-safe string (16 * 8 / 6 = 21.33, rounded up; no + * `=` padding because base64url omits it). + */ +export function generateInviteToken(): string { + return crypto.randomBytes(16).toString('base64url'); +} + +/** + * Thrown by invite-service mutations when caller-supplied input violates a + * field rule (length, sign, ordering, etc.). Caller (HTTP route) maps this to + * a 400 Bad Request with the message as `error`. + */ +export class InviteValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'InviteValidationError'; + } +} + +/** + * Build the public-facing invite URL embedded in API responses. Production + * deployments always set `DOMAIN`; the localhost fallback is only used in + * local dev (where `config.host` is typically `0.0.0.0` and unusable as a + * URL host). Per spec §1.2 + §5.4 the server owns URL construction so + * clients never have to assemble it. + */ +function buildInviteUrl(token: string): string { + if (config.domain) return `https://${config.domain}/register?invite=${token}`; + return `http://localhost:${config.port}/register?invite=${token}`; +} + +/** + * Validate the invite name (1–64 chars after trim). Trimming is part of + * normalization so `" foo "` is stored as `"foo"`. + */ +function validateName(name: string): string { + const trimmed = (name ?? '').trim(); + if (trimmed.length < 1 || trimmed.length > 64) { + throw new InviteValidationError('Name must be 1-64 characters'); + } + return trimmed; +} + +/** + * Validate `maxUses`: `null` means unlimited; otherwise a positive integer. + * Zero is rejected because an invite that can never be used is meaningless + * (use revoke for that). + */ +function validateMaxUses(maxUses: number | null): number | null { + if (maxUses === null) return null; + if (!Number.isInteger(maxUses) || maxUses < 1) { + throw new InviteValidationError('maxUses must be a positive integer or null'); + } + return maxUses; +} + +/** + * Validate `expiresAt` (epoch ms). On create, must be in the future; on + * patch/reinstate, `allowPast` lets admins keep an unchanged past value or + * deliberately set a past expiry to soft-shut. `Date.now()` exactly is not + * "in the future" and is rejected when `allowPast` is false. + */ +function validateExpiresAt(expiresAt: number | null, allowPast: boolean): number | null { + if (expiresAt === null) return null; + if (!Number.isInteger(expiresAt)) { + throw new InviteValidationError('expiresAt must be an integer epoch ms or null'); + } + if (!allowPast && expiresAt <= Date.now()) { + throw new InviteValidationError('expiresAt must be in the future'); + } + return expiresAt; +} + +/** + * Folds a (username, isDeleted) pair into the display string used by + * InviteLinkSummary.createdByUsername / InviteRedemption.currentUsername. + * + * - null username → null (FK unresolvable; should be rare, defensive) + * - isDeleted=1 → 'Deleted User' (matches sanitizeUser convention) + * - else → username + */ +function foldUsername(username: string | null, isDeleted: number | null): string | null { + if (username === null) return null; + return isDeleted === 1 ? 'Deleted User' : username; +} + +/** + * Project an `invite_links` row plus the resolved creator-username and the + * most-recent redemption timestamp into the shared `InviteLinkSummary` shape. + * Centralized so list/create/patch/reinstate all return identically-shaped + * rows. Status is derived (never stored) per spec §2.1. + * + * `lastRedeemedAt` is `null` when the invite has zero redemptions. Callers + * that run inside a transaction pass the `MAX(redeemed_at)` they read inside + * the same transaction handle; `listInvites` bakes this into the SELECT + * projection as a correlated subquery. + */ +function rowToSummary( + row: typeof schema.inviteLinks.$inferSelect, + createdByUsername: string | null, + lastRedeemedAt: number | null, +): InviteLinkSummary { + return { + id: row.id, + token: row.token, + name: row.name, + status: inviteStatus(row), + maxUses: row.maxUses, + usedCount: row.usedCount, + expiresAt: row.expiresAt, + revokedAt: row.revokedAt, + createdBy: row.createdBy, + createdByUsername, + createdAt: row.createdAt, + lastRedeemedAt, + url: buildInviteUrl(row.token), + }; +} + +/** + * Query `MAX(redeemed_at)` for a single invite from the `invite_redemptions` + * table. Returns `null` when there are no redemption rows for this invite. + * + * Accepts an optional Drizzle handle so mutation callers inside a transaction + * body can read the max from the same logical transaction as their surrounding + * writes. Defaults to the outer `getDb()` for non-txn callers. + */ +function resolveLastRedeemedAt( + inviteId: string, + dbHandle: ReturnType = getDb(), +): number | null { + const result = dbHandle + .select({ maxAt: sql`MAX(${schema.inviteRedemptions.redeemedAt})` }) + .from(schema.inviteRedemptions) + .where(eq(schema.inviteRedemptions.inviteId, inviteId)) + .get(); + return result?.maxAt ?? null; +} + +/** + * Resolve the username to display for an invite's creator. Returns the live + * username, `'Deleted User'` for tombstoned accounts (spec §3.1, §4.1), or + * `null` if the FK is unresolvable (defensive — should not happen in practice). + * + * Accepts an optional Drizzle handle so callers inside a `db.transaction` + * body can pass the `tx` proxy and keep the read on the same logical txn as + * surrounding writes. Defaults to the outer `getDb()` for non-txn callers. + */ +function resolveCreatorUsername( + creatorId: string, + dbHandle: ReturnType = getDb(), +): string | null { + const u = dbHandle.select({ username: schema.users.username, isDeleted: schema.users.isDeleted }) + .from(schema.users) + .where(eq(schema.users.id, creatorId)) + .get(); + return foldUsername(u?.username ?? null, u?.isDeleted ?? null); +} + +/** + * Create a new invite link. Validates input, generates id + token, inserts the + * row, and returns the projected summary. Throws `InviteValidationError` on + * bad input (caller maps to 400). + */ +export function createInvite(req: CreateInviteRequest, creatorId: string): InviteLinkSummary { + const name = validateName(req.name); + const maxUses = validateMaxUses(req.maxUses); + const expiresAt = validateExpiresAt(req.expiresAt, false); + + const db = getDb(); + const id = generateSnowflake(); + const token = generateInviteToken(); + const now = Date.now(); + + db.insert(schema.inviteLinks).values({ + id, + token, + name, + createdBy: creatorId, + createdAt: now, + maxUses, + usedCount: 0, + expiresAt, + revokedAt: null, + }).run(); + + const row = db.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get(); + if (!row) throw new Error('Failed to insert invite'); + // Freshly created invite: zero redemptions, so lastRedeemedAt is always null. + return rowToSummary(row, resolveCreatorUsername(creatorId), null); +} + +/** + * Look up the raw `invite_links` row by token. Used by the registration flow + * (check-invite, register) — those sites do their own derived-status checks. + * The format guard short-circuits before hitting the DB to keep malformed + * tokens cheap. + */ +export function getInviteByToken(token: string): typeof schema.inviteLinks.$inferSelect | null { + if (typeof token !== 'string' || !/^[A-Za-z0-9_-]{22}$/.test(token)) return null; + const db = getDb(); + const row = db.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.token, token)).get(); + return row ?? null; +} + +/** + * List invites filtered by lifecycle state. `'active'` returns only rows whose + * derived status is `active`; `'archived'` returns rows in `expired`, + * `exhausted`, or `revoked`. The status is derived in TS (single source of + * truth: `inviteStatus()`), so we fetch all rows then filter — see spec §6.3 + * (no per-instance invite policy / janitor) for why this is acceptable at v1 + * scale; switch to a SQL-side filter only if instances accumulate thousands of + * invites. The LEFT JOIN against `users` resolves `createdByUsername` in a + * single query, avoiding the N+1 the spec calls out (§3.1). + */ +export function listInvites(filter: 'active' | 'archived'): InviteLinkSummary[] { + const db = getDb(); + const rows = db.select({ + invite: schema.inviteLinks, + creatorUsername: schema.users.username, + creatorIsDeleted: schema.users.isDeleted, + lastRedeemedAt: sql`(SELECT MAX(${schema.inviteRedemptions.redeemedAt}) FROM ${schema.inviteRedemptions} WHERE ${schema.inviteRedemptions.inviteId} = ${schema.inviteLinks.id})`, + }) + .from(schema.inviteLinks) + .leftJoin(schema.users, eq(schema.inviteLinks.createdBy, schema.users.id)) + .orderBy(desc(schema.inviteLinks.createdAt)) + .all(); + + const summaries = rows.map(({ invite, creatorUsername, creatorIsDeleted, lastRedeemedAt }) => { + const username = foldUsername(creatorUsername, creatorIsDeleted); + return rowToSummary(invite, username, lastRedeemedAt); + }); + + if (filter === 'active') return summaries.filter(s => s.status === 'active'); + return summaries.filter(s => s.status !== 'active'); +} + +/** + * List redemptions for one invite, newest first. The LEFT JOIN against `users` + * via `userId` surfaces the live username so the UI can render + * "registered as alice (now Anastasia)" — the snapshot in `registrantUsername` + * stays forensically stable while `currentUsername` reflects the live state. + * + * Three null-handling branches per spec §3.1: + * - live user → `currentUsername = users.username`, `isDeleted = false` + * - tombstoned user → `currentUsername = 'Deleted User'`, `isDeleted = true` + * - hard-deleted user → `userId = null`, `currentUsername = null`, + * `isDeleted = false` (the row is genuinely gone, not + * soft-deleted; "Deleted User" would be misleading) + */ +export function listRedemptions(inviteId: string): InviteRedemption[] { + const db = getDb(); + const rows = db.select({ + redemption: schema.inviteRedemptions, + currentUsername: schema.users.username, + currentIsDeleted: schema.users.isDeleted, + }) + .from(schema.inviteRedemptions) + .leftJoin(schema.users, eq(schema.inviteRedemptions.userId, schema.users.id)) + .where(eq(schema.inviteRedemptions.inviteId, inviteId)) + .orderBy(desc(schema.inviteRedemptions.redeemedAt)) + .all(); + + return rows.map(({ redemption, currentUsername, currentIsDeleted }) => ({ + id: redemption.id, + userId: redemption.userId, + registrantUsername: redemption.registrantUsername, + currentUsername: redemption.userId === null ? null : foldUsername(currentUsername, currentIsDeleted), + isDeleted: currentIsDeleted === 1, + redeemedAt: redemption.redeemedAt, + })); +} + +/** + * Thrown when a mutation targets an invite id that does not exist. Caller + * (HTTP route) maps this to 404 Not Found. + */ +export class InviteNotFoundError extends Error { + constructor() { + super('Invite not found'); + this.name = 'InviteNotFoundError'; + } +} + +/** + * Thrown when a mutation is rejected because the invite's current state + * forbids it (e.g. patching a revoked invite, double-revoking). Caller + * (HTTP route) maps this to 409 Conflict; the message is the user-facing + * copy that surfaces in the toast. + */ +export class InviteStateConflictError extends Error { + constructor(message: string) { + super(message); + this.name = 'InviteStateConflictError'; + } +} + +/** + * Patch an existing invite's mutable fields. Wrapped in a SQLite transaction + * with an in-txn re-fetch so concurrent admin edits are serialized: the + * second writer sees the first writer's committed state and either applies + * its own delta on top or rejects (e.g. observed-revoked). + * + * Validation rules per spec §3.1: + * - 404 if id not found. + * - 409 if invite is currently revoked (must reinstate first to modify). + * - 400 if maxUses would drop below current usedCount (would retroactively + * exhaust — confusing; admin should use revoke instead). + * - expiresAt may be moved into the past (effective soft-shut → status + * flips to 'expired' on next read). + * + * An empty patch body is a no-op that returns the current summary unchanged. + */ +export function patchInvite(id: string, req: UpdateInviteRequest): InviteLinkSummary { + const db = getDb(); + return db.transaction((tx) => { + const row = tx.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get(); + if (!row) throw new InviteNotFoundError(); + if (row.revokedAt !== null) { + throw new InviteStateConflictError('Invite is revoked. Reinstate first to modify.'); + } + + const updates: Partial = {}; + if (req.name !== undefined) updates.name = validateName(req.name); + if (req.maxUses !== undefined) { + const v = validateMaxUses(req.maxUses); + if (v !== null && v < row.usedCount) { + throw new InviteValidationError( + `maxUses (${v}) cannot be less than current usedCount (${row.usedCount})`, + ); + } + updates.maxUses = v; + } + if (req.expiresAt !== undefined) { + updates.expiresAt = validateExpiresAt(req.expiresAt, true); + } + + if (Object.keys(updates).length === 0) { + // No-op: just return current summary + return rowToSummary(row, resolveCreatorUsername(row.createdBy, tx), resolveLastRedeemedAt(row.id, tx)); + } + + tx.update(schema.inviteLinks).set(updates).where(eq(schema.inviteLinks.id, id)).run(); + const updated = tx.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get(); + if (!updated) throw new Error('Failed to read updated invite'); + return rowToSummary(updated, resolveCreatorUsername(updated.createdBy, tx), resolveLastRedeemedAt(updated.id, tx)); + }); +} + +/** + * Revoke an invite. Wrapped in a SQLite transaction with an in-txn re-fetch + * so concurrent revokes are serialized: the first wins, the second sees + * `revokedAt !== null` and throws `InviteStateConflictError` (mapped to 409 + * by the route — explicit rejection rather than silent no-op, per spec §3.1). + * + * Token is preserved on revoke; reinstate-from-revoked rotates the token as + * a security boundary (handled in `reinstateInvite`, not here). + */ +export function revokeInvite(id: string): InviteLinkSummary { + const db = getDb(); + return db.transaction((tx) => { + const row = tx.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get(); + if (!row) throw new InviteNotFoundError(); + if (row.revokedAt !== null) { + throw new InviteStateConflictError('Invite is already revoked'); + } + tx.update(schema.inviteLinks).set({ revokedAt: Date.now() }).where(eq(schema.inviteLinks.id, id)).run(); + const updated = tx.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get(); + if (!updated) throw new Error('Failed to read updated invite'); + return rowToSummary(updated, resolveCreatorUsername(updated.createdBy, tx), resolveLastRedeemedAt(updated.id, tx)); + }); +} + +/** + * Reinstate a non-active invite back to `active`. Three branches per spec §3.1: + * + * - **Path A (revoked)**: rotates the token (security boundary — old shared + * links must stop working) and clears `revokedAt`. Caller may also bump + * `maxUses` / `expiresAt` in the same call. + * - **Path B (expired/exhausted)**: preserves the token. Caller MUST supply + * bumps that push the row back into derived `active` state, otherwise the + * txn rolls back with `InviteValidationError` (we never leave an invite + * half-reinstated, e.g. exhausted-and-still-exhausted with no token rotation + * and no state change). + * - **Path C (already active)**: rejected with `InviteStateConflictError` + * (mapped to 409). Reinstating an active invite is meaningless and would + * surprise an admin who clicked the wrong row. + * + * Wrapped in a SQLite transaction with an in-txn re-read so the post-update + * status check sees the row as the next reader would. If the post-state isn't + * `active`, the throw aborts the txn and the row reverts. + */ +export function reinstateInvite(id: string, req: ReinstateInviteRequest): ReinstateInviteResponse { + const db = getDb(); + return db.transaction((tx) => { + const row = tx.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get(); + if (!row) throw new InviteNotFoundError(); + + const currentStatus = inviteStatus(row); + if (currentStatus === 'active') { + throw new InviteStateConflictError('Invite is already active'); + } + + const updates: Partial = {}; + let tokenRotated = false; + + if (currentStatus === 'revoked') { + updates.revokedAt = null; + updates.token = generateInviteToken(); + tokenRotated = true; + } + + if (req.maxUses !== undefined) { + const v = validateMaxUses(req.maxUses); + if (v !== null && v < row.usedCount) { + throw new InviteValidationError( + `maxUses (${v}) cannot be less than current usedCount (${row.usedCount})`, + ); + } + updates.maxUses = v; + } + if (req.expiresAt !== undefined) { + updates.expiresAt = validateExpiresAt(req.expiresAt, true); + } + + // Skip the UPDATE entirely when there's nothing to set — Drizzle throws + // 'No values to set' before our post-state validator can produce the + // user-facing InviteValidationError. Path C (already active) handles its + // rejection above, so an empty updates map only reaches here when the + // caller didn't provide bumps for an expired/exhausted invite — the + // post-state check below will throw the correct error in that case. + if (Object.keys(updates).length > 0) { + tx.update(schema.inviteLinks).set(updates).where(eq(schema.inviteLinks.id, id)).run(); + } + + const updated = tx.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get(); + if (!updated) throw new Error('Failed to read updated invite'); + + if (inviteStatus(updated) !== 'active') { + // Caller did not bump enough — abort the txn so nothing is half-applied + throw new InviteValidationError( + 'Reinstate would leave invite in non-active state. Bump maxUses and/or expiresAt.', + ); + } + + return { + invite: rowToSummary(updated, resolveCreatorUsername(updated.createdBy, tx), resolveLastRedeemedAt(updated.id, tx)), + tokenRotated, + }; + }); +} + +/** + * Discriminant union of reasons an invite cannot be redeemed. Surfaced as a + * typed public field on `InviteUnavailableError` so the HTTP register route + * can switch on it to produce user-facing copy without parsing the message + * string. Mirrors the non-active subset of `InviteStatus` plus `'not found'` + * for the missing-token case. + */ +export type InviteUnavailableReason = 'not found' | 'revoked' | 'expired' | 'exhausted'; + +/** + * Thrown when an invite cannot be redeemed because its current state forbids + * it (token not found, revoked, expired, exhausted). Caller (HTTP register + * route) maps this to 403 Forbidden. The `reason` field is the structured + * discriminant; the message string is preserved for debugging/logging. + */ +export class InviteUnavailableError extends Error { + constructor(public readonly reason: InviteUnavailableReason) { + super(`Invite unavailable: ${reason}`); + this.name = 'InviteUnavailableError'; + } +} + +/** + * Result returned by the `insertUser` callback to `redeemInvite`. Captures + * just the fields needed to write the redemption row (id for the FK, + * username for the forensic snapshot in `registrant_username`). + */ +export interface RedemptionUserResult { + id: string; + username: string; +} + +/** + * Atomically redeem an invite token. The caller-supplied `insertUser` callback + * runs inside the same SQLite transaction as the usedCount increment + redemption + * insert. If insertUser throws, the entire transaction rolls back — the invite + * is NOT consumed for failed registrations (e.g. username uniqueness collisions). + * + * Re-derives status under the transaction to close the TOCTOU window between + * `/api/auth/check-invite` (which the client may call seconds before submit) + * and the actual register POST: another user could have consumed the last slot + * in between. Re-checking inside the txn ensures the slot we increment is the + * one we observed available. + */ +export function redeemInvite( + token: string, + insertUser: () => RedemptionUserResult, +): RedemptionUserResult { + const db = getDb(); + return db.transaction((tx) => { + const row = tx.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.token, token)).get(); + if (!row) throw new InviteUnavailableError('not found'); + const status = inviteStatus(row); + if (status !== 'active') { + // 'active' is excluded by the guard above, so `status` is necessarily + // one of 'revoked' | 'expired' | 'exhausted' — all valid + // InviteUnavailableReason values. TS narrows the union here. + throw new InviteUnavailableError(status); + } + + // The insertUser callback runs inside the transaction. The caller's INSERT + // statement uses the outer `db` connection, but better-sqlite3 serializes + // all writes regardless of which Drizzle handle issued them, so the user + // insert joins the same atomic unit. If insertUser throws, the entire + // transaction rolls back including the usedCount bump and redemption row. + const userResult = insertUser(); + + tx.update(schema.inviteLinks) + .set({ usedCount: row.usedCount + 1 }) + .where(eq(schema.inviteLinks.id, row.id)) + .run(); + + tx.insert(schema.inviteRedemptions).values({ + id: generateSnowflake(), + inviteId: row.id, + userId: userResult.id, + registrantUsername: userResult.username, + redeemedAt: Date.now(), + }).run(); + + return userResult; + }); +} + +/** + * Permanently delete an invite. Redemption rows for this invite are removed + * via `ON DELETE CASCADE` on `invite_redemptions.invite_id` — this is the + * documented destructive intent of "delete the invite and its history". + * + * No transaction needed: deleteInvite has no read-modify-write state semantics + * that other concurrent mutators would race against. The existence check is + * for the 404 response only; if a concurrent process deletes the row between + * the SELECT and the DELETE, the DELETE is a harmless no-op and the caller + * still observes the row gone afterwards. + */ +export function deleteInvite(id: string): void { + const db = getDb(); + const row = db.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get(); + if (!row) throw new InviteNotFoundError(); + db.delete(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).run(); +} diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index bf7325e1..f979fdd7 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -482,6 +482,7 @@ export interface RegisterRequest { avatarColor?: string; homeInstance?: string; homeUserId?: string; + inviteToken?: string; } export interface LoginRequest { @@ -681,6 +682,7 @@ export interface GifResult { export interface InstanceAdminSettings { instanceName: string; registrationOpen: boolean; + federatedRegistrationOpen: boolean; discoveryEnabled: boolean; gifApiKey?: string; gifEnabled?: boolean; @@ -710,6 +712,7 @@ export interface InstanceInfoResponse { name: string; version: string; registrationOpen: boolean; + federatedRegistrationOpen: boolean; } export interface VerifyPasswordRequest { @@ -1129,3 +1132,70 @@ export interface ApprovalRequest { */ subscribers?: ApprovalRequestSubscriberSummary[]; } + +// ─── Invite Links ────────────────────────────────────────────────────────── + +/** Derived status of an invite link. Active = usable; expired/exhausted/revoked = archived. */ +export type InviteStatus = 'active' | 'expired' | 'exhausted' | 'revoked'; + +export interface InviteLinkSummary { + id: string; + token: string; + name: string; + status: InviteStatus; + maxUses: number | null; + usedCount: number; + expiresAt: number | null; + revokedAt: number | null; + createdBy: string; + /** Joined from users.username at read time. `'Deleted User'` when the creator's account is tombstoned. `null` only if the FK is somehow unresolvable (defensive). */ + createdByUsername: string | null; + createdAt: number; + /** Epoch ms of the most recent redemption; `null` when the invite has zero redemptions. */ + lastRedeemedAt: number | null; + /** Server-constructed full URL, e.g. `https://host.example/register?invite=`. Clients must NOT assemble this themselves. */ + url: string; +} + +export interface InviteRedemption { + id: string; + userId: string | null; + registrantUsername: string; + currentUsername: string | null; + isDeleted: boolean; + redeemedAt: number; +} + +export interface CreateInviteRequest { + name: string; + maxUses: number | null; + expiresAt: number | null; +} + +export interface UpdateInviteRequest { + name?: string; + maxUses?: number | null; + expiresAt?: number | null; +} + +export interface ReinstateInviteRequest { + maxUses?: number | null; + expiresAt?: number | null; +} + +export interface ReinstateInviteResponse { + invite: InviteLinkSummary; + tokenRotated: boolean; +} + +export interface CheckInviteValidResponse { + valid: true; + name: string; +} + +export interface CheckInviteInvalidResponse { + valid: false; + reason: 'expired' | 'exhausted' | 'invalid'; +} + +export type CheckInviteResponse = CheckInviteValidResponse | CheckInviteInvalidResponse; diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index d80d49e0..d6f2927f 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -56,6 +56,13 @@ import type { ApprovalRequest, PeeringSubscription, PeeringNotification, + InviteLinkSummary, + InviteRedemption, + CreateInviteRequest, + UpdateInviteRequest, + ReinstateInviteRequest, + ReinstateInviteResponse, + CheckInviteResponse, } from '@backspace/shared'; export type { FederationPeer, ApprovalRequest, PeeringSubscription, PeeringNotification }; @@ -74,6 +81,7 @@ export class BackspaceApiClient { register: (data: RegisterRequest) => Promise; login: (data: LoginRequest) => Promise; checkUsername: (username: string) => Promise<{ available: boolean; reason?: string }>; + checkInvite: (token: string) => Promise; }; readonly users: { @@ -232,6 +240,16 @@ export class BackspaceApiClient { markAllPeeringNotificationsRead: () => Promise<{ success: boolean; count: number }>; }; + readonly invites: { + list: (status?: 'active' | 'archived') => Promise<{ invites: InviteLinkSummary[] }>; + create: (body: CreateInviteRequest) => Promise; + update: (id: string, body: UpdateInviteRequest) => Promise; + revoke: (id: string) => Promise<{ invite: InviteLinkSummary }>; + reinstate: (id: string, body: ReinstateInviteRequest) => Promise; + delete: (id: string) => Promise<{ success: boolean }>; + redemptions: (id: string) => Promise<{ redemptions: InviteRedemption[] }>; + }; + readonly admin: { storageStats: () => Promise; storageOrphans: () => Promise<{ orphans: OrphanedFile[] }>; @@ -393,6 +411,8 @@ export class BackspaceApiClient { request('POST', '/auth/login', data, false), checkUsername: (username: string) => request<{ available: boolean; reason?: string }>('GET', `/auth/check-username?username=${encodeURIComponent(username)}`, undefined, false), + checkInvite: (token: string) => + request('GET', `/auth/check-invite?token=${encodeURIComponent(token)}`, undefined, false), }; this.users = { @@ -720,6 +740,23 @@ export class BackspaceApiClient { ), }; + this.invites = { + list: (status: 'active' | 'archived' = 'active') => + request<{ invites: InviteLinkSummary[] }>('GET', `/admin/invites?status=${status}`), + create: (body: CreateInviteRequest) => + request('POST', '/admin/invites', body), + update: (id: string, body: UpdateInviteRequest) => + request('PATCH', `/admin/invites/${id}`, body), + revoke: (id: string) => + request<{ invite: InviteLinkSummary }>('POST', `/admin/invites/${id}/revoke`), + reinstate: (id: string, body: ReinstateInviteRequest) => + request('POST', `/admin/invites/${id}/reinstate`, body), + delete: (id: string) => + request<{ success: boolean }>('DELETE', `/admin/invites/${id}`), + redemptions: (id: string) => + request<{ redemptions: InviteRedemption[] }>('GET', `/admin/invites/${id}/redemptions`), + }; + this.admin = { storageStats: () => request('GET', '/admin/storage/stats'), storageOrphans: () => request<{ orphans: OrphanedFile[] }>('GET', '/admin/storage/orphans'), diff --git a/packages/web/src/components/auth/RegisterPage.tsx b/packages/web/src/components/auth/RegisterPage.tsx index 9b0fc9ce..d8e7bf45 100644 --- a/packages/web/src/components/auth/RegisterPage.tsx +++ b/packages/web/src/components/auth/RegisterPage.tsx @@ -5,9 +5,13 @@ import { Avatar } from '../ui/Avatar'; import { ImageCropModal } from '../ui/ImageCropModal'; import { AVATAR_GRADIENT_MAP } from '../../utils/gradients'; import { AVATAR_COLORS } from '@backspace/shared'; -import type { AvatarColor } from '@backspace/shared'; +import type { AvatarColor, CheckInviteResponse, InstanceInfoResponse } from '@backspace/shared'; import { api, RateLimitError } from '../../api/client'; +// Single-source regex for extracting a bare invite token from a pasted full URL. +// Token format: 22 chars base64url ([A-Za-z0-9_-]). +const INVITE_URL_REGEX = /[?&]invite=([A-Za-z0-9_-]{22})/; + type UsernameStatus = 'idle' | 'checking' | 'available' | 'taken' | 'invalid'; export function RegisterPage() { @@ -26,6 +30,19 @@ export function RegisterPage() { const usernameCheckTimerRef = useRef | null>(null); const usernameCheckAbortRef = useRef(null); + // Instance info (for registration policy) + const [instanceInfo, setInstanceInfo] = useState(null); + + // Invite token state + const [manualInviteToken, setManualInviteToken] = useState(''); + const [inviteCheck, setInviteCheck] = useState(null); + const [inviteChecking, setInviteChecking] = useState(false); + const inviteCheckTimerRef = useRef | null>(null); + // Ref tracking whether the URL token has been confirmed invalid by the server. + // Used as the gate for the manual-entry debounce so we don't need inviteCheck?.valid + // in the manual-effect dep array (which would cause a dep loop via setInviteCheck). + const urlTokenInvalidRef = useRef(false); + // Step 2 fields const [displayName, setDisplayName] = useState(''); const [avatarColor, setAvatarColor] = useState( @@ -44,6 +61,7 @@ export function RegisterPage() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const redirect = searchParams.get('redirect'); + const urlInviteToken = searchParams.get('invite'); // Cleanup blob URL on unmount useEffect(() => { @@ -52,6 +70,104 @@ export function RegisterPage() { }; }, [avatarPreview]); + // Fetch instance info to determine registration policy + useEffect(() => { + let cancelled = false; + api.instance.info() + .then((info) => { if (!cancelled) setInstanceInfo(info); }) + .catch(() => { + // Leave null — treat as open by default to avoid soft-locking the page. + // Server-side validation (Task 11) is the real gate. + }); + return () => { cancelled = true; }; + }, []); + + // Validate URL-supplied invite token — only fires when: + // (a) a URL token is present, AND + // (b) instanceInfo has loaded AND indicates registration is closed. + // Per spec §4.4: when registration is open, the URL invite param is silently ignored + // (no token consumption, no validation, no rate-limit slot burned). + // When the result is invalid, urlTokenInvalidRef is set so the manual-entry + // debounce effect can use it as a stable gate without introducing a dep loop. + useEffect(() => { + if (!urlInviteToken) return; + if (!instanceInfo || instanceInfo.registrationOpen) return; + let cancelled = false; + urlTokenInvalidRef.current = false; + setInviteChecking(true); + api.auth.checkInvite(urlInviteToken) + .then((res) => { + if (!cancelled) { + if (!res.valid) urlTokenInvalidRef.current = true; + setInviteCheck(res); + } + }) + .catch(() => { + if (!cancelled) { + urlTokenInvalidRef.current = true; + setInviteCheck({ valid: false, reason: 'invalid' }); + } + }) + .finally(() => { if (!cancelled) setInviteChecking(false); }); + return () => { cancelled = true; }; + }, [urlInviteToken, instanceInfo]); + + // Debounced manual-entry invite validation. + // The URL token takes precedence while it is still in-flight or has been confirmed valid. + // Once the URL token is confirmed invalid (urlTokenInvalidRef.current === true), this + // effect fires on manual input changes. + // + // We intentionally do NOT include inviteCheck?.valid in the dep array — the URL-token + // validation effect sets urlTokenInvalidRef synchronously when the server response arrives, + // and the user's next keystroke in the manual field re-triggers this effect. This avoids + // a dep-loop where setInviteCheck() inside this effect would mutate a dep and cause + // infinite re-runs. + useEffect(() => { + if (urlInviteToken && !urlTokenInvalidRef.current) return; // URL token takes precedence while in flight or valid + + const trimmed = manualInviteToken.trim(); + if (!trimmed) { + setInviteCheck(null); + setInviteChecking(false); + return; + } + + // Extract bare token if user pasted a full URL + let token = trimmed; + const urlMatch = trimmed.match(INVITE_URL_REGEX); + if (urlMatch) token = urlMatch[1]!; + + let cancelled = false; + + if (inviteCheckTimerRef.current) clearTimeout(inviteCheckTimerRef.current); + + // Set checking=true immediately so the manual-entry row shows "Checking..." rather + // than the stale URL-token failure state while the user is actively typing. + setInviteChecking(true); + + inviteCheckTimerRef.current = setTimeout(async () => { + if (cancelled) return; + // Clear stale check result (e.g., prior URL-token invalid result) before the + // fresh API response arrives so stale text never briefly flashes on completion. + setInviteCheck(null); + try { + const res = await api.auth.checkInvite(token); + if (cancelled) return; + setInviteCheck(res); + } catch { + if (cancelled) return; + setInviteCheck({ valid: false, reason: 'invalid' }); + } finally { + if (!cancelled) setInviteChecking(false); + } + }, 500); + + return () => { + cancelled = true; + if (inviteCheckTimerRef.current) clearTimeout(inviteCheckTimerRef.current); + }; + }, [manualInviteToken, urlInviteToken]); + // Debounced username availability check useEffect(() => { // Clear previous timer and abort @@ -128,6 +244,17 @@ export function RegisterPage() { return () => clearInterval(timer); }, [retryAfter]); + // ── Invite requirements ── + const inviteRequired = instanceInfo !== null && !instanceInfo.registrationOpen; + const inviteValid = inviteRequired ? inviteCheck?.valid === true : true; + + // Show the manual-entry container when: + // - Registration is closed AND + // - There is no URL token, OR the URL token has already been validated as invalid + const showManualEntry = instanceInfo !== null + && !instanceInfo.registrationOpen + && (!urlInviteToken || inviteCheck?.valid === false); + // ── Step 1 validation ── const handleContinue = (e: React.FormEvent) => { e.preventDefault(); @@ -192,10 +319,35 @@ export function RegisterPage() { const dn = skip ? undefined : displayName.trim() || undefined; const ac = skip ? undefined : avatarColor; + // Resolve the token to send. + // Per spec §4.4 / §3: open-registration instances silently ignore invite tokens — + // sending one would be harmless (server ignores it) but it's cleaner to omit it + // client-side so nothing unexpected is on the wire. + const tokenForRegister = (() => { + if (!instanceInfo || instanceInfo.registrationOpen) { + return undefined; + } + // URL token takes precedence ONLY while it hasn't been confirmed invalid. + const urlInvalid = !!urlInviteToken && inviteCheck?.valid === false; + if (urlInviteToken && !urlInvalid) return urlInviteToken; + const trimmed = manualInviteToken.trim(); + if (trimmed) { + const urlMatch = trimmed.match(INVITE_URL_REGEX); + return urlMatch ? urlMatch[1] : trimmed; + } + // Manual empty AND URL token was confirmed invalid — send URL token so the + // server returns the authoritative error message rather than "invite required". + return urlInviteToken ?? undefined; + })(); + // Step 1: Register via API — store token in localStorage for API auth, // but NOT in Zustand yet so AuthRedirect doesn't fire prematurely const response = await api.auth.register({ - username: username.trim(), password, displayName: dn, avatarColor: ac, + username: username.trim(), + password, + displayName: dn, + avatarColor: ac, + ...(tokenForRegister ? { inviteToken: tokenForRegister } : {}), }); localStorage.setItem('backspace_token', response.token); @@ -235,6 +387,13 @@ export function RegisterPage() { const isDisabled = isRegistering || retryAfter > 0; + // Continue button is blocked while username is invalid/taken OR when an invite is required + // but not yet validated as valid + const continueDisabled = + usernameStatus === 'taken' || + usernameStatus === 'invalid' || + (inviteRequired && !inviteValid); + return (
@@ -258,6 +417,75 @@ export function RegisterPage() {
)} + {/* Closed-registration invite entry — shown when registration is closed and: + (a) no URL token is present, or (b) the URL token has already failed validation */} + {showManualEntry && ( +
+
+ Registration is invite-only on this instance. Paste your invite link or enter the code below. +
+ setManualInviteToken(e.target.value)} + placeholder="Invite code or link" + className="input-standard w-full px-3 py-2 text-sm" + aria-label="Invite code or link" + autoComplete="off" + /> + {inviteChecking && ( +
Checking...
+ )} + {!inviteChecking && inviteCheck?.valid === true && ( +
+ + + + Valid invite: {inviteCheck.name} +
+ )} + {!inviteChecking && inviteCheck?.valid === false && ( +
+ {inviteCheck.reason === 'expired' && 'This invite link has expired. Ask the admin for a new one.'} + {inviteCheck.reason === 'exhausted' && 'This invite has reached its usage limit. Ask the admin to extend it.'} + {inviteCheck.reason === 'invalid' && 'Invalid invite code.'} +
+ )} +
+ )} + + {/* URL-token chip — shown only when registration is closed AND a URL token was provided. + Per spec §4.4: open-registration instances silently ignore the ?invite= param. */} + {urlInviteToken && instanceInfo && !instanceInfo.registrationOpen && ( +
+ {inviteChecking ? ( + <> + + + + + Validating invite... + + ) : inviteCheck?.valid === true ? ( + <> + + + + Using invite: {inviteCheck.name} + + ) : inviteCheck?.valid === false ? ( + <> + + + + Invalid invite link — please request a new one + + ) : ( + <>Validating invite... + )} +
+ )} +
+ {!probeResult.federatedRegistrationOpen && ( +
+ This instance has disabled new federated registrations. Existing accounts can still sign in. +
+ )} +
{ e.preventDefault(); handleConnect(); }} className="space-y-2">
diff --git a/packages/web/src/components/modals/instanceSettingsPanels/GeneralPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/GeneralPanel.tsx index 0c1d9b60..c8661997 100644 --- a/packages/web/src/components/modals/instanceSettingsPanels/GeneralPanel.tsx +++ b/packages/web/src/components/modals/instanceSettingsPanels/GeneralPanel.tsx @@ -28,7 +28,6 @@ export function GeneralPanel() { const baseChanges = instanceSettings && draft ? draft.instanceName !== instanceSettings.instanceName || - draft.registrationOpen !== instanceSettings.registrationOpen || draft.discoveryEnabled !== instanceSettings.discoveryEnabled : false; const hasChanges = baseChanges || gifKeyDirty; @@ -39,7 +38,6 @@ export function GeneralPanel() { try { const payload: Partial = { instanceName: draft!.instanceName, - registrationOpen: draft!.registrationOpen, discoveryEnabled: draft!.discoveryEnabled, }; if (gifKeyDirty) { @@ -86,20 +84,6 @@ export function GeneralPanel() {
- {/* Registration */} -
-
Registration
-
- -
-
- {/* Discovery */}
Discovery
diff --git a/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx new file mode 100644 index 00000000..82dcbec6 --- /dev/null +++ b/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx @@ -0,0 +1,1827 @@ +import { createPortal } from 'react-dom'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { InviteLinkSummary, InviteRedemption, InviteStatus } from '@backspace/shared'; +import { api } from '../../../api/client'; +import { useSettingsStore } from '../../../stores/settingsStore'; +import { useUIStore } from '../../../stores/uiStore'; +import { Toggle } from '../../ui/Toggle'; +import { ConfirmDialog } from '../../ui/ConfirmDialog'; + +interface RegistrationDraft { + registrationOpen: boolean; + federatedRegistrationOpen: boolean; +} + +function formatRelative(ms: number): string { + const diff = Date.now() - ms; + const days = Math.floor(diff / 86_400_000); + if (days >= 1) return `${days}d ago`; + const hours = Math.floor(diff / 3_600_000); + if (hours >= 1) return `${hours}h ago`; + const mins = Math.floor(diff / 60_000); + if (mins >= 1) return `${mins}m ago`; + return 'just now'; +} + +function formatExpiry(invite: InviteLinkSummary): string { + if (invite.status === 'revoked' && invite.revokedAt) { + return `Revoked ${new Date(invite.revokedAt).toLocaleDateString()}`; + } + if (invite.status === 'expired' && invite.expiresAt) { + return `Expired ${new Date(invite.expiresAt).toLocaleDateString()}`; + } + if (invite.status === 'exhausted') { + return 'Exhausted'; + } + if (invite.expiresAt === null) return 'No expiration'; + const remaining = invite.expiresAt - Date.now(); + if (remaining <= 0) return `Expired ${new Date(invite.expiresAt).toLocaleDateString()}`; + const days = Math.floor(remaining / 86_400_000); + if (days >= 1) return `Expires in ${days} day${days === 1 ? '' : 's'}`; + const hours = Math.floor(remaining / 3_600_000); + return `Expires in ${hours}h`; +} + +function inviteStatusDotColor(status: InviteStatus): string { + switch (status) { + case 'active': return 'bg-status-online'; + case 'expired': return 'bg-accent-rose'; + case 'exhausted': return 'bg-accent-amber'; + case 'revoked': return 'bg-txt-tertiary'; + } +} + +function inviteStatusPillColor(status: InviteStatus): string { + switch (status) { + case 'expired': return 'bg-accent-rose/15 text-accent-rose'; + case 'exhausted': return 'bg-accent-amber/15 text-accent-amber'; + case 'revoked': return 'bg-white/5 text-txt-tertiary'; + case 'active': return ''; // never rendered for active + } +} + +function inviteStatusLabel(status: InviteStatus): string { + switch (status) { + case 'active': return 'Active'; + case 'expired': return 'Expired'; + case 'exhausted': return 'Exhausted'; + case 'revoked': return 'Revoked'; + } +} + +// --------------------------------------------------------------------------- +// Sort / filter types +// --------------------------------------------------------------------------- + +type ActiveSort = 'recent' | 'oldest' | 'name' | 'mostUsed' | 'expiringSoonest'; +type ArchivedSort = 'recent' | 'oldest' | 'name'; +type ArchivedStatus = 'expired' | 'exhausted' | 'revoked'; + +// --------------------------------------------------------------------------- +// Sort / filter pure functions +// Sort/filter applied client-side. At ~500+ invites in a single bucket, +// move to server-side: add `?sort=` and `?status=` params to /admin/invites, +// page through results. Today the test instances have <50 invites total. +// --------------------------------------------------------------------------- + +function sortInvites( + list: InviteLinkSummary[], + sortKey: ActiveSort | ArchivedSort, +): InviteLinkSummary[] { + const arr = [...list]; + switch (sortKey) { + case 'recent': + return arr.sort((a, b) => b.createdAt - a.createdAt); + case 'oldest': + return arr.sort((a, b) => a.createdAt - b.createdAt); + case 'name': + return arr.sort((a, b) => a.name.localeCompare(b.name)); + case 'mostUsed': + return arr.sort((a, b) => b.usedCount - a.usedCount); + case 'expiringSoonest': + return arr.sort((a, b) => { + // null expiresAt (no expiration) sorts last + if (a.expiresAt === null && b.expiresAt === null) return 0; + if (a.expiresAt === null) return 1; + if (b.expiresAt === null) return -1; + return a.expiresAt - b.expiresAt; + }); + } +} + +function filterInvitesByStatus( + list: InviteLinkSummary[], + statuses: Set, +): InviteLinkSummary[] { + // ArchivedStatus excludes 'active' by construction — this filter is only + // applied on the archived tab where all invites have a non-active status. + return list.filter((inv) => statuses.has(inv.status as ArchivedStatus)); +} + +// --------------------------------------------------------------------------- +// FilterDropdown +// --------------------------------------------------------------------------- + +interface FilterDropdownProps { + view: 'active' | 'archived'; + activeSort: ActiveSort; + onActiveSortChange: (s: ActiveSort) => void; + archivedSort: ArchivedSort; + onArchivedSortChange: (s: ArchivedSort) => void; + archivedStatusFilter: Set; + onArchivedStatusToggle: (s: ArchivedStatus) => void; +} + +function FilterDropdown({ + view, + activeSort, + onActiveSortChange, + archivedSort, + onArchivedSortChange, + archivedStatusFilter, + onArchivedStatusToggle, +}: FilterDropdownProps) { + const [open, setOpen] = useState(false); + + const activeSortOptions: Array<{ key: ActiveSort; label: string }> = [ + { key: 'recent', label: 'Most recent' }, + { key: 'oldest', label: 'Oldest' }, + { key: 'name', label: 'Name (A–Z)' }, + { key: 'mostUsed', label: 'Most used' }, + { key: 'expiringSoonest', label: 'Expiring soonest' }, + ]; + + const archivedSortOptions: Array<{ key: ArchivedSort; label: string }> = [ + { key: 'recent', label: 'Most recent' }, + { key: 'oldest', label: 'Oldest' }, + { key: 'name', label: 'Name (A–Z)' }, + ]; + + const archivedStatusOptions: ArchivedStatus[] = ['expired', 'exhausted', 'revoked']; + + const handleArchivedStatusToggle = (s: ArchivedStatus) => { + // Prevent deselecting the last selected status — always keep at least one. + if (archivedStatusFilter.has(s) && archivedStatusFilter.size === 1) return; + onArchivedStatusToggle(s); + }; + + return ( +
+ + + {open && ( + <> +
setOpen(false)} /> +
+ {view === 'archived' && ( + <> +
+ Status +
+ {archivedStatusOptions.map((s) => ( + + ))} +
+ + )} +
+ Sort by +
+ {view === 'active' + ? activeSortOptions.map((opt) => ( + + )) + : archivedSortOptions.map((opt) => ( + + ))} +
+ + )} +
+ ); +} + +type ExpiryPresetId = '1h' | '24h' | '7d' | '30d' | 'never' | 'custom'; + +/** Edit modal additionally supports a 'keep' option (don't change expiry on PATCH). */ +type EditExpiryId = 'keep' | ExpiryPresetId; + +const EXPIRY_PRESETS: ReadonlyArray<{ id: ExpiryPresetId; label: string; ms: number | null }> = [ + { id: '1h', label: '1 hour', ms: 3_600_000 }, + { id: '24h', label: '24 hours', ms: 86_400_000 }, + { id: '7d', label: '7 days', ms: 7 * 86_400_000 }, + { id: '30d', label: '30 days', ms: 30 * 86_400_000 }, + { id: 'never', label: 'Never', ms: null }, + // Custom uses a free-form datetime input rendered below the preset row; + // ms is intentionally null and ignored for this id. + { id: 'custom', label: 'Custom…', ms: null }, +]; + +/** + * Format a millisecond timestamp as a value suitable for ``. + * The input expects local-wall-clock time in `YYYY-MM-DDTHH:mm` format (no timezone suffix); + * the browser then interprets it in the user's local timezone on read-back via `new Date(value)`. + */ +function toDatetimeLocalValue(ms: number): string { + const d = new Date(ms); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +interface ExpirySelectorProps { + value: EditExpiryId; + customDateTime: string; + onChange: (value: EditExpiryId, customDateTime: string) => void; + /** When true, prepends a "Keep current" pill (used by Edit). Create/Reinstate omit it. */ + showKeep: boolean; + disabled?: boolean; +} + +/** + * Shared expiry preset row + custom datetime input. Used by Create, Edit, and Reinstate + * modals so the picker UX stays consistent across all three flows. + * + * Resolution rules (applied by callers via `resolveExpiryFromSelector` below): + * 'keep' → omit `expiresAt` from request body (Edit only) + * 'never' → expiresAt: null + * 'custom' → expiresAt: new Date(customDateTime).getTime() (validated by caller) + * preset → expiresAt: Date.now() + preset.ms + */ +function ExpirySelector({ value, customDateTime, onChange, showKeep, disabled }: ExpirySelectorProps) { + return ( +
+
+ {showKeep && ( + + )} + {EXPIRY_PRESETS.map((p) => ( + + ))} +
+ {value === 'custom' && ( + onChange('custom', e.target.value)} + min={toDatetimeLocalValue(Date.now() + 60_000)} + disabled={disabled} + className="input-standard w-full px-3 py-2 text-sm mt-2" + /> + )} +
+ ); +} + +/** + * Resolve an `ExpirySelector` selection into a `expiresAt` timestamp for API bodies. + * + * Returns one of: + * - `{ kind: 'omit' }` — caller should NOT include `expiresAt` in the body (Edit "Keep current") + * - `{ kind: 'value', expiresAt: number | null }` — caller sets `body.expiresAt = expiresAt` + * - `{ kind: 'invalid', message: string }` — caller should toast the message and abort + */ +function resolveExpiryFromSelector( + value: EditExpiryId, + customDateTime: string, +): + | { kind: 'omit' } + | { kind: 'value'; expiresAt: number | null } + | { kind: 'invalid'; message: string } { + if (value === 'keep') return { kind: 'omit' }; + if (value === 'never') return { kind: 'value', expiresAt: null }; + if (value === 'custom') { + if (customDateTime === '') { + return { kind: 'invalid', message: 'Pick a future date & time' }; + } + const ts = new Date(customDateTime).getTime(); + if (!Number.isFinite(ts) || ts <= Date.now()) { + return { kind: 'invalid', message: 'Pick a future date & time' }; + } + return { kind: 'value', expiresAt: ts }; + } + const preset = EXPIRY_PRESETS.find((p) => p.id === value); + if (!preset || preset.ms === null) { + // Unreachable: 'never' and 'custom' are handled above; remaining ids all carry an ms. + return { kind: 'invalid', message: 'Invalid expiry selection' }; + } + return { kind: 'value', expiresAt: Date.now() + preset.ms }; +} + +interface CreateInviteModalProps { + onClose: () => void; + onCreated: (created: InviteLinkSummary) => void; +} + +function CreateInviteModal({ onClose, onCreated }: CreateInviteModalProps) { + const addToast = useUIStore((s) => s.addToast); + const [name, setName] = useState(''); + const [unlimited, setUnlimited] = useState(true); + const [maxUses, setMaxUses] = useState('1'); + const [expiryId, setExpiryId] = useState('7d'); + const [customDateTime, setCustomDateTime] = useState(''); + const [submitting, setSubmitting] = useState(false); + const nameInputRef = useRef(null); + + // Auto-focus name input on mount + useEffect(() => { + nameInputRef.current?.focus(); + }, []); + + // Escape closes the modal (capture phase so it fires before parent handlers) + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !submitting) { + e.stopPropagation(); + onClose(); + } + }; + document.addEventListener('keydown', handleKey, true); + return () => document.removeEventListener('keydown', handleKey, true); + }, [onClose, submitting]); + + const handleCreate = async () => { + const trimmed = name.trim(); + if (trimmed.length === 0 || trimmed.length > 64) { + addToast('Name must be 1–64 characters', 'warning'); + return; + } + let maxUsesNum: number | null = null; + if (!unlimited) { + const parsed = Number(maxUses); + if (!Number.isInteger(parsed) || parsed < 1) { + addToast('Max uses must be a positive integer', 'warning'); + return; + } + maxUsesNum = parsed; + } + + // Create never uses the 'keep' option — it always sets a concrete expiry. + const resolved = resolveExpiryFromSelector(expiryId, customDateTime); + if (resolved.kind === 'invalid') { + addToast(resolved.message, 'warning'); + return; + } + if (resolved.kind === 'omit') { + // Unreachable: ExpirySelector for Create is rendered with showKeep={false}, so + // 'keep' cannot be selected. Defensive guard so future refactors fail loudly. + addToast('Invalid expiry selection', 'warning'); + return; + } + const expiresAt = resolved.expiresAt; + + setSubmitting(true); + try { + const created = await api.invites.create({ name: trimmed, maxUses: maxUsesNum, expiresAt }); + try { + await navigator.clipboard.writeText(created.url); + addToast('Link created. Copied to clipboard.', 'success', 2000); + } catch { + addToast('Link created. Copy manually from the row.', 'success', 2000); + } + onCreated(created); + onClose(); + } catch (err) { + addToast(`Failed to create invite: ${(err as Error).message}`, 'warning'); + } finally { + setSubmitting(false); + } + }; + + return createPortal( +
+ {/* Backdrop */} +
+ + {/* Modal panel — stop propagation so backdrop click doesn't fire inside */} +
e.stopPropagation()} + > + {/* Header */} +
+
+ + + + +
+
+

Create invite link

+

+ Generate a shareable link that lets people register on this instance. You'll set how many times it can be used and when it expires. +

+
+
+ + { + e.preventDefault(); + handleCreate(); + }} + className="px-5 pt-4 pb-5 space-y-5" + > + {/* Name */} +
+
Name
+ setName(e.target.value)} + maxLength={64} + placeholder="e.g. Friends batch 1" + className="input-standard w-full" + disabled={submitting} + /> +
+ + {/* Max uses */} +
+
Max uses
+
+ + +
+
+ + {/* Expiry */} +
+
Expires
+ { + // Create's expiryId state is the narrower ExpiryPresetId; 'keep' cannot + // be returned because never renders it. + if (v === 'keep') return; + setExpiryId(v); + setCustomDateTime(dt); + }} + showKeep={false} + disabled={submitting} + /> +
+ + {/* Actions */} +
+
+ + +
+
+ +
+
, + document.body, + ); +} + +interface EditInviteModalProps { + invite: InviteLinkSummary; + onClose: () => void; + onUpdated: () => void; +} + +/** + * Edit modal — same shape as Create, pre-filled. Only sends fields the user actually changed + * (no-op churn avoidance). Disallowed for revoked rows (the row hides the Edit button entirely + * — Reinstate is the only path back from revoked). + */ +function EditInviteModal({ invite, onClose, onUpdated }: EditInviteModalProps) { + const addToast = useUIStore((s) => s.addToast); + const [name, setName] = useState(invite.name); + const [unlimited, setUnlimited] = useState(invite.maxUses === null); + const [maxUses, setMaxUses] = useState(invite.maxUses?.toString() ?? '1'); + const [expiryId, setExpiryId] = useState('keep'); + const [customDateTime, setCustomDateTime] = useState(''); + const [submitting, setSubmitting] = useState(false); + const nameInputRef = useRef(null); + + // Auto-focus name input on mount + useEffect(() => { + nameInputRef.current?.focus(); + }, []); + + // Escape closes the modal (capture phase so it fires before the parent settings modal handler) + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !submitting) { + e.stopPropagation(); + onClose(); + } + }; + document.addEventListener('keydown', handleKey, true); + return () => document.removeEventListener('keydown', handleKey, true); + }, [onClose, submitting]); + + const handleSave = async () => { + const trimmed = name.trim(); + if (trimmed.length === 0 || trimmed.length > 64) { + addToast('Name must be 1–64 characters', 'warning'); + return; + } + + // Validate maxUses input only when the user has selected limited mode. + // Server requires `maxUses >= usedCount` (a server-side floor of 1 still applies). + let newMax: number | null = null; + if (!unlimited) { + const parsed = Number(maxUses); + if (!Number.isInteger(parsed) || parsed < 1) { + addToast('Max uses must be a positive integer', 'warning'); + return; + } + if (parsed < invite.usedCount) { + addToast( + `Max uses cannot be less than current uses (${invite.usedCount})`, + 'warning', + ); + return; + } + newMax = parsed; + } + + // Build a partial body — only include fields the user actually changed. + const body: { name?: string; maxUses?: number | null; expiresAt?: number | null } = {}; + if (trimmed !== invite.name) body.name = trimmed; + if (newMax !== invite.maxUses) body.maxUses = newMax; + + const resolved = resolveExpiryFromSelector(expiryId, customDateTime); + if (resolved.kind === 'invalid') { + addToast(resolved.message, 'warning'); + return; + } + if (resolved.kind === 'value') { + body.expiresAt = resolved.expiresAt; + } + // 'omit' (Keep current) → leave expiresAt off the body entirely. + + if (Object.keys(body).length === 0) { + addToast('No changes to save', 'warning'); + return; + } + + setSubmitting(true); + try { + await api.invites.update(invite.id, body); + addToast('Invite updated', 'success', 2000); + onUpdated(); + onClose(); + } catch (err) { + addToast(`Failed to update invite: ${(err as Error).message}`, 'warning'); + } finally { + setSubmitting(false); + } + }; + + // Floor for the maxUses input — at least 1, but also at least usedCount so the + // browser native validation matches the server's constraint. + const maxUsesMin = Math.max(1, invite.usedCount); + + return createPortal( +
+
+ +
e.stopPropagation()} + > + {/* Header */} +
+
+ + + + +
+
+

Edit "{invite.name}"

+

+ Adjust the limits on this invite link. The URL stays the same — anyone who already has it can still redeem under the new constraints. +

+
+
+ +
{ + e.preventDefault(); + handleSave(); + }} + className="px-5 pt-4 pb-5 space-y-5" + > + {/* Name */} +
+
Name
+ setName(e.target.value)} + maxLength={64} + className="input-standard w-full" + disabled={submitting} + /> +
+ + {/* Max uses */} +
+
+ Max uses ({invite.usedCount} used) +
+
+ + +
+
+ + {/* Expiry */} +
+
Expires
+ { + setExpiryId(v); + setCustomDateTime(dt); + }} + showKeep={true} + disabled={submitting} + /> +
+ + {/* Actions */} +
+
+ + +
+
+
+
+
, + document.body, + ); +} + +interface ReinstateInviteModalProps { + invite: InviteLinkSummary; + onClose: () => void; + onReinstated: () => void; +} + +/** + * Reinstate modal. Two visual variants share one component: + * - Variant A (revoked): warns that a NEW link will be generated; old URL stays dead. + * - Variant B (expired/exhausted): same URL becomes active again. + * + * Both variants always set a fresh expiry (the user is reactivating something whose + * expiry has, by definition, lapsed or is being re-set). Server's reinstate handler + * requires `maxUses > usedCount` for exhausted invites, hence the input min of usedCount+1. + * + * Toast and submit-label copy are derived from the response's `tokenRotated` flag and + * the invite's pre-action status, respectively, per spec §4.2. + */ +function ReinstateInviteModal({ invite, onClose, onReinstated }: ReinstateInviteModalProps) { + const addToast = useUIStore((s) => s.addToast); + const isRevoked = invite.status === 'revoked'; + + const [unlimited, setUnlimited] = useState(invite.maxUses === null); + // Default to a value strictly greater than usedCount — for exhausted invites, that's + // the minimum the server will accept; for others, it's a sensible bump. + const [maxUses, setMaxUses] = useState(() => { + const baseline = invite.maxUses ?? invite.usedCount + 1; + return Math.max(baseline, invite.usedCount + 1).toString(); + }); + const [expiryId, setExpiryId] = useState('7d'); + const [customDateTime, setCustomDateTime] = useState(''); + const [submitting, setSubmitting] = useState(false); + + // Escape closes (capture phase to avoid bubbling into parent settings modal) + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !submitting) { + e.stopPropagation(); + onClose(); + } + }; + document.addEventListener('keydown', handleKey, true); + return () => document.removeEventListener('keydown', handleKey, true); + }, [onClose, submitting]); + + const maxUsesMin = invite.usedCount + 1; + + const handleReinstate = async () => { + // Validate maxUses when limited. + let newMax: number | null = null; + if (!unlimited) { + const parsed = Number(maxUses); + if (!Number.isInteger(parsed) || parsed < maxUsesMin) { + addToast( + `Max uses must be at least ${maxUsesMin} (current uses: ${invite.usedCount})`, + 'warning', + ); + return; + } + newMax = parsed; + } + + // Reinstate always sets a new expiry — no 'keep' option in this flow. + const resolved = resolveExpiryFromSelector(expiryId, customDateTime); + if (resolved.kind === 'invalid') { + addToast(resolved.message, 'warning'); + return; + } + if (resolved.kind === 'omit') { + // Unreachable: ExpirySelector for Reinstate is rendered with showKeep={false}. + addToast('Invalid expiry selection', 'warning'); + return; + } + + const body: { maxUses?: number | null; expiresAt?: number | null } = { + maxUses: newMax, + expiresAt: resolved.expiresAt, + }; + + setSubmitting(true); + try { + const result = await api.invites.reinstate(invite.id, body); + if (result.tokenRotated) { + try { + await navigator.clipboard.writeText(result.invite.url); + addToast('Reinstated with new link. Copied to clipboard.', 'success', 2500); + } catch { + addToast('Reinstated with new link. Copy manually from the row.', 'success', 2500); + } + } else { + addToast('Reinstated. The same link is active again.', 'success', 2500); + } + onReinstated(); + onClose(); + } catch (err) { + addToast(`Failed to reinstate: ${(err as Error).message}`, 'warning'); + } finally { + setSubmitting(false); + } + }; + + const subtitle = isRevoked + ? 'This invite was revoked. Reinstating generates a new link with a different URL — the old URL stays inactive.' + : 'This invite has lapsed. Reinstating reactivates the same URL — anyone who saved it will be able to use it again.'; + + return createPortal( +
+
+ +
e.stopPropagation()} + > + {/* Header */} +
+
+ + + + +
+
+

Reinstate "{invite.name}"

+

{subtitle}

+
+
+ +
{ + e.preventDefault(); + handleReinstate(); + }} + className="px-5 pt-4 pb-5 space-y-5" + > + {/* Amber callout — only for revoked variant to reinforce the "new URL" consequence */} + {isRevoked && ( +
+ A new link will be generated. Anyone who had the old URL will not be able to use it. +
+ )} + + {/* Max uses */} +
+
+ Max uses (current: {invite.maxUses ?? '∞'}, used: {invite.usedCount}) +
+
+ + +
+
+ + {/* Expiry */} +
+
Expires
+ { + if (v === 'keep') return; // unreachable: showKeep={false} + setExpiryId(v); + setCustomDateTime(dt); + }} + showKeep={false} + disabled={submitting} + /> +
+ + {/* Actions */} +
+
+ + +
+
+
+
+
, + document.body, + ); +} + +interface RedemptionsModalProps { + invite: InviteLinkSummary; + onClose: () => void; +} + +/** + * Read-only redemption viewer. Each row shows the registrant's username at sign-up + * time. When the live state has diverged (rename or account deletion), the row shows + * the original name with the live state in parens — `alice (now Anastasia)` or + * `bob (now Deleted User)` per spec §4.3. + * + * Note: the spec mentions opening the user's profile (UserPopover pattern) on row + * click. That popover is not currently wired into a generic trigger callable from + * outside its existing call sites, so this modal renders rows as non-interactive. + * Adding click-through is a later polish pass — see Task 19 report. + */ +function RedemptionsModal({ invite, onClose }: RedemptionsModalProps) { + const addToast = useUIStore((s) => s.addToast); + const [redemptions, setRedemptions] = useState(null); + const [error, setError] = useState(false); + + // Escape closes (capture phase to avoid bubbling into parent settings modal) + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation(); + onClose(); + } + }; + document.addEventListener('keydown', handleKey, true); + return () => document.removeEventListener('keydown', handleKey, true); + }, [onClose]); + + useEffect(() => { + let cancelled = false; + api.invites + .redemptions(invite.id) + .then((r) => { + if (!cancelled) setRedemptions(r.redemptions); + }) + .catch(() => { + if (cancelled) return; + setError(true); + setRedemptions([]); + addToast('Failed to load redemptions', 'warning'); + }); + return () => { + cancelled = true; + }; + }, [invite.id, addToast]); + + return createPortal( +
+
+ +
e.stopPropagation()} + > + {/* Header */} +
+
+ + + +
+
+

Redemptions for "{invite.name}"

+

+ Users who registered using this invite link, in the order they signed up. +

+
+ +
+ +
+ {invite.status === 'revoked' && ( +
+ This invite was revoked + {invite.revokedAt + ? ` ${new Date(invite.revokedAt).toLocaleDateString()}` + : ''} + . The redemptions below represent users who registered before revocation. +
+ )} + +
+ {invite.usedCount} + {invite.maxUses !== null ? ` of ${invite.maxUses}` : ''} use + {invite.usedCount === 1 ? '' : 's'} +
+
+ +
+ {redemptions === null ? ( +
Loading…
+ ) : redemptions.length === 0 ? ( +
+ {error ? 'Could not load redemptions.' : 'No redemptions yet.'} +
+ ) : ( +
+ {redemptions.map((r) => { + const showCurrent = + !r.isDeleted && + r.currentUsername !== null && + r.currentUsername !== r.registrantUsername; + return ( +
+ + {r.registrantUsername} + {(r.isDeleted || showCurrent) && ( + + {' '} + (now {r.isDeleted ? 'Deleted User' : r.currentUsername}) + + )} + + + {new Date(r.redeemedAt).toLocaleString()} + +
+ ); + })} +
+ )} +
+
+
, + document.body, + ); +} + +interface InviteRowProps { + invite: InviteLinkSummary; + expanded: boolean; + onToggleExpand: () => void; + onMutate: () => void; +} + +/** + * One row in the invite list. Renders as a clickable collapsed header that, when + * expanded, reveals a meta grid + status-specific action row. Owns its own modal + * and confirm-dialog state so the parent panel only manages list-level fetch + + * single-row expansion state (`expandedInviteId`). + * + * Action surface depends on `invite.status`: + * - active → Copy link · Edit · Revoke · View redemptions + * - non-active → Reinstate · Delete permanently · View redemptions + */ +function InviteRow({ invite, expanded, onToggleExpand, onMutate }: InviteRowProps) { + const addToast = useUIStore((s) => s.addToast); + const [showEdit, setShowEdit] = useState(false); + const [showReinstate, setShowReinstate] = useState(false); + const [showRedemptions, setShowRedemptions] = useState(false); + const [confirmRevoke, setConfirmRevoke] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + const [actionLoading, setActionLoading] = useState(false); + + const usageLabel = + invite.maxUses === null + ? `${invite.usedCount} / ∞` + : `${invite.usedCount} / ${invite.maxUses}`; + + const usageNearLimit = + invite.maxUses !== null && invite.maxUses > 0 && invite.usedCount / invite.maxUses >= 0.8; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(invite.url); + addToast('Invite link copied', 'success', 2000); + } catch { + addToast('Failed to copy link', 'warning'); + } + }; + + const performRevoke = async () => { + setActionLoading(true); + try { + await api.invites.revoke(invite.id); + addToast('Invite revoked', 'success', 2000); + setConfirmRevoke(false); + onMutate(); + } catch (err) { + addToast(`Failed to revoke: ${(err as Error).message}`, 'warning'); + } finally { + setActionLoading(false); + } + }; + + const performDelete = async () => { + setActionLoading(true); + try { + await api.invites.delete(invite.id); + addToast('Invite deleted', 'success', 2000); + setConfirmDelete(false); + onMutate(); + } catch (err) { + addToast(`Failed to delete: ${(err as Error).message}`, 'warning'); + } finally { + setActionLoading(false); + } + }; + + const isActive = invite.status === 'active'; + const createdByLabel = invite.createdByUsername ?? 'Unknown'; + + // Subtitle (collapsed view) + // active → "X / Y uses · Expires in 3 days · Created by alice" + // archived → "X / Y uses · Revoked 4/12/2026 · 2d ago" + const subtitle = isActive + ? `${usageLabel} uses · ${formatExpiry(invite)} · Created by ${createdByLabel}` + : `${usageLabel} uses · ${formatExpiry(invite)} · ${formatRelative(invite.createdAt)}`; + + // Archived row 1 second-cell label + value. EXHAUSTED has no dedicated terminal + // timestamp on the invite, so we surface lastRedeemedAt (the moment that drove + // it to exhausted) when known, falling back to em-dash if absent. + let archivedTerminalLabel: string; + let archivedTerminalValue: string; + if (invite.status === 'expired') { + archivedTerminalLabel = 'EXPIRED AT'; + archivedTerminalValue = invite.expiresAt !== null ? formatRelative(invite.expiresAt) : '—'; + } else if (invite.status === 'revoked') { + archivedTerminalLabel = 'REVOKED AT'; + archivedTerminalValue = invite.revokedAt !== null ? formatRelative(invite.revokedAt) : '—'; + } else { + // exhausted + archivedTerminalLabel = 'EXHAUSTED'; + archivedTerminalValue = + invite.lastRedeemedAt !== null ? formatRelative(invite.lastRedeemedAt) : '—'; + } + + const tokenDisplay = `…${invite.token.slice(-6)}`; + const lastRedeemedDisplay = + invite.lastRedeemedAt !== null ? formatRelative(invite.lastRedeemedAt) : '—'; + + return ( + <> +
+ {/* Collapsed clickable header */} +
+
+
+
+
+ {invite.name} +
+
{subtitle}
+
+
+
+ {!isActive && ( + + {inviteStatusLabel(invite.status)} + + )} + {expanded ? '▾' : '▸'} +
+
+ + {/* Expanded body */} + {expanded && ( +
+
+ {/* Row 1: USED · (EXPIRES | terminal-status AT) · CREATED */} +
+
+
Used
+
+ {usageLabel} +
+
+ {isActive ? ( +
+
Expires
+
{formatExpiry(invite)}
+
+ ) : ( +
+
+ {archivedTerminalLabel} +
+
{archivedTerminalValue}
+
+ )} +
+
Created
+
{formatRelative(invite.createdAt)}
+
+
+ + {/* Row 2: CREATED BY · TOKEN · LAST REDEEMED */} +
+
+
Created by
+
+ {createdByLabel} +
+
+
+
Token
+
+ {tokenDisplay} +
+
+
+
Last redeemed
+
{lastRedeemedDisplay}
+
+
+ + {/* Action row */} +
+ {isActive ? ( + <> + + + + + + ) : ( + <> + + + + + )} +
+
+
+ )} +
+ + {showEdit && ( + setShowEdit(false)} + onUpdated={onMutate} + /> + )} + {showReinstate && ( + setShowReinstate(false)} + onReinstated={onMutate} + /> + )} + {showRedemptions && ( + setShowRedemptions(false)} /> + )} + + setConfirmRevoke(false)} + onConfirm={performRevoke} + title={`Revoke "${invite.name}"?`} + description={ + <> + The link stops working immediately. Anyone who has the URL can no longer use it. +
+
+ If you change your mind later, Reinstate issues a fresh link under this entry — the original URL stays inactive. + + } + confirmLabel="Revoke link" + variant="danger" + loading={actionLoading} + /> + + setConfirmDelete(false)} + onConfirm={performDelete} + title={`Delete "${invite.name}" permanently?`} + description={ + <> + 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. + + } + confirmLabel="Delete permanently" + variant="danger" + loading={actionLoading} + /> + + ); +} + +export function RegistrationPanel() { + const instanceSettings = useSettingsStore((s) => s.instanceSettings); + const updateInstanceSettings = useSettingsStore((s) => s.updateInstanceSettings); + const addToast = useUIStore((s) => s.addToast); + + const [draft, setDraft] = useState(null); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(''); + const [showCreate, setShowCreate] = useState(false); + + const [tab, setTab] = useState<'active' | 'archived'>('active'); + const [invites, setInvites] = useState([]); + const [invitesLoading, setInvitesLoading] = useState(false); + const [activeCount, setActiveCount] = useState(0); + const [archivedCount, setArchivedCount] = useState(0); + // Single-row expansion state — only one InviteRow at a time may be expanded. + // Lifted to the panel so switching tabs can reset it; otherwise an expanded row + // that scrolls out of the visible list keeps stale state. + const [expandedInviteId, setExpandedInviteId] = useState(null); + + // Sort / filter state — independent per tab so switching tabs preserves each + // tab's last selection. + const [activeSort, setActiveSort] = useState('recent'); + const [archivedSort, setArchivedSort] = useState('recent'); + const [archivedStatusFilter, setArchivedStatusFilter] = useState>( + () => new Set(['expired', 'exhausted', 'revoked']), + ); + + const toggleArchivedStatus = (s: ArchivedStatus) => { + setArchivedStatusFilter((prev) => { + const next = new Set(prev); + if (next.has(s)) next.delete(s); + else next.add(s); + return next; + }); + }; + + const handleTabSwitch = (next: 'active' | 'archived') => { + setTab(next); + setExpandedInviteId(null); + }; + + // Tracks the currently displayed tab so in-flight fetches can detect when + // the user has switched tabs and discard their stale response. Without this + // guard, a slower 'archived' response can resolve after a newer 'active' + // response and clobber the visible list. Used by both the auto-load effect + // and manual fetchInvites() callers (e.g. post-mutation refresh). + const tabRef = useRef<'active' | 'archived'>(tab); + useEffect(() => { + tabRef.current = tab; + }, [tab]); + + const fetchInvites = useCallback( + async (which: 'active' | 'archived') => { + setInvitesLoading(true); + try { + const res = await api.invites.list(which); + if (tabRef.current !== which) return; + setInvites(res.invites); + } catch { + if (tabRef.current === which) addToast('Failed to load invites', 'warning'); + } finally { + if (tabRef.current === which) setInvitesLoading(false); + } + }, + [addToast], + ); + + const refreshCounts = useCallback(async () => { + try { + const [a, r] = await Promise.all([ + api.invites.list('active'), + api.invites.list('archived'), + ]); + setActiveCount(a.invites.length); + setArchivedCount(r.invites.length); + } catch { + // Leave previous counts on transient failure + } + }, []); + + useEffect(() => { + refreshCounts(); + }, [refreshCounts]); + + useEffect(() => { + if (instanceSettings) { + setDraft({ + registrationOpen: instanceSettings.registrationOpen, + federatedRegistrationOpen: instanceSettings.federatedRegistrationOpen, + }); + setSaveError(''); + } + }, [instanceSettings]); + + useEffect(() => { + fetchInvites(tab); + }, [tab, fetchInvites]); + + // Derived sorted + filtered list. Tab badge counts (`activeCount`/`archivedCount`) + // are NOT derived from this — they reflect the full unfiltered bucket size. + // Must be declared before any conditional early-return to satisfy rules-of-hooks. + const displayInvites = useMemo(() => { + let list = invites; + if (tab === 'archived') { + list = filterInvitesByStatus(list, archivedStatusFilter); + } + list = sortInvites(list, tab === 'active' ? activeSort : archivedSort); + return list; + }, [invites, tab, activeSort, archivedSort, archivedStatusFilter]); + + if (!draft) return
Loading settings...
; + + const hasChanges = !!instanceSettings && ( + draft.registrationOpen !== instanceSettings.registrationOpen || + draft.federatedRegistrationOpen !== instanceSettings.federatedRegistrationOpen + ); + + const handleSave = async () => { + setSaving(true); + setSaveError(''); + try { + await updateInstanceSettings({ + registrationOpen: draft.registrationOpen, + federatedRegistrationOpen: draft.federatedRegistrationOpen, + }); + addToast('Registration settings saved', 'success', 2000); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to save'; + setSaveError(message); + addToast('Failed to update registration settings', 'warning'); + } finally { + setSaving(false); + } + }; + + const handleReset = () => { + if (instanceSettings) { + setDraft({ + registrationOpen: instanceSettings.registrationOpen, + federatedRegistrationOpen: instanceSettings.federatedRegistrationOpen, + }); + } + setSaveError(''); + }; + + return ( + <> +
e.preventDefault()}> +

Registration

+
+ Control who can create accounts on this instance. Public registration covers local + sign-ups; federated registration covers users from peered instances creating an account here. +
+ + {/* Public registration */} +
+
Public Registration
+
+ +
+
+ + {/* Federated registration */} +
+
Federated Registration
+
+ +
+
+ + {/* Invite Links */} +
+ {/* Row 1: heading + create button */} +
+
Invite Links
+ +
+ + {/* Row 2: tab strip (left) + FilterDropdown (right) */} +
+
+ + +
+ +
+ + {invitesLoading ? ( +
Loading...
+ ) : invites.length === 0 ? ( +
+ {tab === 'active' ? 'No active invite links.' : 'No archived invite links.'} +
+ ) : displayInvites.length === 0 ? ( +
+ No invites match the current filter. +
+ ) : ( +
+ {displayInvites.map((inv) => ( + + setExpandedInviteId(expandedInviteId === inv.id ? null : inv.id) + } + onMutate={() => { fetchInvites(tab); refreshCounts(); }} + /> + ))} +
+ )} +
+ + {/* Status messages */} + {saveError && ( +
{saveError}
+ )} + {/* Save / Reset bar */} + {hasChanges && ( +
+
+
+ + +
+
+
+ )} +
+ + {showCreate && ( + setShowCreate(false)} + onCreated={() => { fetchInvites('active'); refreshCounts(); }} + /> + )} + + ); +} diff --git a/packages/web/src/components/modals/settingsPanels/InstancePanel.tsx b/packages/web/src/components/modals/settingsPanels/InstancePanel.tsx index 16e5ffc2..d9d4f23b 100644 --- a/packages/web/src/components/modals/settingsPanels/InstancePanel.tsx +++ b/packages/web/src/components/modals/settingsPanels/InstancePanel.tsx @@ -4,12 +4,13 @@ import { useSettingsSections } from '../../../hooks/useSettingsSections'; import type { SettingsSection } from '../SettingsSectionsContext'; import { SettingsTabBar } from '../SettingsTabBar'; import { GeneralPanel } from '../instanceSettingsPanels/GeneralPanel'; +import { RegistrationPanel } from '../instanceSettingsPanels/RegistrationPanel'; import { FederationPanel } from '../instanceSettingsPanels/FederationPanel'; import { StreamingPanel } from '../instanceSettingsPanels/StreamingPanel'; import { StoragePanel } from '../instanceSettingsPanels/StoragePanel'; import { UsersPanel } from '../instanceSettingsPanels/UsersPanel'; -type SubTab = 'general' | 'federation' | 'streaming' | 'storage' | 'users'; +type SubTab = 'general' | 'registration' | 'federation' | 'streaming' | 'storage' | 'users'; export function InstancePanel() { const fetchInstanceSettings = useSettingsStore((s) => s.fetchInstanceSettings); @@ -20,6 +21,7 @@ export function InstancePanel() { const sections = useMemo(() => [ { id: 'general', label: 'General' }, + { id: 'registration', label: 'Registration' }, { id: 'federation', label: 'Federation', badgeCount: approvalCount }, { id: 'streaming', label: 'Streaming' }, { id: 'storage', label: 'Storage' }, @@ -43,6 +45,7 @@ export function InstancePanel() { {subTab === 'general' && } + {subTab === 'registration' && } {subTab === 'federation' && } {subTab === 'streaming' && } {subTab === 'storage' && } diff --git a/packages/web/src/components/ui/ConfirmDialog.tsx b/packages/web/src/components/ui/ConfirmDialog.tsx index 27a34ab2..6d141a9f 100644 --- a/packages/web/src/components/ui/ConfirmDialog.tsx +++ b/packages/web/src/components/ui/ConfirmDialog.tsx @@ -25,13 +25,16 @@ export function ConfirmDialog({ loading = false, }: ConfirmDialogProps) { const handleKeyDown = useCallback((e: KeyboardEvent) => { - if (e.key === 'Escape' && !loading) onClose(); + if (e.key === 'Escape' && !loading) { + e.stopPropagation(); + onClose(); + } }, [onClose, loading]); useEffect(() => { if (isOpen) { - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); + document.addEventListener('keydown', handleKeyDown, true); + return () => document.removeEventListener('keydown', handleKeyDown, true); } }, [isOpen, handleKeyDown]);