diff --git a/packages/server/src/utils/inviteService.test.ts b/packages/server/src/utils/inviteService.test.ts index 56f39865..4cd0e501 100644 --- a/packages/server/src/utils/inviteService.test.ts +++ b/packages/server/src/utils/inviteService.test.ts @@ -23,6 +23,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 { eq } from 'drizzle-orm'; function applyMigrations(db: Database.Database): void { @@ -374,3 +375,74 @@ describe('revokeInvite', () => { expect(() => revokeInvite('nonexistent')).toThrow(InviteNotFoundError); }); }); + +describe('reinstateInvite', () => { + beforeEach(() => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + }); + + it('Path A — was revoked: rotates token, clears revokedAt, applies bumps', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: 5, expiresAt: null }, adminId); + const originalToken = inv.token; + revokeInvite(inv.id); + + const result = reinstateInvite(inv.id, { maxUses: 10 }); + expect(result.tokenRotated).toBe(true); + expect(result.invite.token).not.toBe(originalToken); + expect(result.invite.token).toMatch(/^[A-Za-z0-9_-]{22}$/); + expect(result.invite.revokedAt).toBeNull(); + expect(result.invite.maxUses).toBe(10); + expect(result.invite.status).toBe('active'); + }); + + it('Path B — exhausted: keeps same token, applies maxUses bump', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: 1, expiresAt: null }, adminId); + // Exhaust it + testDb.update(schema.inviteLinks).set({ usedCount: 1 }).where(eq(schema.inviteLinks.id, inv.id)).run(); + + const result = reinstateInvite(inv.id, { maxUses: 5 }); + expect(result.tokenRotated).toBe(false); + expect(result.invite.token).toBe(inv.token); + expect(result.invite.maxUses).toBe(5); + expect(result.invite.status).toBe('active'); + }); + + it('Path B — expired: keeps same token, bumps expiresAt', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: null, expiresAt: Date.now() + 100_000 }, adminId); + // Move to past to expire + testDb.update(schema.inviteLinks).set({ expiresAt: Date.now() - 1000 }).where(eq(schema.inviteLinks.id, inv.id)).run(); + + const future = Date.now() + 86_400_000; + const result = reinstateInvite(inv.id, { expiresAt: future }); + expect(result.tokenRotated).toBe(false); + expect(result.invite.token).toBe(inv.token); + expect(result.invite.expiresAt).toBe(future); + expect(result.invite.status).toBe('active'); + }); + + it('Path C — already-active: throws InviteStateConflictError', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: null, expiresAt: null }, adminId); + expect(() => reinstateInvite(inv.id, {})).toThrow(InviteStateConflictError); + }); + + it('throws InviteValidationError when result is still non-active (caller did not bump enough)', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: 1, expiresAt: null }, adminId); + // Exhaust it + testDb.update(schema.inviteLinks).set({ usedCount: 1 }).where(eq(schema.inviteLinks.id, inv.id)).run(); + + // Caller did not bump maxUses → still exhausted → must throw + expect(() => reinstateInvite(inv.id, {})).toThrow(InviteValidationError); + + // Verify the txn rolled back: row still has maxUses=1 (no partial update) + const row = testDb.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, inv.id)).get(); + expect(row?.maxUses).toBe(1); + }); +}); diff --git a/packages/server/src/utils/inviteService.ts b/packages/server/src/utils/inviteService.ts index fe3190e8..d318227f 100644 --- a/packages/server/src/utils/inviteService.ts +++ b/packages/server/src/utils/inviteService.ts @@ -3,7 +3,14 @@ import { eq, desc } 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 } from '@backspace/shared'; +import type { + InviteLinkSummary, + CreateInviteRequest, + InviteRedemption, + UpdateInviteRequest, + ReinstateInviteRequest, + ReinstateInviteResponse, +} from '@backspace/shared'; /** * Derived status of an invite link. Mirrors the `InviteStatus` union exported @@ -381,3 +388,76 @@ export function revokeInvite(id: string): InviteLinkSummary { return rowToSummary(updated, resolveCreatorUsername(updated.createdBy, 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 = {}; + 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); + } + + 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)), + tokenRotated, + }; + }); +}