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