diff --git a/packages/server/src/utils/inviteService.test.ts b/packages/server/src/utils/inviteService.test.ts new file mode 100644 index 00000000..5f94b4cf --- /dev/null +++ b/packages/server/src/utils/inviteService.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { inviteStatus, generateInviteToken } from './inviteService.js'; + +describe('inviteStatus', () => { + const base = { revokedAt: null, expiresAt: null, maxUses: null, usedCount: 0 }; + + it('returns active for a fresh invite', () => { + expect(inviteStatus(base)).toBe('active'); + }); + + it('returns revoked when revokedAt is set', () => { + expect(inviteStatus({ ...base, revokedAt: 100 })).toBe('revoked'); + }); + + it('returns expired when expiresAt is past', () => { + expect(inviteStatus({ ...base, expiresAt: Date.now() - 1000 })).toBe('expired'); + }); + + it('returns active when expiresAt is future', () => { + expect(inviteStatus({ ...base, expiresAt: Date.now() + 100_000 })).toBe('active'); + }); + + it('returns exhausted when usedCount >= maxUses', () => { + expect(inviteStatus({ ...base, maxUses: 5, usedCount: 5 })).toBe('exhausted'); + expect(inviteStatus({ ...base, maxUses: 5, usedCount: 6 })).toBe('exhausted'); + }); + + it('returns active when usedCount < maxUses', () => { + expect(inviteStatus({ ...base, maxUses: 5, usedCount: 4 })).toBe('active'); + }); + + it('revoked beats expired', () => { + expect(inviteStatus({ ...base, revokedAt: 100, expiresAt: Date.now() - 1000 })).toBe('revoked'); + }); + + it('expired beats exhausted', () => { + expect(inviteStatus({ ...base, expiresAt: Date.now() - 1000, maxUses: 5, usedCount: 5 })).toBe('expired'); + }); + + it('treats maxUses null as unlimited', () => { + expect(inviteStatus({ ...base, maxUses: null, usedCount: 1_000_000 })).toBe('active'); + }); +}); + +describe('generateInviteToken', () => { + it('returns 22-char base64url string', () => { + const t = generateInviteToken(); + expect(t).toMatch(/^[A-Za-z0-9_-]{22}$/); + }); + + it('returns different tokens on subsequent calls', () => { + const a = generateInviteToken(); + const b = generateInviteToken(); + expect(a).not.toBe(b); + }); +}); diff --git a/packages/server/src/utils/inviteService.ts b/packages/server/src/utils/inviteService.ts new file mode 100644 index 00000000..f1de9245 --- /dev/null +++ b/packages/server/src/utils/inviteService.ts @@ -0,0 +1,41 @@ +import crypto from 'node:crypto'; + +/** + * 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'); +}