Self-registry toggle, invite links with attribution, first-user-admin

Three pieces of a single registration story.

1. **Self-registry toggle.** New generic `settings` key/value table.
   Initial key: `self_registry_enabled` (default `1`). Admin-only PATCH
   /api/settings flips it. GET /api/settings is public so the login
   screen can hide the "Opprett konto" CTA when registration is closed.

2. **Invite links.** New `invites(token, inviter_user_id, created_at,
   claimed_at, claimed_by_user_id)` table; tokens are 22-char base64url
   (~128 bits of entropy). Endpoints:
     POST   /api/invites             — create (any logged-in user)
     GET    /api/invites             — list mine
     DELETE /api/invites/:token      — cancel an unclaimed invite
   Claimed invites are kept in the DB (the audit trail of who-invited-
   whom survives) — only unclaimed ones can be cancelled.

   The signup endpoint accepts an optional `invite_token`. The signup
   handler does the claim + user-insert in a single SQLite transaction
   so we can't end up with a claimed invite pointing at a missing user.
   A concurrent claim race is closed by `UPDATE … WHERE claimed_at IS
   NULL` — only one transaction's UPDATE actually flips the column.

   New `users.invited_by` column records the inviter id so accounts have
   a traceable origin. Profile page shows the user's invites with
   "Kopier lenke" / "Avbryt" buttons; the SPA serves /invite/<token>
   into the Signup view with the token prefilled.

3. **First-user auto-admin.** The signup handler counts users *before*
   the insert; if it's the first one, `is_admin` is set on the row.
   This solves the bootstrap chicken-and-egg without an env var or
   sqlite3 step. Documented in README.

When self-registry is **off**:
  - The login screen hides "Opprett konto" and shows a "stengt" notice
  - /api/auth/signup with no invite returns 403 signup_closed
  - /api/auth/signup with a valid invite still works (and attributes)
  - /api/auth/signup with an *invalid* invite returns 403 invalid_invite

When self-registry is **on**:
  - Anyone can sign up (no invite required)
  - An invite that comes along is still consumed for attribution
  - An invalid invite is ignored — signup proceeds without attribution

26 tests still pass; typecheck clean; bundle 31.2 KB → 32.7 KB gzipped.
This commit is contained in:
Ole-Morten Duesund 2026-05-25 13:45:32 +02:00
commit 755a615f61
14 changed files with 567 additions and 37 deletions

View file

@ -4,6 +4,8 @@ import {
currentUserId, issueSession, clearSession, requireAuth, gcSessions,
type AppVariables,
} from './session';
import { isSelfRegistryEnabled } from './settings';
import { claimInvite } from './invites';
import type {
SignupRequest,
ChallengeResponse,
@ -124,6 +126,21 @@ authRoutes.post('/signup', async (c) => {
const existing = db.prepare('SELECT 1 FROM users WHERE email = ?').get(email);
if (existing) return c.json({ error: 'email_taken' }, 409);
// Gate: signup requires either (a) self-registry enabled, or (b) a valid
// unclaimed invite token. We resolve the invite path even when self-registry
// is on so the `invited_by` attribution still records who shared the link.
const inviteToken = typeof body.invite_token === 'string' ? body.invite_token.trim() : '';
const selfReg = isSelfRegistryEnabled();
if (!selfReg && !inviteToken) {
return c.json({ error: 'signup_closed' }, 403);
}
// First-user-auto-admin: count rows *before* the insert. If this is the
// first user, they become admin so the deployment is never stranded
// without one. (Documented in README.)
const userCount = (db.prepare('SELECT COUNT(*) AS n FROM users').get() as { n: number }).n;
const isFirstUser = userCount === 0;
// Server-side hash of the client-derived verifier. The verifier is already
// expensive to brute-force (Argon2id-MODERATE), so Bun.password adds a second
// hardening layer in case the DB leaks but the verifier salt is still public.
@ -136,27 +153,52 @@ authRoutes.post('/signup', async (c) => {
const id = newId();
const now = Date.now();
db.prepare(`
INSERT INTO users
(id, email, auth_salt, auth_verifier_hash, kek_salt,
wrapped_dek_pw, dek_pw_nonce, wrapped_dek_rec, rec_salt, dek_rec_nonce,
rec_auth_salt, rec_auth_verifier_hash, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
email,
b64ToBuffer(body.auth_salt),
verifierHash,
b64ToBuffer(body.kek_salt),
b64ToBuffer(body.wrapped_dek_pw),
b64ToBuffer(body.dek_pw_nonce),
b64ToBuffer(body.wrapped_dek_rec),
b64ToBuffer(body.rec_salt),
b64ToBuffer(body.dek_rec_nonce),
b64ToBuffer(body.rec_auth_salt),
recVerifierHash,
now,
);
// The user-insert and the invite-claim must commit or fail together: a
// claimed invite pointing at a nonexistent user (or a user without their
// claimed invite recorded) would be an audit-trail inconsistency.
let invitedBy: string | null = null;
try {
db.transaction(() => {
if (inviteToken) {
const inviter = claimInvite(inviteToken, id);
if (!inviter) {
if (!selfReg) throw new Error('invalid_invite');
// self-registry on + bad token = proceed without attribution
} else {
invitedBy = inviter;
}
}
db.prepare(`
INSERT INTO users
(id, email, auth_salt, auth_verifier_hash, kek_salt,
wrapped_dek_pw, dek_pw_nonce, wrapped_dek_rec, rec_salt, dek_rec_nonce,
rec_auth_salt, rec_auth_verifier_hash, is_admin, invited_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
email,
b64ToBuffer(body.auth_salt),
verifierHash,
b64ToBuffer(body.kek_salt),
b64ToBuffer(body.wrapped_dek_pw),
b64ToBuffer(body.dek_pw_nonce),
b64ToBuffer(body.wrapped_dek_rec),
b64ToBuffer(body.rec_salt),
b64ToBuffer(body.dek_rec_nonce),
b64ToBuffer(body.rec_auth_salt),
recVerifierHash,
isFirstUser ? 1 : 0,
invitedBy,
now,
);
})();
} catch (err) {
if (err instanceof Error && err.message === 'invalid_invite') {
return c.json({ error: 'invalid_invite' }, 403);
}
throw err;
}
issueSession(c, id);
gcSessions();

View file

@ -35,9 +35,12 @@ const SCHEMA_STATEMENTS: readonly string[] = [
is_moderator INTEGER NOT NULL DEFAULT 0,
-- Admin flag. Strictly stronger than moderator: admins have everything
-- moderators have, plus the /api/admin/* endpoints (promote/demote other
-- users). Bootstrapped via "sqlite3 ... UPDATE" (see README); after that,
-- admins can grant moderator/admin to others through the UI.
-- users). The first user to sign up is auto-promoted to admin so the
-- deployment is never stranded without one; after that, admins can
-- grant moderator/admin to others through the UI.
is_admin INTEGER NOT NULL DEFAULT 0,
-- Inviter, if this account was created via an invite token. Nullable.
invited_by TEXT REFERENCES users(id),
-- Optional public URL slug. When set + opt-in, the user's public
-- activities are reachable at "/<username>/list". Distinct from
-- display_name because URL slugs need uniqueness and shape constraints
@ -105,6 +108,24 @@ const SCHEMA_STATEMENTS: readonly string[] = [
PRIMARY KEY (activity_id, user_id)
)`,
`CREATE INDEX IF NOT EXISTS activity_hearts_user_idx ON activity_hearts(user_id)`,
// Global settings (key/value). Currently used for self_registry_enabled
// but kept generic so future toggles don't need their own table.
`CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
)`,
// Invites: any logged-in user generates a token; signup-via-token records
// the inviter so accounts have a traceable origin. Tokens are
// single-use; claimed_* are set when a signup consumes one.
`CREATE TABLE IF NOT EXISTS invites (
token TEXT PRIMARY KEY,
inviter_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
claimed_at INTEGER,
claimed_by_user_id TEXT REFERENCES users(id)
)`,
`CREATE INDEX IF NOT EXISTS invites_inviter_idx ON invites(inviter_user_id)`,
];
const PRAGMAS: readonly string[] = [
@ -159,6 +180,7 @@ export function getDb(): Database {
ensureColumn(db, 'users', 'display_name', 'TEXT');
ensureColumn(db, 'users', 'is_moderator', 'INTEGER NOT NULL DEFAULT 0');
ensureColumn(db, 'users', 'is_admin', 'INTEGER NOT NULL DEFAULT 0');
ensureColumn(db, 'users', 'invited_by', 'TEXT');
// Feedback triage columns (added after the feedback feature shipped).
ensureColumn(db, 'feedback', 'done_at', 'INTEGER');
ensureColumn(db, 'feedback', 'done_by', 'TEXT');

View file

@ -7,6 +7,8 @@ import { tagsRoutes } from './tags';
import { usersRoutes } from './users';
import { feedbackRoutes } from './feedback';
import { adminRoutes } from './admin';
import { settingsRoutes } from './settings';
import { invitesRoutes } from './invites';
// Initialise DB up front so the server fails fast on schema problems.
getDb();
@ -29,6 +31,8 @@ app.route('/api/tags', tagsRoutes);
app.route('/api/users', usersRoutes);
app.route('/api/feedback', feedbackRoutes);
app.route('/api/admin', adminRoutes);
app.route('/api/settings', settingsRoutes);
app.route('/api/invites', invitesRoutes);
// In production, serve the built Svelte SPA. The static helper is registered
// for the asset directory and for the top-level files that Vite copies from

136
server/invites.ts Normal file
View file

@ -0,0 +1,136 @@
import { Hono } from 'hono';
import { getDb } from './db';
import { requireAuth, type AppVariables } from './session';
import type { InviteEntry } from '../shared/types';
/**
* Invite tokens. Any logged-in user can create one and share it; signup-via-
* token records the inviter on the new user and marks the invite claimed.
*
* Tokens are single-use: each invite either has no `claimed_at` (still usable)
* or one (consumed, forever). To "regenerate" you delete-and-create.
*
* Token format: 22-char base64url of 16 random bytes (~128 bits of entropy).
* Long enough to be unguessable, short enough to share verbatim in chat.
*/
export const invitesRoutes = new Hono<{ Variables: AppVariables }>();
function newToken(): string {
const buf = new Uint8Array(16);
crypto.getRandomValues(buf);
return Buffer.from(buf)
.toString('base64')
.replaceAll('+', '-')
.replaceAll('/', '_')
.replace(/=+$/, '');
}
interface InviteRow {
token: string;
inviter_user_id: string;
created_at: number;
claimed_at: number | null;
claimed_by_user_id: string | null;
claimed_display_name: string | null;
claimed_email: string | null;
}
function toEntry(row: InviteRow, origin: string): InviteEntry {
let claimedByDisplay: string | null = null;
if (row.claimed_at && row.claimed_email) {
if (row.claimed_display_name && row.claimed_display_name.trim()) {
claimedByDisplay = row.claimed_display_name;
} else {
const at = row.claimed_email.indexOf('@');
claimedByDisplay = at > 0 ? row.claimed_email.slice(0, at) : row.claimed_email;
}
}
return {
token: row.token,
url: `${origin}/invite/${row.token}`,
created_at: row.created_at,
claimed_at: row.claimed_at,
claimed_by_display: claimedByDisplay,
};
}
/**
* Validate a token and atomically mark it claimed. Returns the inviter id
* if the token was valid + unclaimed; null otherwise. Called from signup.
*/
export function claimInvite(token: string, claimerUserId: string): string | null {
const db = getDb();
const row = db
.prepare('SELECT inviter_user_id, claimed_at FROM invites WHERE token = ?')
.get(token) as { inviter_user_id: string; claimed_at: number | null } | null;
if (!row) return null;
if (row.claimed_at !== null) return null;
// Two near-simultaneous claims could both pass the check above. Make the
// claim conditional on claimed_at still being NULL so only one wins.
const res = db
.prepare('UPDATE invites SET claimed_at = ?, claimed_by_user_id = ? WHERE token = ? AND claimed_at IS NULL')
.run(Date.now(), claimerUserId, token);
if (res.changes === 0) return null;
return row.inviter_user_id;
}
function originOf(c: { req: { url: string } }): string {
const u = new URL(c.req.url);
return `${u.protocol}//${u.host}`;
}
// --- GET /api/invites -------------------------------------------------------
// List the caller's invites (active first, then claimed).
invitesRoutes.get('/', requireAuth, (c) => {
const userId = c.get('userId');
const rows = getDb()
.prepare(`
SELECT i.token, i.inviter_user_id, i.created_at, i.claimed_at,
i.claimed_by_user_id,
u.display_name AS claimed_display_name,
u.email AS claimed_email
FROM invites i
LEFT JOIN users u ON u.id = i.claimed_by_user_id
WHERE i.inviter_user_id = ?
ORDER BY (i.claimed_at IS NOT NULL) ASC, i.created_at DESC
`)
.all(userId) as InviteRow[];
return c.json(rows.map((r) => toEntry(r, originOf(c))));
});
// --- POST /api/invites ------------------------------------------------------
invitesRoutes.post('/', requireAuth, (c) => {
const userId = c.get('userId');
const token = newToken();
getDb()
.prepare('INSERT INTO invites (token, inviter_user_id, created_at) VALUES (?, ?, ?)')
.run(token, userId, Date.now());
const row = getDb()
.prepare(`
SELECT i.token, i.inviter_user_id, i.created_at, i.claimed_at,
i.claimed_by_user_id,
NULL AS claimed_display_name, NULL AS claimed_email
FROM invites i WHERE i.token = ?
`)
.get(token) as InviteRow;
return c.json(toEntry(row, originOf(c)), 201);
});
// --- DELETE /api/invites/:token ---------------------------------------------
// Cancel an unused invite. Refuses to delete already-claimed invites so the
// audit trail (who-invited-whom) survives.
invitesRoutes.delete('/:token', requireAuth, (c) => {
const userId = c.get('userId');
const token = c.req.param('token');
const db = getDb();
const row = db
.prepare('SELECT inviter_user_id, claimed_at FROM invites WHERE token = ?')
.get(token) as { inviter_user_id: string; claimed_at: number | null } | null;
if (!row) return c.json({ error: 'not_found' }, 404);
if (row.inviter_user_id !== userId) return c.json({ error: 'forbidden' }, 403);
if (row.claimed_at !== null) return c.json({ error: 'already_claimed' }, 409);
db.prepare('DELETE FROM invites WHERE token = ?').run(token);
return c.json({ ok: true });
});

69
server/settings.ts Normal file
View file

@ -0,0 +1,69 @@
import { Hono } from 'hono';
import { getDb } from './db';
import { requireAuth, type AppVariables } from './session';
import { isAdmin } from './roles';
import type { PublicSettings, SettingsUpdateRequest } from '../shared/types';
/**
* Global site settings. Generic key/value table so future toggles don't each
* need their own column. Keys are validated against a whitelist (`KNOWN`)
* before write so we don't accumulate junk rows from typos or curious admins.
*/
export const settingsRoutes = new Hono<{ Variables: AppVariables }>();
const KNOWN = new Set<string>(['self_registry_enabled']);
const DEFAULTS: Record<string, string> = {
// Default to allowing signup so a fresh deployment is usable. An admin
// can disable it once they've bootstrapped their account.
self_registry_enabled: '1',
};
function readRaw(key: string): string {
const row = getDb()
.prepare('SELECT value FROM settings WHERE key = ?')
.get(key) as { value: string } | null;
return row?.value ?? DEFAULTS[key] ?? '';
}
function writeRaw(key: string, value: string): void {
getDb()
.prepare(`
INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
`)
.run(key, value, Date.now());
}
/** Boolean helper used by signup. */
export function isSelfRegistryEnabled(): boolean {
return readRaw('self_registry_enabled') === '1';
}
function publicSnapshot(): PublicSettings {
return {
self_registry_enabled: readRaw('self_registry_enabled') === '1',
};
}
// --- GET /api/settings ------------------------------------------------------
// Public: clients need to know whether to show the "Opprett konto" button on
// the login screen. Nothing sensitive here.
settingsRoutes.get('/', (c) => c.json(publicSnapshot()));
// --- PATCH /api/settings ----------------------------------------------------
// Admin-only.
settingsRoutes.patch('/', requireAuth, async (c) => {
const userId = c.get('userId');
if (!isAdmin(userId)) return c.json({ error: 'forbidden' }, 403);
const body = (await c.req.json().catch(() => null)) as SettingsUpdateRequest | null;
if (!body) return c.json({ error: 'invalid_json' }, 400);
for (const [k, v] of Object.entries(body)) {
if (!KNOWN.has(k)) return c.json({ error: `unknown_key:${k}` }, 400);
if (typeof v !== 'boolean') return c.json({ error: `invalid:${k}` }, 400);
writeRaw(k, v ? '1' : '0');
}
return c.json(publicSnapshot());
});