/** * Makes invites, and checks and claims them at sign-up. An invite works once, * for `UNUSED_INVITES_RETENTION_DAYS` days. `routes/invites.ts` makes and * checks them, and `POST /api/users/register` claims one. * * @module */ import { randomBytes } from "node:crypto"; import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import { db } from "../db/connection.ts"; import { isInviteRow } from "../model/invite.ts"; import { UNUSED_INVITES_RETENTION_DAYS } from "../config.ts"; /** Returns 128 random bits as 22 URL-safe characters. */ function generateInviteToken(): string { return randomBytes(16).toString("base64url"); } /** * Makes an invite from `inviterId` and returns its token, which the client * puts in the invite link. */ 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; } /** * Returns the id of the inviter when invite `token` is unused and still in its * window, which is the one after which `db/startup.ts` purges it. It doesn't * claim the invite: sign-up goes through {@link claimInvite}. * * @throws {APIException} 404 when the invite doesn't exist or has expired, * 409 when it has been used. */ 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 invite `token` used and returns the inviter's id. Call it before the * async account creation, and {@link releaseInvite} if that fails: checking * first and marking after would let parallel sign-ups share one invite. * * @throws {APIException} 404 when the invite doesn't exist or has expired, * 409 when it has been used. */ export function claimInvite(token: string): string { const inviterId = validateInvite(token); const result = db.prepare( `UPDATE invites SET used_at = ? WHERE token = ? AND used_at IS NULL;`, ).run(new Date().toISOString(), token); if (result.changes === 0) { throw new APIException( APIErrorCode.VALIDATION_ERROR, 409, "Invite already used", ); } return inviterId; } /** * Makes a claimed invite usable again, when the sign-up that claimed it * failed. */ export function releaseInvite(token: string): void { db.prepare(`UPDATE invites SET used_at = NULL WHERE token = ?;`).run(token); }