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:
+73
-2
@@ -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
@@ -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
@@ -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. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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),
|
||||||
|
}));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
// instanceRow is guaranteed by ensureDefaults() (migrate.ts) to have id=1
|
||||||
|
// post-boot, with federatedRegistrationOpen NOT NULL DEFAULT 1. The optional
|
||||||
|
// chain is defensive against the impossible-in-production case of a missing
|
||||||
|
// row (e.g., a hand-cleared DB) — falls open-closed rather than open-open
|
||||||
|
// for federation, which is the safer default.
|
||||||
|
const federatedRegistrationOpen = instanceRow?.federatedRegistrationOpen === 1;
|
||||||
|
|
||||||
|
// Optional invite token. Only meaningful for the local-closed path; ignored
|
||||||
|
// entirely on the federated path (spec §1.3, §5.6) and on the local-open path
|
||||||
|
// (spec §5.7).
|
||||||
|
const inviteToken = typeof request.body.inviteToken === 'string'
|
||||||
|
? request.body.inviteToken
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (homeInstance) {
|
||||||
|
// Federated path: token IGNORED entirely. Gate is federatedRegistrationOpen.
|
||||||
|
if (!federatedRegistrationOpen) {
|
||||||
|
return reply.code(403).send({ error: 'Federated registration is closed on this instance', statusCode: 403 });
|
||||||
|
}
|
||||||
|
// Fall through to existing federated stub upgrade / new federated user logic below.
|
||||||
|
} else {
|
||||||
|
// Local path: registrationOpen is the primary gate. A valid invite token
|
||||||
|
// bypasses it when closed. When open, the token is silently ignored.
|
||||||
if (!registrationOpen) {
|
if (!registrationOpen) {
|
||||||
return reply.code(403).send({ error: 'Registration is currently closed', statusCode: 403 });
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,600 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { eq, desc, sql } from 'drizzle-orm';
|
||||||
|
import { getDb, schema } from '../db/index.js';
|
||||||
|
import { generateSnowflake } from './snowflake.js';
|
||||||
|
import { config } from '../config.js';
|
||||||
|
import type {
|
||||||
|
InviteLinkSummary,
|
||||||
|
CreateInviteRequest,
|
||||||
|
InviteRedemption,
|
||||||
|
UpdateInviteRequest,
|
||||||
|
ReinstateInviteRequest,
|
||||||
|
ReinstateInviteResponse,
|
||||||
|
} from '@backspace/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derived status of an invite link. Mirrors the `InviteStatus` union exported
|
||||||
|
* from `@backspace/shared` (kept side-by-side intentionally — the shared union
|
||||||
|
* defines the API contract, this local one drives internal service logic, and
|
||||||
|
* keeping them independent lets a drift surface as a real type error).
|
||||||
|
*/
|
||||||
|
export type InviteStatus = 'active' | 'expired' | 'exhausted' | 'revoked';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal row shape needed to derive an invite's status. Matches the relevant
|
||||||
|
* columns of `invite_links` (revokedAt, expiresAt, maxUses, usedCount).
|
||||||
|
*/
|
||||||
|
export interface InviteStatusInput {
|
||||||
|
revokedAt: number | null;
|
||||||
|
expiresAt: number | null;
|
||||||
|
maxUses: number | null;
|
||||||
|
usedCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the current status of an invite from its row. Precedence order:
|
||||||
|
* revoked > expired > exhausted > active. A `maxUses` of `null` means
|
||||||
|
* unlimited; an `expiresAt` of `null` means no expiry.
|
||||||
|
*/
|
||||||
|
export function inviteStatus(row: InviteStatusInput): InviteStatus {
|
||||||
|
if (row.revokedAt !== null) return 'revoked';
|
||||||
|
if (row.expiresAt !== null && row.expiresAt < Date.now()) return 'expired';
|
||||||
|
if (row.maxUses !== null && row.usedCount >= row.maxUses) return 'exhausted';
|
||||||
|
return 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a fresh invite token: 16 random bytes encoded as base64url, which
|
||||||
|
* yields a 22-character URL-safe string (16 * 8 / 6 = 21.33, rounded up; no
|
||||||
|
* `=` padding because base64url omits it).
|
||||||
|
*/
|
||||||
|
export function generateInviteToken(): string {
|
||||||
|
return crypto.randomBytes(16).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown by invite-service mutations when caller-supplied input violates a
|
||||||
|
* field rule (length, sign, ordering, etc.). Caller (HTTP route) maps this to
|
||||||
|
* a 400 Bad Request with the message as `error`.
|
||||||
|
*/
|
||||||
|
export class InviteValidationError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'InviteValidationError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the public-facing invite URL embedded in API responses. Production
|
||||||
|
* deployments always set `DOMAIN`; the localhost fallback is only used in
|
||||||
|
* local dev (where `config.host` is typically `0.0.0.0` and unusable as a
|
||||||
|
* URL host). Per spec §1.2 + §5.4 the server owns URL construction so
|
||||||
|
* clients never have to assemble it.
|
||||||
|
*/
|
||||||
|
function buildInviteUrl(token: string): string {
|
||||||
|
if (config.domain) return `https://${config.domain}/register?invite=${token}`;
|
||||||
|
return `http://localhost:${config.port}/register?invite=${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the invite name (1–64 chars after trim). Trimming is part of
|
||||||
|
* normalization so `" foo "` is stored as `"foo"`.
|
||||||
|
*/
|
||||||
|
function validateName(name: string): string {
|
||||||
|
const trimmed = (name ?? '').trim();
|
||||||
|
if (trimmed.length < 1 || trimmed.length > 64) {
|
||||||
|
throw new InviteValidationError('Name must be 1-64 characters');
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate `maxUses`: `null` means unlimited; otherwise a positive integer.
|
||||||
|
* Zero is rejected because an invite that can never be used is meaningless
|
||||||
|
* (use revoke for that).
|
||||||
|
*/
|
||||||
|
function validateMaxUses(maxUses: number | null): number | null {
|
||||||
|
if (maxUses === null) return null;
|
||||||
|
if (!Number.isInteger(maxUses) || maxUses < 1) {
|
||||||
|
throw new InviteValidationError('maxUses must be a positive integer or null');
|
||||||
|
}
|
||||||
|
return maxUses;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate `expiresAt` (epoch ms). On create, must be in the future; on
|
||||||
|
* patch/reinstate, `allowPast` lets admins keep an unchanged past value or
|
||||||
|
* deliberately set a past expiry to soft-shut. `Date.now()` exactly is not
|
||||||
|
* "in the future" and is rejected when `allowPast` is false.
|
||||||
|
*/
|
||||||
|
function validateExpiresAt(expiresAt: number | null, allowPast: boolean): number | null {
|
||||||
|
if (expiresAt === null) return null;
|
||||||
|
if (!Number.isInteger(expiresAt)) {
|
||||||
|
throw new InviteValidationError('expiresAt must be an integer epoch ms or null');
|
||||||
|
}
|
||||||
|
if (!allowPast && expiresAt <= Date.now()) {
|
||||||
|
throw new InviteValidationError('expiresAt must be in the future');
|
||||||
|
}
|
||||||
|
return expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Folds a (username, isDeleted) pair into the display string used by
|
||||||
|
* InviteLinkSummary.createdByUsername / InviteRedemption.currentUsername.
|
||||||
|
*
|
||||||
|
* - null username → null (FK unresolvable; should be rare, defensive)
|
||||||
|
* - isDeleted=1 → 'Deleted User' (matches sanitizeUser convention)
|
||||||
|
* - else → username
|
||||||
|
*/
|
||||||
|
function foldUsername(username: string | null, isDeleted: number | null): string | null {
|
||||||
|
if (username === null) return null;
|
||||||
|
return isDeleted === 1 ? 'Deleted User' : username;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project an `invite_links` row plus the resolved creator-username and the
|
||||||
|
* most-recent redemption timestamp into the shared `InviteLinkSummary` shape.
|
||||||
|
* Centralized so list/create/patch/reinstate all return identically-shaped
|
||||||
|
* rows. Status is derived (never stored) per spec §2.1.
|
||||||
|
*
|
||||||
|
* `lastRedeemedAt` is `null` when the invite has zero redemptions. Callers
|
||||||
|
* that run inside a transaction pass the `MAX(redeemed_at)` they read inside
|
||||||
|
* the same transaction handle; `listInvites` bakes this into the SELECT
|
||||||
|
* projection as a correlated subquery.
|
||||||
|
*/
|
||||||
|
function rowToSummary(
|
||||||
|
row: typeof schema.inviteLinks.$inferSelect,
|
||||||
|
createdByUsername: string | null,
|
||||||
|
lastRedeemedAt: number | null,
|
||||||
|
): InviteLinkSummary {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
token: row.token,
|
||||||
|
name: row.name,
|
||||||
|
status: inviteStatus(row),
|
||||||
|
maxUses: row.maxUses,
|
||||||
|
usedCount: row.usedCount,
|
||||||
|
expiresAt: row.expiresAt,
|
||||||
|
revokedAt: row.revokedAt,
|
||||||
|
createdBy: row.createdBy,
|
||||||
|
createdByUsername,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
lastRedeemedAt,
|
||||||
|
url: buildInviteUrl(row.token),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query `MAX(redeemed_at)` for a single invite from the `invite_redemptions`
|
||||||
|
* table. Returns `null` when there are no redemption rows for this invite.
|
||||||
|
*
|
||||||
|
* Accepts an optional Drizzle handle so mutation callers inside a transaction
|
||||||
|
* body can read the max from the same logical transaction as their surrounding
|
||||||
|
* writes. Defaults to the outer `getDb()` for non-txn callers.
|
||||||
|
*/
|
||||||
|
function resolveLastRedeemedAt(
|
||||||
|
inviteId: string,
|
||||||
|
dbHandle: ReturnType<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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user