/** * Gateway-backed verification-session client. * * Typed async wrappers over the gateway's `verification_sessions_*` IPC * routes. The gateway owns the `channel_verification_sessions` table and * mints all secrets; the daemon relays lifecycle operations here and keeps * message composition and channel delivery. Responses are validated against * the shared contract schemas in `@vellumai/gateway-client` — the same * schemas the gateway routes are pinned to. * * Error posture (fail-closed — there is no local fallback): * - Lifecycle wrappers THROW on any transport failure or malformed * response. Control-plane callers surface the error to the user; * inbound-stage callers catch and degrade to a plain deny. * - `validateAndConsumeVerification` never throws: any failure — transport * included — is the generic invalid-code result, preserving the consume * path's anti-oracle posture (no signal about WHY a code was rejected). */ import { CountRecentSendsIpcResponseSchema, CreateInboundSessionIpcResponseSchema, CreateOutboundSessionConditionalIpcResponseSchema, type CreateOutboundSessionConflict, type CreateOutboundSessionIpcParams, type CreateOutboundSessionIpcResponse, CreateOutboundSessionIpcResponseSchema, SessionLookupIpcResponseSchema, SessionMutationIpcResponseSchema, type SessionStatus, type ValidateConsumeSessionIpcResponse, ValidateConsumeSessionIpcResponseSchema, VERIFICATION_SESSIONS_IPC_METHODS, type VerificationSessionsIpcMethod, type VerificationSessionWire, } from "@vellumai/gateway-client"; import type { ZodType } from "zod"; import { ipcCallPersistentValidated } from "../ipc/gateway-validated-call.js"; import { composeApprovalMessage } from "../runtime/approval-message-composer.js"; export type { VerificationSessionWire } from "@vellumai/gateway-client"; export interface CreateVerificationSessionResult { challengeId: string; secret: string; verifyCommand: string; ttlSeconds: number; instruction: string; } // Aliases of the shared contract's IPC response types under the names // consumers import. export type CreateOutboundSessionResult = CreateOutboundSessionIpcResponse; export type ValidateVerificationResult = ValidateConsumeSessionIpcResponse; /** * Call a gateway session route and validate the response against its * contract schema. Throws on transport failure or a malformed response * (shared helper; typed to the session method union). */ async function callGateway( method: VerificationSessionsIpcMethod, params: Record, responseSchema: ZodType, ): Promise { return ipcCallPersistentValidated(method, params, responseSchema); } /** Call a mutation route; throws unless the gateway acks `{ ok: true }`. */ async function callMutation( method: VerificationSessionsIpcMethod, params: Record, ): Promise { await callGateway(method, params, SessionMutationIpcResponseSchema); } /** * Create an inbound verification session for a guardian candidate. The * gateway mints the high-entropy secret and persists only its hash; the * `instruction` copy is composed daemon-side from the returned secret. * Throws when the gateway is unreachable (fail-closed, user-visible). */ export async function createInboundVerificationSession( channel: string, conversationId?: string, ): Promise { const response = await callGateway( VERIFICATION_SESSIONS_IPC_METHODS.createInbound, { channel, sourceConversationId: conversationId }, CreateInboundSessionIpcResponseSchema, ); return { challengeId: response.session.id, secret: response.secret, verifyCommand: response.verifyCommand, ttlSeconds: response.ttlSeconds, instruction: composeApprovalMessage({ scenario: "guardian_verify_challenge_setup", channel, verifyCommand: response.verifyCommand, }), }; } /** * Create an outbound verification session with expected identity pre-set. * The gateway mints the secret (numeric when identity is bound, hex for * `pending_bootstrap`); it transits back for daemon-owned delivery. * Throws when the gateway is unreachable (fail-closed, user-visible). */ export async function createOutboundSession( params: CreateOutboundSessionIpcParams, ): Promise { return callGateway( VERIFICATION_SESSIONS_IPC_METHODS.createOutbound, params as unknown as Record, CreateOutboundSessionIpcResponseSchema, ); } /** * Guarded variant of `createOutboundSession` for callers passing an atomic * claim guard (`requireSourceSessionPending` / * `ifNoneActiveForExternalUserId`). The gateway * evaluates the guard in the same synchronous section as the mint; a failed * guard returns a conflict marker instead of revoking the concurrent * winner's session. Throws when the gateway is unreachable (fail-closed). */ export async function createOutboundSessionConditional( params: CreateOutboundSessionIpcParams, ): Promise { return callGateway( VERIFICATION_SESSIONS_IPC_METHODS.createOutbound, params as unknown as Record, CreateOutboundSessionConditionalIpcResponseSchema, ); } /** * Look up the pending (status `pending`, non-expired) inbound session for a * channel. Throws when the gateway is unreachable — callers on soft paths * catch and treat the read as inconclusive. */ export async function getPendingSession( channel: string, ): Promise { return callGateway( VERIFICATION_SESSIONS_IPC_METHODS.getPending, { channel }, SessionLookupIpcResponseSchema, ); } /** * Find the most recent active outbound session (`pending_bootstrap` / * `awaiting_response`) for a channel. Throws when the gateway is * unreachable — callers on soft paths catch and treat the read as * inconclusive. */ export async function findActiveSession( channel: string, filter: { expectedExternalUserId?: string; verificationPurpose?: "guardian" | "trusted_contact"; } = {}, ): Promise { return callGateway( VERIFICATION_SESSIONS_IPC_METHODS.findActive, { channel, ...filter }, SessionLookupIpcResponseSchema, ); } /** * Resolve a bootstrap deep-link token to its `pending_bootstrap` session. * Takes the RAW token — hashing happens gateway-side so the scheme stays * pinned to the stored `bootstrap_token_hash` values. Throws when the * gateway is unreachable. */ export async function resolveBootstrapToken( channel: string, token: string, ): Promise { return callGateway( VERIFICATION_SESSIONS_IPC_METHODS.resolveBootstrap, { channel, token }, SessionLookupIpcResponseSchema, ); } /** * Telegram bootstrap completion: bind the expected identity fields and flip * `identity_binding_status` to bound. Throws on any failure (fail-closed). */ export async function bindSessionIdentity( id: string, externalUserId: string, chatId: string, ): Promise { await callMutation(VERIFICATION_SESSIONS_IPC_METHODS.bindIdentity, { sessionId: id, externalUserId, chatId, }); } /** * Transition a session's status. Throws on any failure (fail-closed). */ export async function updateSessionStatus( id: string, status: SessionStatus, extraFields?: Partial<{ consumedByExternalUserId: string; consumedByChatId: string; }>, ): Promise { await callMutation(VERIFICATION_SESSIONS_IPC_METHODS.updateStatus, { sessionId: id, status, consumedByExternalUserId: extraFields?.consumedByExternalUserId, consumedByChatId: extraFields?.consumedByChatId, }); } /** * Update outbound delivery tracking fields on a session. Throws on any * failure (fail-closed). */ export async function updateSessionDelivery( id: string, lastSentAt: number, sendCount: number, nextResendAt: number | null, ): Promise { await callMutation(VERIFICATION_SESSIONS_IPC_METHODS.updateDelivery, { sessionId: id, lastSentAt, sendCount, nextResendAt, }); } /** * Count sends to a destination across all sessions within a rolling window * (destination-level send throttle input). Throws when the gateway is * unreachable — the throttle must not fail open. */ export async function countRecentSendsToDestination( channel: string, destinationAddress: string, windowMs: number, ): Promise { const response = await callGateway( VERIFICATION_SESSIONS_IPC_METHODS.countRecentSends, { channel, destinationAddress, windowMs }, CountRecentSendsIpcResponseSchema, ); return response.count; } /** * Revoke all pending sessions for a channel (user cancelled verification). * Throws on any failure (fail-closed). */ export async function revokePendingSessions(channel: string): Promise { await callMutation(VERIFICATION_SESSIONS_IPC_METHODS.revokePending, { channel, }); } function genericVerifyFailedReason(): string { return composeApprovalMessage({ scenario: "guardian_verify_failed", failureReason: "The verification code is invalid or has expired.", }); } /** * Validate and consume a verification challenge at the gateway (rate * limiting, identity binding, status-guarded single consume, in-engine role * side effects). * * Never throws. Every failure — including gateway-unreachable and malformed * responses — returns the same generic invalid-code result: the consume * path is fail-closed and anti-oracle, so the machine-readable gateway * reason is deliberately not surfaced to the actor. */ export async function validateAndConsumeVerification( channel: string, secret: string, actorExternalUserId: string, actorChatId: string, ): Promise { try { const response = await callGateway( VERIFICATION_SESSIONS_IPC_METHODS.validateConsume, { channel, secret, actorExternalUserId, actorChatId }, ValidateConsumeSessionIpcResponseSchema, ); if (response.success) { return { success: true, verificationType: response.verificationType }; } return { success: false, reason: genericVerifyFailedReason() }; } catch { return { success: false, reason: genericVerifyFailedReason() }; } }