/** * Answering deploy interviews over a notification transport. * * The payoff the whole design was shaped around: because an alert and an * interview question are the same message with different reply semantics, one * transport gives both. The send path, the token table, and the inbound path * are shared; only what `targetId` points at differs. * * alert targetId -> alerts.id reply acknowledges * interview targetId -> a BUS event id reply IS the answer * * Two rules here are safety properties rather than preferences, and both are * enforced before anything is delivered. */ import type { AlertSeverity } from '../../db/schema'; /** * Interview families a messaging transport must never carry. * * `headless-cli-interview` already requires that secrets never travel in a * reply payload — a responder writes plaintext out-of-band to the encrypted * store and replies only `{ acknowledged: true }`. A messaging transport * cannot satisfy that: the operator would have to type the secret into a chat, * putting it in the transport's message store, the device's history, and every * other route on the policy, all before the responder ever sees it. The * out-of-band write is meaningless when the inbound channel is itself the leak. * * So this is compliance with an existing requirement, not a new rule. See * design D11. */ const REFUSED_PREFIXES = ['secret.required.'] as const; export function isRefusedFamily(eventType: string): boolean { return REFUSED_PREFIXES.some((prefix) => eventType.startsWith(prefix)); } export interface InterviewDeliveryDecision { deliver: boolean; /** Why not, when `deliver` is false — shown to the operator. */ reason?: string; } export interface InterviewContext { eventType: string; /** True when a terminal responder is available to answer instead. */ hasTty: boolean; /** True when at least one ack-capable route exists. */ hasBidirectionalRoute: boolean; } /** * Whether a notification transport should carry this interview question. * * Order matters: the refusal is checked FIRST, so a secret question is * declined even on a headless box with a perfectly good transport. Declining * it there leaves the deploy waiting for a terminal, which is correct — the * alternative is leaking a credential to make a deploy convenient. */ export function decideInterviewDelivery(context: InterviewContext): InterviewDeliveryDecision { if (isRefusedFamily(context.eventType)) { return { deliver: false, reason: 'This question asks for a secret, which must not travel over a messaging transport. ' + 'Answer it at a terminal — see `celilo events list-pending`.', }; } // Terminal wins when someone is sitting there (D12). Without this, a deploy // started from a laptop appears to hang while silently waiting for a text. if (context.hasTty) { return { deliver: false, reason: 'A terminal responder is attached and will answer.' }; } if (!context.hasBidirectionalRoute) { return { deliver: false, reason: 'No route can receive replies, so a delivered question could never be answered.', }; } return { deliver: true }; } /** * The interview families do not share a payload shape, and the differences * matter for what a human sees on a phone: * * interview.required.* { scope, key, message, ... } `message` IS the question * config.required.* { module, key, type, ... } no prose; compose from module+key * * Reading only `scope`/`key` drops the actual question text on the family that * has it, and renders the raw event type on the family that does not. Neither * is something you want to read at 3am. */ export function describeQuestion( eventType: string, payload: Record, ): { scope: string; key: string; description?: string } { const key = typeof payload.key === 'string' ? payload.key : 'value'; const description = typeof payload.description === 'string' ? payload.description : undefined; // The interview family's `message` is the operator-facing question — prefer // it over anything we would compose ourselves. if (typeof payload.message === 'string' && payload.message.length > 0) { const scope = typeof payload.scope === 'string' ? payload.scope : eventType; return { scope, key: payload.message, description }; } const owner = typeof payload.module === 'string' ? payload.module : typeof payload.scope === 'string' ? payload.scope : eventType; return { scope: owner, key, description }; } /** * Render an interview question for a phone. * * The question leads, because that is the notification preview. The token * instruction is last and bare — someone answering a deploy prompt from a * phone should not have to parse a menu. */ export function composeInterviewBody(input: { scope: string; key: string; description?: string; token: string; }): string { const lines = [`❓ ${input.scope} needs: ${input.key}`]; if (input.description) lines.push(input.description); lines.push('', `reply ${input.token} `); return lines.join('\n'); } // `parseInterviewAnswer` lived here and is gone. It was correct and had the // tests to prove it, and it was never reachable: the caller only ran it for a // body the ALERT grammar had already accepted, which no real answer ever was // (#533). Extraction now happens in `interpretInbound` — the one place that // knows a delivery's kind before it parses — so the reply grammar exists once // rather than in two halves that could disagree. /** Severity an interview question is delivered at — never a page-worthy one. */ export const INTERVIEW_SEVERITY: AlertSeverity = 'warning';