/** * Reply tokens — how an inbound message identifies what it is answering. * * Minted per DELIVERY, not per alert. The token therefore identifies WHO * replied as well as what they replied to, which is what "an ack from the * secondary is broadcast to everyone already paged" needs, and it doubles as * the audit trail. A per-alert token could not distinguish two people * answering the same page. * * The same table serves alert acknowledgements and interview answers; only * `targetId` differs in meaning. That is the unification the design is built * around (D10). */ import { randomInt, randomUUID } from 'node:crypto'; import { and, eq, gt, isNull, like } from 'drizzle-orm'; import type { DbClient } from '../../db/client'; import { type NotificationDelivery, notificationDeliveries } from '../../db/schema'; /** * Crockford-style base32 without I, L, O, U — the characters people misread or * mistype when copying a code off a phone screen. Six of these is ~1e9 * possibilities, which combined with expiry and the sender check is ample: the * token is one of two factors, not a secret on its own. */ const TOKEN_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; const TOKEN_LENGTH = 6; export function generateToken(): string { let token = ''; for (let i = 0; i < TOKEN_LENGTH; i++) { token += TOKEN_ALPHABET[randomInt(TOKEN_ALPHABET.length)]; } return token; } /** Normalise operator input: case-insensitive, and forgiving of separators. */ export function normaliseToken(raw: string): string { return raw.trim().toUpperCase().replace(/[\s-]/g, ''); } export interface MintTokenInput { kind: 'alert' | 'interview'; /** `alerts.id` for an alert; a BUS event id for an interview. */ targetId: string; routeId: string; now: Date; ttlMs: number; } /** * Mint a delivery record with a unique token. * * Retries on collision rather than trusting randomness: a six-character token * WILL collide eventually, and a collision that silently reassigned an * outstanding token would route someone's ack to the wrong alert. */ export function mintDelivery(db: DbClient, input: MintTokenInput): NotificationDelivery { for (let attempt = 0; attempt < 8; attempt++) { const token = generateToken(); if (findLiveDelivery(db, token, input.now)) continue; const id = randomUUID(); db.insert(notificationDeliveries) .values({ id, token, kind: input.kind, targetId: input.targetId, routeId: input.routeId, sentAt: input.now, expiresAt: new Date(input.now.getTime() + input.ttlMs), }) .run(); return db .select() .from(notificationDeliveries) .where(eq(notificationDeliveries.id, id)) .get() as NotificationDelivery; } throw new Error('Could not mint a unique reply token after 8 attempts'); } /** An unexpired, unconsumed delivery for this token. */ export function findLiveDelivery( db: DbClient, token: string, now: Date, ): NotificationDelivery | undefined { return db .select() .from(notificationDeliveries) .where( and( eq(notificationDeliveries.token, normaliseToken(token)), isNull(notificationDeliveries.consumedAt), gt(notificationDeliveries.expiresAt, now), ), ) .get(); } /** * Every live delivery whose token STARTS WITH `prefix`. * * An operator typing at 3am should not have to copy six characters exactly. * They need only enough to be unambiguous, and "unambiguous" is scoped to what * is actually outstanding right now — consumed and expired deliveries are not * candidates, so yesterday's tokens cannot make today's prefix ambiguous. * * Returning every match rather than the first is the point: the caller must be * able to tell "no such token" from "you were ambiguous, be more specific". * Silently taking the first match would acknowledge an alert the operator did * not mean, which is worse than asking again. * * The shortening does not weaken authentication in the way it first appears. * The token was never a secret on its own — the sender address is the other * factor and is unaffected — and the search space here is not 32^6 but the * handful of alerts live at this moment. */ export function findLiveDeliveriesByPrefix( db: DbClient, prefix: string, now: Date, ): NotificationDelivery[] { const normalised = normaliseToken(prefix); if (!normalised) return []; return db .select() .from(notificationDeliveries) .where( and( like(notificationDeliveries.token, `${normalised}%`), isNull(notificationDeliveries.consumedAt), gt(notificationDeliveries.expiresAt, now), ), ) .all(); } export function consumeDelivery(db: DbClient, deliveryId: string, now: Date): void { db.update(notificationDeliveries) .set({ consumedAt: now }) .where(eq(notificationDeliveries.id, deliveryId)) .run(); } /** Every delivery made for an alert — who to send the all-clear to. */ export function deliveriesForAlert(db: DbClient, alertId: string): NotificationDelivery[] { return db .select() .from(notificationDeliveries) .where( and(eq(notificationDeliveries.kind, 'alert'), eq(notificationDeliveries.targetId, alertId)), ) .all(); }