From 95737ba405484e6efb3911e5beab7c12083586a2 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 28 Apr 2026 20:26:57 +0200 Subject: [PATCH] feat(invites): redeemInvite (atomic txn) + deleteInvite --- .../server/src/utils/inviteService.test.ts | 143 ++++++++++++++++++ packages/server/src/utils/inviteService.ts | 90 +++++++++++ 2 files changed, 233 insertions(+) diff --git a/packages/server/src/utils/inviteService.test.ts b/packages/server/src/utils/inviteService.test.ts index 4ec5e7da..569a98b6 100644 --- a/packages/server/src/utils/inviteService.test.ts +++ b/packages/server/src/utils/inviteService.test.ts @@ -24,6 +24,7 @@ vi.mock('../db/index.js', () => ({ import { inviteStatus, generateInviteToken, createInvite, getInviteByToken, listInvites, listRedemptions, InviteValidationError } from './inviteService.js'; import { patchInvite, revokeInvite, InviteStateConflictError, InviteNotFoundError } from './inviteService.js'; import { reinstateInvite } from './inviteService.js'; +import { redeemInvite, deleteInvite, InviteUnavailableError } from './inviteService.js'; import { eq } from 'drizzle-orm'; function applyMigrations(db: Database.Database): void { @@ -464,3 +465,145 @@ describe('reinstateInvite', () => { expect(row?.usedCount).toBe(1); }); }); + +describe('redeemInvite', () => { + beforeEach(() => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + }); + + it('happy path: increments usedCount, writes redemption row, calls insertUser callback', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: 5, expiresAt: null }, adminId); + const newUserId = 'new-user-1'; + const newUsername = 'alice'; + + let insertCalled = false; + const result = redeemInvite(inv.token, () => { + insertCalled = true; + // Caller's INSERT — uses the outer testDb handle (mocked getDb), which + // is correctly serialized into the same logical txn by better-sqlite3. + testDb.insert(schema.users).values({ + id: newUserId, + username: newUsername, + passwordHash: 'x', + createdAt: Date.now(), + }).run(); + return { id: newUserId, username: newUsername }; + }); + + expect(insertCalled).toBe(true); + expect(result.id).toBe(newUserId); + expect(result.username).toBe(newUsername); + + // user row exists + const user = testDb.select().from(schema.users).where(eq(schema.users.id, newUserId)).get(); + expect(user?.username).toBe(newUsername); + + // usedCount incremented + const row = testDb.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, inv.id)).get(); + expect(row?.usedCount).toBe(1); + + // redemption row written + const redemptions = testDb.select().from(schema.inviteRedemptions).where(eq(schema.inviteRedemptions.inviteId, inv.id)).all(); + expect(redemptions).toHaveLength(1); + expect(redemptions[0]?.userId).toBe(newUserId); + expect(redemptions[0]?.registrantUsername).toBe(newUsername); + }); + + it('throws InviteUnavailableError when status is not active under txn (exhausted)', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: 1, expiresAt: null }, adminId); + // Simulate "another concurrent registration consumed the last slot just before this call" + testDb.update(schema.inviteLinks).set({ usedCount: 1 }).where(eq(schema.inviteLinks.id, inv.id)).run(); + + let insertCalled = false; + expect(() => redeemInvite(inv.token, () => { + insertCalled = true; + return { id: 'x', username: 'x' }; + })).toThrow(InviteUnavailableError); + + expect(insertCalled).toBe(false); + // No redemption row + const redemptions = testDb.select().from(schema.inviteRedemptions).where(eq(schema.inviteRedemptions.inviteId, inv.id)).all(); + expect(redemptions).toHaveLength(0); + }); + + it('aborts transaction (no usedCount increment, no redemption row) if insertUser throws', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: 5, expiresAt: null }, adminId); + + expect(() => redeemInvite(inv.token, () => { + throw new Error('username collision'); + })).toThrow('username collision'); + + // usedCount unchanged + const row = testDb.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, inv.id)).get(); + expect(row?.usedCount).toBe(0); + + // No redemption row + const redemptions = testDb.select().from(schema.inviteRedemptions).where(eq(schema.inviteRedemptions.inviteId, inv.id)).all(); + expect(redemptions).toHaveLength(0); + }); + + it('throws InviteUnavailableError when token not found', () => { + expect(() => redeemInvite('aaaaaaaaaaaaaaaaaaaaaa', () => ({ id: 'x', username: 'x' }))).toThrow(InviteUnavailableError); + }); + + it('throws InviteUnavailableError on revoked invite', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: null, expiresAt: null }, adminId); + revokeInvite(inv.id); + + let insertCalled = false; + expect(() => redeemInvite(inv.token, () => { + insertCalled = true; + return { id: 'x', username: 'x' }; + })).toThrow(InviteUnavailableError); + + expect(insertCalled).toBe(false); + }); +}); + +describe('deleteInvite', () => { + beforeEach(() => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + }); + + it('deletes the invite and CASCADE-removes redemption rows', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: null, expiresAt: null }, adminId); + + // Seed a redemption referencing this invite + const userId = 'redeemer-1'; + testDb.insert(schema.users).values({ + id: userId, username: 'redeemer', passwordHash: 'x', createdAt: Date.now(), + }).run(); + testDb.insert(schema.inviteRedemptions).values({ + id: 'red-del-1', + inviteId: inv.id, + userId, + registrantUsername: 'redeemer', + redeemedAt: Date.now(), + }).run(); + + deleteInvite(inv.id); + + // Invite row gone + const inviteRow = testDb.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, inv.id)).get(); + expect(inviteRow).toBeUndefined(); + + // Redemption row CASCADE-deleted + const redemptions = testDb.select().from(schema.inviteRedemptions).where(eq(schema.inviteRedemptions.inviteId, inv.id)).all(); + expect(redemptions).toHaveLength(0); + }); + + it('throws InviteNotFoundError when id not found', () => { + expect(() => deleteInvite('nonexistent')).toThrow(InviteNotFoundError); + }); +}); diff --git a/packages/server/src/utils/inviteService.ts b/packages/server/src/utils/inviteService.ts index c068f795..3ef0541c 100644 --- a/packages/server/src/utils/inviteService.ts +++ b/packages/server/src/utils/inviteService.ts @@ -467,3 +467,93 @@ export function reinstateInvite(id: string, req: ReinstateInviteRequest): Reinst }; }); } + +/** + * 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 string is preserved in the + * message for debugging — the user-facing copy is constructed by the route. + */ +export class InviteUnavailableError extends Error { + constructor(reason: string) { + 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') { + 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(); +}