Merge feat/registration-invites: registration toggles + invite links

Adds registrationOpen / federatedRegistrationOpen split, full invite-link
CRUD (create, edit, revoke, reinstate, delete, redemptions viewer),
RegisterPage invite UX with URL + manual entry, federation banner in
Connections, and design polish aligning the panel with PEERED INSTANCES
+ ConnectInstanceModal patterns.
This commit is contained in:
Jannis Braun
2026-04-29 03:07:44 +02:00
28 changed files with 8903 additions and 50 deletions
+73 -2
View File
@@ -9,10 +9,13 @@ Source files:
- `packages/server/src/utils/storageJanitor.ts` -- Storage stats, orphan detection, cleanup - `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/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/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/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/StoragePanel.tsx` -- Storage management UI
- `packages/web/src/components/modals/instanceSettingsPanels/StreamingPanel.tsx` -- Streaming config UI - `packages/web/src/components/modals/instanceSettingsPanels/StreamingPanel.tsx` -- Streaming config UI
- `packages/web/src/components/modals/instanceSettingsPanels/UsersPanel.tsx` -- User management 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/types.ts` -- Shared type interfaces
- `packages/shared/src/constants.ts` -- Streaming constants (resolutions, framerates, bitrate matrix) - `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 | | Field | Type | DB Column | Validation | Notes |
|-------|------|-----------|------------|-------| |-------|------|-----------|------------|-------|
| instanceName | string | instanceName | 1-32 chars, trimmed | Default: `'Backspace'` | | 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 | | discoveryEnabled | boolean | discoveryEnabled | boolean | Controls space Explore page |
| gifApiKey | string? | gifApiKey | string or empty to clear | Returned masked as `****{last4}` for security | | 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 | | gifEnabled | boolean? | (derived) | -- | Read-only; true when gifApiKey is non-null |
@@ -164,11 +168,14 @@ No authentication. Returns:
name: string; // instanceSettings.instanceName ?? 'Backspace' name: string; // instanceSettings.instanceName ?? 'Backspace'
version: string; // Hardcoded '1.0.0' in instance.ts version: string; // Hardcoded '1.0.0' in instance.ts
registrationOpen: boolean; // DB setting overrides env if non-null 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). 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 ### General Instance Settings
``` ```
@@ -428,12 +435,76 @@ All panels live under `packages/web/src/components/modals/instanceSettingsPanels
#### GeneralPanel #### 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)` - 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. - 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. - 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 `<name>` 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://<host>/register?invite=<token>` — 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 #### FederationPanel
Manages: federation peers list, pending approval requests (inbound + outbound), manual peering initiation, secret rotation, peer reset. Manages: federation peers list, pending approval requests (inbound + outbound), manual peering initiation, secret rotation, peer reset.
+83 -6
View File
@@ -7,11 +7,28 @@ Source files: `packages/server/src/routes/*.ts`
## Auth (`routes/auth.ts`) — public, rate-limited ## 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-username ?username= → { available, reason? }
GET /auth/check-invite ?token= → CheckInviteResponse
POST /auth/login { username, password } → { token, user } 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 ## Users (`routes/users.ts`) — auth required
``` ```
GET /users/@me → { user } GET /users/@me → { user }
@@ -189,18 +206,20 @@ Permissions checked: CONNECT, SPEAK, STREAM (space channels). DM calls: always f
## Instance (`routes/instance.ts`) — public ## 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`) ## Settings (`routes/settings.ts`)
``` ```
GET /settings/streaming (auth) → { streamingLimits } GET /settings/streaming (auth) → { streamingLimits }
PATCH /settings/streaming (admin) → { streamingLimits } PATCH /settings/streaming (admin) → { streamingLimits }
GET /settings/instance (admin) → { instanceName, registrationOpen, discoveryEnabled, ... } GET /settings/instance (admin) → { instanceName, registrationOpen, federatedRegistrationOpen, discoveryEnabled, ... }
PATCH /settings/instance (admin) { instanceName?, registrationOpen?, discoveryEnabled?, PATCH /settings/instance (admin) { instanceName?, registrationOpen?, federatedRegistrationOpen?,
gifApiKey?, maxUploadSizeMb?, federationRelayEnabled?, discoveryEnabled?, gifApiKey?, maxUploadSizeMb?,
federationRelayTtlDays? } → { settings } 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 ## Admin (`routes/admin.ts`) — admin required
``` ```
@@ -215,6 +234,64 @@ POST /admin/users/:id/reset-password → { temporaryPasswo
DELETE /admin/users/:id → { success } 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://<host>/register?invite=<token>` — 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`) ## Federation (`routes/federation.ts`)
``` ```
POST /federation/peer/initiate (admin) { remoteOrigin } → peer created POST /federation/peer/initiate (admin) { remoteOrigin } → peer created
+91 -13
View File
@@ -1,7 +1,9 @@
# Authentication & Session System # Authentication & Session System
Source files: 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/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/routes/admin.ts` -- Admin password reset endpoint (lines 232-265)
- `packages/server/src/utils/auth.ts` -- Password hashing, JWT sign/verify, `authenticate` preHandler, `requireAdmin` - `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 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 1. `instanceSettings.registrationOpen` (DB, id=1) -- if not null, this takes priority
2. `config.registrationOpen` (env `REGISTRATION_OPEN`, default `true`) -- fallback 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 ### First-User Admin Promotion
@@ -144,18 +217,21 @@ If `requestedAvatarColor` is provided and is in `AVATAR_COLORS`, use it. Otherwi
### Registration Steps ### Registration Steps
1. Validate inputs (username format, password length) 1. Validate inputs (username format, password length)
2. Check registration is open 2. Read both registration gates from `instance_settings`
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). 3. **Branch by request shape** (spec §1.2):
4. Check username uniqueness (exact match on lowercased username) - If `homeInstance` set → reject with 403 unless `federatedRegistrationOpen === true`. `inviteToken` ignored on this path.
5. Hash password (bcrypt, 12 rounds) - Else (local) → if `registrationOpen` is false, require a valid `inviteToken`; otherwise reject with 403. Pre-flight token check rejects obvious-invalid tokens before bcrypt.
6. Generate Snowflake ID 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).
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. 5. Check username uniqueness (exact match on lowercased username)
8. Sign JWT with `{ userId, username }` 6. Hash password (bcrypt, 12 rounds)
9. Return `{ token, user }` (user sanitized via `sanitizeUser(user, true)`) 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 ### 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) - `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`) - `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 | | `POST /api/auth/register` | 10 | 2 min |
| `GET /api/auth/check-username` | 30 | 1 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/auth/login` | 15 | 2 min |
| `POST /api/users/@me/change-password` | 5 | 15 min | | `POST /api/users/@me/change-password` | 5 | 15 min |
| `DELETE /api/users/@me` | 3 | 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 | | `jwtSecret` | `JWT_SECRET` | (required) | Min 32 chars, startup crash if shorter |
| `jwtExpiresIn` | `JWT_EXPIRES_IN` | `'30d'` | Passed to `jsonwebtoken` `expiresIn` option | | `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. |
--- ---
+20
View File
@@ -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. - **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** — 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 ### Identity Deletion
Each remote instance row exposes an identity deletion flow with three modes: Each remote instance row exposes an identity deletion flow with three modes:
+38 -1
View File
@@ -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) ## Instance Settings (singleton, id=1)
| Column | Type | Default | Notes | | Column | Type | Default | Notes |
@@ -334,7 +370,8 @@ PK: (spaceId, userId, restrictionType)
| allowedFramerates | text NOT NULL | `'30,45,60'` | CSV | | allowedFramerates | text NOT NULL | `'30,45,60'` | CSV |
| maxResolution | integer NOT NULL | 1080 | | | maxResolution | integer NOT NULL | 1080 | |
| maxFramerate | integer NOT NULL | 60 | | | 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 | | gifApiKey | text | | Klipy API key |
| bitrateMatrixOverrides | text | | JSON sparse overrides | | bitrateMatrixOverrides | text | | JSON sparse overrides |
| allowCustomBitrate | integer NOT NULL | 1 | | | allowCustomBitrate | integer NOT NULL | 1 | |
@@ -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`);
File diff suppressed because it is too large Load Diff
@@ -29,6 +29,13 @@
"when": 1777229997210, "when": 1777229997210,
"tag": "0003_brave_inhumans", "tag": "0003_brave_inhumans",
"breakpoints": true "breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1777395066272,
"tag": "0004_slim_mentallo",
"breakpoints": true
} }
] ]
} }
+26
View File
@@ -310,6 +310,7 @@ export const instanceSettings = sqliteTable('instance_settings', {
maxResolution: integer('max_resolution').notNull().default(1080), maxResolution: integer('max_resolution').notNull().default(1080),
maxFramerate: integer('max_framerate').notNull().default(60), maxFramerate: integer('max_framerate').notNull().default(60),
registrationOpen: integer('registration_open'), // null = use env var default, 0/1 = explicit 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'), gifApiKey: text('gif_api_key'),
bitrateMatrixOverrides: text('bitrate_matrix_overrides'), bitrateMatrixOverrides: text('bitrate_matrix_overrides'),
allowCustomBitrate: integer('allow_custom_bitrate').notNull().default(1), allowCustomBitrate: integer('allow_custom_bitrate').notNull().default(1),
@@ -481,3 +482,28 @@ export const userFederationRegistry = sqliteTable('user_federation_registry', {
}, (table) => ({ }, (table) => ({
pk: primaryKey({ columns: [table.userId, table.origin] }), 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),
}));
+2
View File
@@ -20,6 +20,7 @@ import { socialRoutes } from './routes/social.js';
import { settingsRoutes } from './routes/settings.js'; import { settingsRoutes } from './routes/settings.js';
import { utilRoutes } from './routes/utils.js'; import { utilRoutes } from './routes/utils.js';
import { instanceRoutes } from './routes/instance.js'; import { instanceRoutes } from './routes/instance.js';
import { invitesRoutes } from './routes/invites.js';
import { exploreRoutes } from './routes/explore.js'; import { exploreRoutes } from './routes/explore.js';
import { searchRoutes } from './routes/search.js'; import { searchRoutes } from './routes/search.js';
import { adminRoutes } from './routes/admin.js'; import { adminRoutes } from './routes/admin.js';
@@ -103,6 +104,7 @@ async function main(): Promise<void> {
await app.register(settingsRoutes); await app.register(settingsRoutes);
await app.register(utilRoutes); await app.register(utilRoutes);
await app.register(instanceRoutes); await app.register(instanceRoutes);
await app.register(invitesRoutes);
await app.register(exploreRoutes); await app.register(exploreRoutes);
await app.register(searchRoutes); await app.register(searchRoutes);
await app.register(adminRoutes); await app.register(adminRoutes);
+490
View File
@@ -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<typeof drizzle<typeof schema>>;
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<FastifyInstance> {
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);
});
});
+115 -5
View File
@@ -8,6 +8,7 @@ import type { RegisterRequest, LoginRequest, AuthResponse } from '@backspace/sha
import { AVATAR_COLORS } from '@backspace/shared'; import { AVATAR_COLORS } from '@backspace/shared';
import { sanitizeUser } from '../utils/sanitize.js'; import { sanitizeUser } from '../utils/sanitize.js';
import { findFederatedUser } from './federation.js'; import { findFederatedUser } from './federation.js';
import { getInviteByToken, inviteStatus, redeemInvite, InviteUnavailableError } from '../utils/inviteService.js';
export async function authRoutes(app: FastifyInstance): Promise<void> { export async function authRoutes(app: FastifyInstance): Promise<void> {
app.post<{ Body: RegisterRequest }>('/api/auth/register', { app.post<{ Body: RegisterRequest }>('/api/auth/register', {
@@ -75,13 +76,52 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
const db = getDb(); 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 instanceRow = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get();
const registrationOpen = instanceRow?.registrationOpen !== null && instanceRow?.registrationOpen !== undefined const registrationOpen = instanceRow?.registrationOpen !== null && instanceRow?.registrationOpen !== undefined
? instanceRow.registrationOpen === 1 ? instanceRow.registrationOpen === 1
: config.registrationOpen; : config.registrationOpen;
if (!registrationOpen) { // instanceRow is guaranteed by ensureDefaults() (migrate.ts) to have id=1
return reply.code(403).send({ error: 'Registration is currently closed', statusCode: 403 }); // 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); const passwordHash = await hashPassword(password);
@@ -174,7 +214,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
// which would otherwise produce a permanently stuck-online row that no // which would otherwise produce a permanently stuck-online row that no
// disconnect timer can clean up. The WS handshake will flip it to // disconnect timer can clean up. The WS handshake will flip it to
// 'online' once a real socket attaches. // 'online' once a real socket attaches.
db.insert(schema.users).values({ const userRow = {
id: userId, id: userId,
username: trimmedUsername, username: trimmedUsername,
displayName: displayName?.trim() || null, displayName: displayName?.trim() || null,
@@ -184,7 +224,35 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
homeUserId: (homeInstance && homeUserId && typeof homeUserId === 'string') ? homeUserId : null, homeUserId: (homeInstance && homeUserId && typeof homeUserId === 'string') ? homeUserId : null,
avatarColor, avatarColor,
createdAt: now, 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(); const user = db.select().from(schema.users).where(eq(schema.users.id, userId)).get();
if (!user) { if (!user) {
@@ -239,6 +307,48 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
return reply.code(200).send({ available: !existing }); 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', { app.post<{ Body: LoginRequest }>('/api/auth/login', {
config: { config: {
rateLimit: { rateLimit: {
@@ -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<typeof drizzle<typeof schema>>;
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<FastifyInstance> {
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');
});
});
+1
View File
@@ -22,6 +22,7 @@ export async function instanceRoutes(app: FastifyInstance): Promise<void> {
name: instanceName, name: instanceName,
version: BACKSPACE_VERSION, version: BACKSPACE_VERSION,
registrationOpen, registrationOpen,
federatedRegistrationOpen: settings?.federatedRegistrationOpen === 1,
}; };
return reply.code(200).send(response); return reply.code(200).send(response);
+357
View File
@@ -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<typeof drizzle<typeof schema>>;
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<FastifyInstance> {
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');
});
});
+137
View File
@@ -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<void> {
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 });
});
}
+171
View File
@@ -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<typeof drizzle<typeof schema>>;
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<FastifyInstance> {
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);
});
});
+9
View File
@@ -185,6 +185,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
const response: InstanceAdminSettings = { const response: InstanceAdminSettings = {
instanceName: row.instanceName ?? 'Backspace', instanceName: row.instanceName ?? 'Backspace',
registrationOpen: row.registrationOpen !== null ? row.registrationOpen === 1 : config.registrationOpen, registrationOpen: row.registrationOpen !== null ? row.registrationOpen === 1 : config.registrationOpen,
federatedRegistrationOpen: row.federatedRegistrationOpen === 1,
discoveryEnabled: row.discoveryEnabled === 1, discoveryEnabled: row.discoveryEnabled === 1,
gifApiKey: gifKey ? `****${gifKey.slice(-4)}` : undefined, gifApiKey: gifKey ? `****${gifKey.slice(-4)}` : undefined,
gifEnabled: !!gifKey, gifEnabled: !!gifKey,
@@ -216,6 +217,13 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
updateData.registrationOpen = body.registrationOpen ? 1 : 0; 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) { if (body.discoveryEnabled !== undefined) {
updateData.discoveryEnabled = body.discoveryEnabled ? 1 : 0; updateData.discoveryEnabled = body.discoveryEnabled ? 1 : 0;
} }
@@ -276,6 +284,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
const response: InstanceAdminSettings = { const response: InstanceAdminSettings = {
instanceName: updatedRow.instanceName ?? 'Backspace', instanceName: updatedRow.instanceName ?? 'Backspace',
registrationOpen: updatedRow.registrationOpen !== null ? updatedRow.registrationOpen === 1 : config.registrationOpen, registrationOpen: updatedRow.registrationOpen !== null ? updatedRow.registrationOpen === 1 : config.registrationOpen,
federatedRegistrationOpen: updatedRow.federatedRegistrationOpen === 1,
discoveryEnabled: updatedRow.discoveryEnabled === 1, discoveryEnabled: updatedRow.discoveryEnabled === 1,
gifApiKey: updatedGifKey ? `****${updatedGifKey.slice(-4)}` : undefined, gifApiKey: updatedGifKey ? `****${updatedGifKey.slice(-4)}` : undefined,
gifEnabled: !!updatedGifKey, gifEnabled: !!updatedGifKey,
@@ -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<typeof drizzle<typeof schema>>;
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);
});
});
+600
View File
@@ -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 (164 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<typeof getDb> = getDb(),
): number | null {
const result = dbHandle
.select({ maxAt: sql<number | null>`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<typeof getDb> = 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<number | null>`(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<typeof schema.inviteLinks.$inferInsert> = {};
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<typeof schema.inviteLinks.$inferInsert> = {};
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();
}
+70
View File
@@ -482,6 +482,7 @@ export interface RegisterRequest {
avatarColor?: string; avatarColor?: string;
homeInstance?: string; homeInstance?: string;
homeUserId?: string; homeUserId?: string;
inviteToken?: string;
} }
export interface LoginRequest { export interface LoginRequest {
@@ -681,6 +682,7 @@ export interface GifResult {
export interface InstanceAdminSettings { export interface InstanceAdminSettings {
instanceName: string; instanceName: string;
registrationOpen: boolean; registrationOpen: boolean;
federatedRegistrationOpen: boolean;
discoveryEnabled: boolean; discoveryEnabled: boolean;
gifApiKey?: string; gifApiKey?: string;
gifEnabled?: boolean; gifEnabled?: boolean;
@@ -710,6 +712,7 @@ export interface InstanceInfoResponse {
name: string; name: string;
version: string; version: string;
registrationOpen: boolean; registrationOpen: boolean;
federatedRegistrationOpen: boolean;
} }
export interface VerifyPasswordRequest { export interface VerifyPasswordRequest {
@@ -1129,3 +1132,70 @@ export interface ApprovalRequest {
*/ */
subscribers?: ApprovalRequestSubscriberSummary[]; 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=<token>`. 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;
+37
View File
@@ -56,6 +56,13 @@ import type {
ApprovalRequest, ApprovalRequest,
PeeringSubscription, PeeringSubscription,
PeeringNotification, PeeringNotification,
InviteLinkSummary,
InviteRedemption,
CreateInviteRequest,
UpdateInviteRequest,
ReinstateInviteRequest,
ReinstateInviteResponse,
CheckInviteResponse,
} from '@backspace/shared'; } from '@backspace/shared';
export type { FederationPeer, ApprovalRequest, PeeringSubscription, PeeringNotification }; export type { FederationPeer, ApprovalRequest, PeeringSubscription, PeeringNotification };
@@ -74,6 +81,7 @@ export class BackspaceApiClient {
register: (data: RegisterRequest) => Promise<AuthResponse>; register: (data: RegisterRequest) => Promise<AuthResponse>;
login: (data: LoginRequest) => Promise<AuthResponse>; login: (data: LoginRequest) => Promise<AuthResponse>;
checkUsername: (username: string) => Promise<{ available: boolean; reason?: string }>; checkUsername: (username: string) => Promise<{ available: boolean; reason?: string }>;
checkInvite: (token: string) => Promise<CheckInviteResponse>;
}; };
readonly users: { readonly users: {
@@ -232,6 +240,16 @@ export class BackspaceApiClient {
markAllPeeringNotificationsRead: () => Promise<{ success: boolean; count: number }>; markAllPeeringNotificationsRead: () => Promise<{ success: boolean; count: number }>;
}; };
readonly invites: {
list: (status?: 'active' | 'archived') => Promise<{ invites: InviteLinkSummary[] }>;
create: (body: CreateInviteRequest) => Promise<InviteLinkSummary>;
update: (id: string, body: UpdateInviteRequest) => Promise<InviteLinkSummary>;
revoke: (id: string) => Promise<{ invite: InviteLinkSummary }>;
reinstate: (id: string, body: ReinstateInviteRequest) => Promise<ReinstateInviteResponse>;
delete: (id: string) => Promise<{ success: boolean }>;
redemptions: (id: string) => Promise<{ redemptions: InviteRedemption[] }>;
};
readonly admin: { readonly admin: {
storageStats: () => Promise<StorageStats>; storageStats: () => Promise<StorageStats>;
storageOrphans: () => Promise<{ orphans: OrphanedFile[] }>; storageOrphans: () => Promise<{ orphans: OrphanedFile[] }>;
@@ -393,6 +411,8 @@ export class BackspaceApiClient {
request<AuthResponse>('POST', '/auth/login', data, false), request<AuthResponse>('POST', '/auth/login', data, false),
checkUsername: (username: string) => checkUsername: (username: string) =>
request<{ available: boolean; reason?: string }>('GET', `/auth/check-username?username=${encodeURIComponent(username)}`, undefined, false), request<{ available: boolean; reason?: string }>('GET', `/auth/check-username?username=${encodeURIComponent(username)}`, undefined, false),
checkInvite: (token: string) =>
request<CheckInviteResponse>('GET', `/auth/check-invite?token=${encodeURIComponent(token)}`, undefined, false),
}; };
this.users = { 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<InviteLinkSummary>('POST', '/admin/invites', body),
update: (id: string, body: UpdateInviteRequest) =>
request<InviteLinkSummary>('PATCH', `/admin/invites/${id}`, body),
revoke: (id: string) =>
request<{ invite: InviteLinkSummary }>('POST', `/admin/invites/${id}/revoke`),
reinstate: (id: string, body: ReinstateInviteRequest) =>
request<ReinstateInviteResponse>('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 = { this.admin = {
storageStats: () => request<StorageStats>('GET', '/admin/storage/stats'), storageStats: () => request<StorageStats>('GET', '/admin/storage/stats'),
storageOrphans: () => request<{ orphans: OrphanedFile[] }>('GET', '/admin/storage/orphans'), storageOrphans: () => request<{ orphans: OrphanedFile[] }>('GET', '/admin/storage/orphans'),
@@ -5,9 +5,13 @@ import { Avatar } from '../ui/Avatar';
import { ImageCropModal } from '../ui/ImageCropModal'; import { ImageCropModal } from '../ui/ImageCropModal';
import { AVATAR_GRADIENT_MAP } from '../../utils/gradients'; import { AVATAR_GRADIENT_MAP } from '../../utils/gradients';
import { AVATAR_COLORS } from '@backspace/shared'; 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'; 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'; type UsernameStatus = 'idle' | 'checking' | 'available' | 'taken' | 'invalid';
export function RegisterPage() { export function RegisterPage() {
@@ -26,6 +30,19 @@ export function RegisterPage() {
const usernameCheckTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const usernameCheckTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const usernameCheckAbortRef = useRef<AbortController | null>(null); const usernameCheckAbortRef = useRef<AbortController | null>(null);
// Instance info (for registration policy)
const [instanceInfo, setInstanceInfo] = useState<InstanceInfoResponse | null>(null);
// Invite token state
const [manualInviteToken, setManualInviteToken] = useState('');
const [inviteCheck, setInviteCheck] = useState<CheckInviteResponse | null>(null);
const [inviteChecking, setInviteChecking] = useState(false);
const inviteCheckTimerRef = useRef<ReturnType<typeof setTimeout> | 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 // Step 2 fields
const [displayName, setDisplayName] = useState(''); const [displayName, setDisplayName] = useState('');
const [avatarColor, setAvatarColor] = useState<AvatarColor>( const [avatarColor, setAvatarColor] = useState<AvatarColor>(
@@ -44,6 +61,7 @@ export function RegisterPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const redirect = searchParams.get('redirect'); const redirect = searchParams.get('redirect');
const urlInviteToken = searchParams.get('invite');
// Cleanup blob URL on unmount // Cleanup blob URL on unmount
useEffect(() => { useEffect(() => {
@@ -52,6 +70,104 @@ export function RegisterPage() {
}; };
}, [avatarPreview]); }, [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 // Debounced username availability check
useEffect(() => { useEffect(() => {
// Clear previous timer and abort // Clear previous timer and abort
@@ -128,6 +244,17 @@ export function RegisterPage() {
return () => clearInterval(timer); return () => clearInterval(timer);
}, [retryAfter]); }, [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 ── // ── Step 1 validation ──
const handleContinue = (e: React.FormEvent) => { const handleContinue = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -192,10 +319,35 @@ export function RegisterPage() {
const dn = skip ? undefined : displayName.trim() || undefined; const dn = skip ? undefined : displayName.trim() || undefined;
const ac = skip ? undefined : avatarColor; 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, // Step 1: Register via API — store token in localStorage for API auth,
// but NOT in Zustand yet so AuthRedirect doesn't fire prematurely // but NOT in Zustand yet so AuthRedirect doesn't fire prematurely
const response = await api.auth.register({ 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); localStorage.setItem('backspace_token', response.token);
@@ -235,6 +387,13 @@ export function RegisterPage() {
const isDisabled = isRegistering || retryAfter > 0; 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 ( return (
<div className="min-h-full flex items-center justify-center bg-surface-base relative"> <div className="min-h-full flex items-center justify-center bg-surface-base relative">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(124,108,246,0.06)_0%,transparent_50%)]" /> <div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(124,108,246,0.06)_0%,transparent_50%)]" />
@@ -258,6 +417,75 @@ export function RegisterPage() {
</div> </div>
)} )}
{/* 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 && (
<div className="mb-4 p-3 rounded-lg bg-surface-elevated border border-surface-border space-y-2">
<div className="text-sm text-txt-secondary">
Registration is invite-only on this instance. Paste your invite link or enter the code below.
</div>
<input
type="text"
value={manualInviteToken}
onChange={(e) => 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 && (
<div className="text-xs text-txt-tertiary">Checking...</div>
)}
{!inviteChecking && inviteCheck?.valid === true && (
<div className="text-xs text-status-online flex items-center gap-1">
<svg className="w-3 h-3" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
Valid invite: {inviteCheck.name}
</div>
)}
{!inviteChecking && inviteCheck?.valid === false && (
<div className="text-xs text-txt-danger">
{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.'}
</div>
)}
</div>
)}
{/* 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 && (
<div className="mb-4 inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-accent-primary/10 text-accent-primary text-xs">
{inviteChecking ? (
<>
<svg className="w-3 h-3 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Validating invite...
</>
) : inviteCheck?.valid === true ? (
<>
<svg className="w-3 h-3" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
Using invite: {inviteCheck.name}
</>
) : inviteCheck?.valid === false ? (
<>
<svg className="w-3 h-3" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
Invalid invite link please request a new one
</>
) : (
<>Validating invite...</>
)}
</div>
)}
<div className="mb-5"> <div className="mb-5">
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2"> <label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
Username <span className="text-txt-danger">*</span> Username <span className="text-txt-danger">*</span>
@@ -325,12 +553,19 @@ export function RegisterPage() {
<button <button
type="submit" type="submit"
disabled={usernameStatus === 'taken' || usernameStatus === 'invalid'} disabled={continueDisabled}
className="w-full py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed" className="w-full py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
> >
Continue Continue
</button> </button>
{/* Helper text when invite is required but not yet entered */}
{inviteRequired && !manualInviteToken.trim() && !urlInviteToken && (
<div className="text-xs text-txt-tertiary mt-2">
An invite is required to register on this instance.
</div>
)}
<p className="mt-3 text-sm text-txt-tertiary"> <p className="mt-3 text-sm text-txt-tertiary">
Already have an account?{' '} Already have an account?{' '}
<Link to={`/login${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ''}`} className="text-accent-primary hover:underline"> <Link to={`/login${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ''}`} className="text-accent-primary hover:underline">
@@ -203,6 +203,12 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) {
</div> </div>
</div> </div>
{!probeResult.federatedRegistrationOpen && (
<div className="mb-3 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 text-sm text-amber-300">
This instance has disabled new federated registrations. Existing accounts can still sign in.
</div>
)}
<form onSubmit={(e) => { e.preventDefault(); handleConnect(); }} className="space-y-2"> <form onSubmit={(e) => { e.preventDefault(); handleConnect(); }} className="space-y-2">
<input type="text" autoComplete="username" value={user?.username || ''} readOnly tabIndex={-1} className="sr-only" /> <input type="text" autoComplete="username" value={user?.username || ''} readOnly tabIndex={-1} className="sr-only" />
<div> <div>
@@ -28,7 +28,6 @@ export function GeneralPanel() {
const baseChanges = instanceSettings && draft const baseChanges = instanceSettings && draft
? draft.instanceName !== instanceSettings.instanceName || ? draft.instanceName !== instanceSettings.instanceName ||
draft.registrationOpen !== instanceSettings.registrationOpen ||
draft.discoveryEnabled !== instanceSettings.discoveryEnabled draft.discoveryEnabled !== instanceSettings.discoveryEnabled
: false; : false;
const hasChanges = baseChanges || gifKeyDirty; const hasChanges = baseChanges || gifKeyDirty;
@@ -39,7 +38,6 @@ export function GeneralPanel() {
try { try {
const payload: Partial<InstanceAdminSettings> = { const payload: Partial<InstanceAdminSettings> = {
instanceName: draft!.instanceName, instanceName: draft!.instanceName,
registrationOpen: draft!.registrationOpen,
discoveryEnabled: draft!.discoveryEnabled, discoveryEnabled: draft!.discoveryEnabled,
}; };
if (gifKeyDirty) { if (gifKeyDirty) {
@@ -86,20 +84,6 @@ export function GeneralPanel() {
</div> </div>
</div> </div>
{/* Registration */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Registration</div>
<div className="rounded-lg bg-white/[0.02] p-3.5">
<label className="flex items-center justify-between cursor-pointer">
<div>
<div className="text-sm font-medium text-txt-primary">Open Registration</div>
<div className="text-xs text-txt-tertiary mt-0.5">Allow new users to create accounts on this instance</div>
</div>
<Toggle enabled={draft.registrationOpen} onChange={(v) => setDraft({ ...draft, registrationOpen: v })} />
</label>
</div>
</div>
{/* Discovery */} {/* Discovery */}
<div> <div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Discovery</div> <div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Discovery</div>
File diff suppressed because it is too large Load Diff
@@ -4,12 +4,13 @@ import { useSettingsSections } from '../../../hooks/useSettingsSections';
import type { SettingsSection } from '../SettingsSectionsContext'; import type { SettingsSection } from '../SettingsSectionsContext';
import { SettingsTabBar } from '../SettingsTabBar'; import { SettingsTabBar } from '../SettingsTabBar';
import { GeneralPanel } from '../instanceSettingsPanels/GeneralPanel'; import { GeneralPanel } from '../instanceSettingsPanels/GeneralPanel';
import { RegistrationPanel } from '../instanceSettingsPanels/RegistrationPanel';
import { FederationPanel } from '../instanceSettingsPanels/FederationPanel'; import { FederationPanel } from '../instanceSettingsPanels/FederationPanel';
import { StreamingPanel } from '../instanceSettingsPanels/StreamingPanel'; import { StreamingPanel } from '../instanceSettingsPanels/StreamingPanel';
import { StoragePanel } from '../instanceSettingsPanels/StoragePanel'; import { StoragePanel } from '../instanceSettingsPanels/StoragePanel';
import { UsersPanel } from '../instanceSettingsPanels/UsersPanel'; import { UsersPanel } from '../instanceSettingsPanels/UsersPanel';
type SubTab = 'general' | 'federation' | 'streaming' | 'storage' | 'users'; type SubTab = 'general' | 'registration' | 'federation' | 'streaming' | 'storage' | 'users';
export function InstancePanel() { export function InstancePanel() {
const fetchInstanceSettings = useSettingsStore((s) => s.fetchInstanceSettings); const fetchInstanceSettings = useSettingsStore((s) => s.fetchInstanceSettings);
@@ -20,6 +21,7 @@ export function InstancePanel() {
const sections = useMemo<SettingsSection[]>(() => [ const sections = useMemo<SettingsSection[]>(() => [
{ id: 'general', label: 'General' }, { id: 'general', label: 'General' },
{ id: 'registration', label: 'Registration' },
{ id: 'federation', label: 'Federation', badgeCount: approvalCount }, { id: 'federation', label: 'Federation', badgeCount: approvalCount },
{ id: 'streaming', label: 'Streaming' }, { id: 'streaming', label: 'Streaming' },
{ id: 'storage', label: 'Storage' }, { id: 'storage', label: 'Storage' },
@@ -43,6 +45,7 @@ export function InstancePanel() {
<SettingsTabBar /> <SettingsTabBar />
{subTab === 'general' && <GeneralPanel />} {subTab === 'general' && <GeneralPanel />}
{subTab === 'registration' && <RegistrationPanel />}
{subTab === 'federation' && <FederationPanel onApprovalCountChange={setApprovalCount} />} {subTab === 'federation' && <FederationPanel onApprovalCountChange={setApprovalCount} />}
{subTab === 'streaming' && <StreamingPanel />} {subTab === 'streaming' && <StreamingPanel />}
{subTab === 'storage' && <StoragePanel />} {subTab === 'storage' && <StoragePanel />}
@@ -25,13 +25,16 @@ export function ConfirmDialog({
loading = false, loading = false,
}: ConfirmDialogProps) { }: ConfirmDialogProps) {
const handleKeyDown = useCallback((e: KeyboardEvent) => { const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape' && !loading) onClose(); if (e.key === 'Escape' && !loading) {
e.stopPropagation();
onClose();
}
}, [onClose, loading]); }, [onClose, loading]);
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
document.addEventListener('keydown', handleKeyDown); document.addEventListener('keydown', handleKeyDown, true);
return () => document.removeEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown, true);
} }
}, [isOpen, handleKeyDown]); }, [isOpen, handleKeyDown]);