import { randomBytes } from "node:crypto"; import { APIErrorCode, APIException } from "../model/interfaces.ts"; import { db, isInviteRow } from "../model/db.ts"; import { UNUSED_INVITES_RETENTION_DAYS } from "../config.ts"; /** Short, opaque, URL-safe invite token (128 bits of entropy → 22 chars). */ function generateInviteToken(): string { return randomBytes(16).toString("base64url"); } export function createInvite(inviterId: string): string { const token = generateInviteToken(); db.prepare( `INSERT INTO invites (token, inviter_id, created_at) VALUES (?, ?, ?);`, ).run(token, inviterId, new Date().toISOString()); return token; } /** * Looks the token up, checks it has not been used and has not expired (same * 7-day window as the startup cleanup). Returns the inviterId on success; * throws APIException otherwise. * * Tokens are opaque random strings stored as the row's primary key. Invites * created before this scheme (full JWTs) are still stored verbatim in the * token column, so they continue to validate by direct lookup. */ export function validateInvite(token: string): string { const row = db.prepare( `SELECT token, inviter_id, used_at, created_at FROM invites WHERE token = ?;`, ).get(token); if (!row || !isInviteRow(row)) { throw new APIException( APIErrorCode.NOT_FOUND, 404, "Invalid or expired invite", ); } if (row.used_at !== null) { throw new APIException( APIErrorCode.VALIDATION_ERROR, 409, "Invite already used", ); } const expiresAt = new Date(row.created_at).getTime() + UNUSED_INVITES_RETENTION_DAYS * 24 * 60 * 60 * 1000; if (Date.now() > expiresAt) { throw new APIException( APIErrorCode.NOT_FOUND, 404, "Invalid or expired invite", ); } return row.inviter_id; } /** * Marks the token as used. Call this only after the user has been created. */ export function redeemInvite(token: string): void { db.prepare( `UPDATE invites SET used_at = ? WHERE token = ?;`, ).run(new Date().toISOString(), token); }