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 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 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
|
### First-User Admin Promotion
|
||||||
|
|
||||||
@@ -144,18 +187,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`)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|||||||
import Fastify, { type FastifyInstance } from 'fastify';
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
@@ -203,3 +204,254 @@ describe('GET /api/auth/check-invite', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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({ registrationOpen: 0, 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,7 +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 } from '../utils/inviteService.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', {
|
||||||
@@ -76,13 +76,47 @@ 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;
|
||||||
|
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);
|
||||||
@@ -175,7 +209,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,
|
||||||
@@ -185,7 +219,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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user