diff --git a/packages/server/src/utils/inviteService.test.ts b/packages/server/src/utils/inviteService.test.ts index 5f94b4cf..05fa529d 100644 --- a/packages/server/src/utils/inviteService.test.ts +++ b/packages/server/src/utils/inviteService.test.ts @@ -1,5 +1,52 @@ -import { describe, it, expect } from 'vitest'; -import { inviteStatus, generateInviteToken } from './inviteService.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import * as schema from '../db/schema.js'; +import { setWorkerId } from './snowflake.js'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +setWorkerId(1); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +import { inviteStatus, generateInviteToken, createInvite, getInviteByToken } from './inviteService.js'; + +function applyMigrations(db: Database.Database): void { + const migrationsDir = path.resolve(__dirname, '../../drizzle'); + const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort(); + for (const f of files) { + const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8'); + const statements = sqlText.split(/-->\s*statement-breakpoint/); + for (const stmt of statements) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +function seedAdmin(): string { + const adminId = 'admin-user-1'; + testDb.insert(schema.users).values({ + id: adminId, + username: 'admin', + passwordHash: 'x', + isAdmin: 1, + createdAt: Date.now(), + }).run(); + return adminId; +} describe('inviteStatus', () => { const base = { revokedAt: null, expiresAt: null, maxUses: null, usedCount: 0 }; @@ -54,3 +101,74 @@ describe('generateInviteToken', () => { expect(a).not.toBe(b); }); }); + +describe('createInvite', () => { + beforeEach(() => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + }); + + it('creates an invite with required fields', () => { + const adminId = seedAdmin(); + const invite = createInvite({ name: 'Friends batch 1', maxUses: 10, expiresAt: Date.now() + 86_400_000 }, adminId); + expect(invite.name).toBe('Friends batch 1'); + expect(invite.maxUses).toBe(10); + expect(invite.usedCount).toBe(0); + expect(invite.revokedAt).toBeNull(); + expect(invite.token).toMatch(/^[A-Za-z0-9_-]{22}$/); + expect(invite.status).toBe('active'); + expect(invite.createdBy).toBe(adminId); + expect(invite.createdByUsername).toBe('admin'); + }); + + it('accepts null maxUses (unlimited)', () => { + const adminId = seedAdmin(); + const invite = createInvite({ name: 'unlimited', maxUses: null, expiresAt: null }, adminId); + expect(invite.maxUses).toBeNull(); + expect(invite.expiresAt).toBeNull(); + expect(invite.status).toBe('active'); + }); + + it('rejects empty name', () => { + const adminId = seedAdmin(); + expect(() => createInvite({ name: '', maxUses: null, expiresAt: null }, adminId)).toThrow(); + }); + + it('rejects name longer than 64 chars', () => { + const adminId = seedAdmin(); + expect(() => createInvite({ name: 'x'.repeat(65), maxUses: null, expiresAt: null }, adminId)).toThrow(); + }); + + it('rejects non-positive maxUses', () => { + const adminId = seedAdmin(); + expect(() => createInvite({ name: 'a', maxUses: 0, expiresAt: null }, adminId)).toThrow(); + expect(() => createInvite({ name: 'a', maxUses: -1, expiresAt: null }, adminId)).toThrow(); + }); + + it('rejects past expiresAt', () => { + const adminId = seedAdmin(); + expect(() => createInvite({ name: 'a', maxUses: null, expiresAt: Date.now() - 1000 }, adminId)).toThrow(); + }); +}); + +describe('getInviteByToken', () => { + beforeEach(() => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + }); + + it('returns the invite when token matches', () => { + const adminId = seedAdmin(); + const created = createInvite({ name: 'a', maxUses: null, expiresAt: null }, adminId); + const found = getInviteByToken(created.token); + expect(found?.id).toBe(created.id); + }); + + it('returns null when token not found', () => { + expect(getInviteByToken('nonexistent_token_aaaaaa')).toBeNull(); + }); +}); diff --git a/packages/server/src/utils/inviteService.ts b/packages/server/src/utils/inviteService.ts index f1de9245..97efde7b 100644 --- a/packages/server/src/utils/inviteService.ts +++ b/packages/server/src/utils/inviteService.ts @@ -1,4 +1,9 @@ import crypto from 'node:crypto'; +import { eq } from 'drizzle-orm'; +import { getDb, schema } from '../db/index.js'; +import { generateSnowflake } from './snowflake.js'; +import { config } from '../config.js'; +import type { InviteLinkSummary, CreateInviteRequest } from '@backspace/shared'; /** * Derived status of an invite link. Mirrors the `InviteStatus` union exported @@ -39,3 +44,156 @@ export function inviteStatus(row: InviteStatusInput): InviteStatus { 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 (1–64 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; +} + +/** + * Project an `invite_links` row plus the resolved creator-username 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. + */ +function rowToSummary( + row: typeof schema.inviteLinks.$inferSelect, + createdByUsername: string | 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, + url: buildInviteUrl(row.token), + }; +} + +/** + * 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). + */ +function resolveCreatorUsername(creatorId: string): string | null { + const db = getDb(); + const u = db.select({ username: schema.users.username, isDeleted: schema.users.isDeleted }) + .from(schema.users) + .where(eq(schema.users.id, creatorId)) + .get(); + if (!u) return null; + if (u.isDeleted === 1) return 'Deleted User'; + return u.username; +} + +/** + * 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'); + return rowToSummary(row, resolveCreatorUsername(creatorId)); +} + +/** + * 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; +}