Files
backspace/packages/server/src/utils/inviteService.ts
T

601 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (164 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();
}