feat(auth): split registration gate by homeInstance + atomic invite redemption
The /api/auth/register handler now branches on homeInstance: - Local path (no homeInstance): gated by registrationOpen. When closed, a valid inviteToken bypasses the gate and is consumed atomically inside redeemInvite()'s transaction (user insert + usedCount bump + redemption row all commit together, or all roll back). When open, inviteToken is silently ignored. - Federated path (homeInstance set): gated by federatedRegistrationOpen. Token is ignored entirely on this path -- tokens never unlock federated creation. Closed → 403 with "Federated registration is closed". InviteUnavailableError thrown by redeemInvite() (concurrent revoke, last-slot race, expiry between check-invite and submit) is mapped to 403 "Invalid or expired invite". The in-txn re-derive closes the TOCTOU window. 9 new tests cover the toggle matrix from spec §5.6 + invite consumption semantics + federated-gate independence + last-slot race rejection. Updates docs/systems/auth.md: rewrites the Registration Gate section to describe the three-path model (open / invite / federated), adds the toggle matrix, adds an Invite Tokens subsection with the atomic-redemption shape, notes that the federated stub upgrade is always gated by federatedRegistrationOpen, never by an invite token.
This commit is contained in:
+57
-11
@@ -118,11 +118,54 @@ There is **no token blocklist**. The only revocation mechanism is the `passwordC
|
||||
|
||||
### Registration Gate
|
||||
|
||||
Registration open/closed is determined by:
|
||||
The `/api/auth/register` route splits its gate by request shape (spec §1.2). There are **two independent toggles** plus an **invite-token bypass** for the local path.
|
||||
|
||||
**Local anonymous signup** (no `homeInstance` in body):
|
||||
|
||||
1. `instanceSettings.registrationOpen` (DB, id=1) -- if not null, this takes priority
|
||||
2. `config.registrationOpen` (env `REGISTRATION_OPEN`, default `true`) -- fallback
|
||||
|
||||
Both are checked. DB value overrides env when explicitly set by admin.
|
||||
DB value overrides env when explicitly set by admin. When closed, a valid `inviteToken` bypasses the gate and is **atomically consumed** alongside the user insert (see "Invite Tokens" below). When open, an `inviteToken` field is **silently ignored** (no validation, no consumption).
|
||||
|
||||
**Federated identity replication** (request body has `homeInstance`):
|
||||
|
||||
- Gated solely by `instanceSettings.federatedRegistrationOpen` (NOT NULL DEFAULT 1).
|
||||
- `inviteToken` is **ignored entirely** on this path -- tokens never unlock federated creation, even when supplied.
|
||||
- Closed → 403 `"Federated registration is closed on this instance"`.
|
||||
|
||||
**Invariants** (spec §1.3):
|
||||
|
||||
- **Login 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.
|
||||
|
||||
**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 |
|
||||
|
||||
### 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.
|
||||
|
||||
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"`.
|
||||
|
||||
The `/api/auth/check-invite` debounced UX endpoint pre-validates a token from the register page; the in-txn re-derive inside `redeemInvite()` is the authoritative enforcement point.
|
||||
|
||||
### First-User Admin Promotion
|
||||
|
||||
@@ -144,18 +187,21 @@ If `requestedAvatarColor` is provided and is in `AVATAR_COLORS`, use it. Otherwi
|
||||
### Registration Steps
|
||||
|
||||
1. Validate inputs (username format, password length)
|
||||
2. Check registration is open
|
||||
3. **Federated stub upgrade check** (if `homeInstance` is set): call `findFederatedUser` to look for an existing relay-created stub. If found and upgradeable, upgrade it instead of creating a new record (see below).
|
||||
4. Check username uniqueness (exact match on lowercased username)
|
||||
5. Hash password (bcrypt, 12 rounds)
|
||||
6. Generate Snowflake ID
|
||||
7. Insert user row (status defaults to `'offline'` at the schema level; it is set to `'online'` only when the client establishes a WebSocket via the WS auth path in `ws/handler.ts`). Admin flag set if first user.
|
||||
8. Sign JWT with `{ userId, username }`
|
||||
9. Return `{ token, user }` (user sanitized via `sanitizeUser(user, true)`)
|
||||
2. Read both registration gates from `instance_settings`
|
||||
3. **Branch by request shape** (spec §1.2):
|
||||
- If `homeInstance` set → reject with 403 unless `federatedRegistrationOpen === true`. `inviteToken` ignored on this path.
|
||||
- Else (local) → if `registrationOpen` is false, require a valid `inviteToken`; otherwise reject with 403. Pre-flight token check rejects obvious-invalid tokens before bcrypt.
|
||||
4. **Federated stub upgrade check** (if `homeInstance` is set): call `findFederatedUser` to look for an existing relay-created stub. If found and upgradeable, upgrade it instead of creating a new record (see below).
|
||||
5. Check username uniqueness (exact match on lowercased username)
|
||||
6. Hash password (bcrypt, 12 rounds)
|
||||
7. Generate Snowflake ID
|
||||
8. Insert user row (status defaults to `'offline'` at the schema level; it is set to `'online'` only when the client establishes a WebSocket via the WS auth path in `ws/handler.ts`). Admin flag set if first user. **When the local-closed-with-token path is in play**, the insert runs inside `redeemInvite()`'s transaction so the user row, the `usedCount` bump, and the `invite_redemptions` row commit atomically (or all roll back).
|
||||
9. Sign JWT with `{ userId, username }`
|
||||
10. Return `{ token, user }` (user sanitized via `sanitizeUser(user, true)`)
|
||||
|
||||
### Federated Stub Upgrade
|
||||
|
||||
When a user registers with `homeInstance` set (federated registration via friend-connect), the registration path checks for an existing relay-created stub using `findFederatedUser`. If found and the stub has `passwordHash = '!federation-replicated'` (not a real account), the stub is upgraded:
|
||||
When a user registers with `homeInstance` set (federated registration via friend-connect), the registration path checks for an existing relay-created stub using `findFederatedUser`. The stub-upgrade flow is **always gated by `federatedRegistrationOpen`**, never by an invite token (spec §1.3). If found and the stub has `passwordHash = '!federation-replicated'` (not a real account), the stub is upgraded:
|
||||
|
||||
- `passwordHash` is set to the new bcrypt hash (enables login)
|
||||
- `username` is updated to the registration's chosen username (replaces placeholder like `291255103060533248@nova.ddns.net` with `nova@nova.ddns.net`)
|
||||
|
||||
Reference in New Issue
Block a user