feat(invites): reinstateInvite with revoked-vs-archived branching

This commit is contained in:
Jannis Braun
2026-04-28 20:20:13 +02:00
parent 45b2eabed3
commit ee405cba7c
2 changed files with 153 additions and 1 deletions
@@ -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);
});
});
+81 -1
View File
@@ -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<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);
}
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,
};
});
}