/** * Route handlers for channel verification session endpoints. * * POST /v1/channel-verification-sessions — create session (inbound challenge, outbound verification, or trusted contact) * POST /v1/channel-verification-sessions/resend — resend outbound verification code * DELETE /v1/channel-verification-sessions — cancel all active sessions (inbound + outbound) * POST /v1/channel-verification-sessions/revoke — cancel all sessions and revoke binding * GET /v1/channel-verification-sessions/status — check guardian binding status * * Source of truth: * - Verification SESSION state (pending sessions, codes, resend counters) is * gateway-owned; the daemon relays lifecycle ops over the * `verification_sessions_*` IPC client and fails loudly when the gateway is * unreachable. The in-memory initiation throttle (`verificationRateLimiter`) * stays daemon-side. * - The channel-verified OUTCOME (status / verifiedAt / verifiedVia) is gateway-owned, written * in-process by the HTTP guardian-attest handler (`ContactStore.markChannelVerified`) and by the * inbound code-match path (`gateway/src/verification/text-verification.ts`). * - The revoke/downgrade OUTCOME is relayed from the daemon via * `ipcCallPersistent("mark_channel_revoked", …)` to `ContactStore.markChannelRevoked`. */ import { z } from "zod"; import { revokePendingSessions } from "../../channels/gateway-verification-sessions.js"; import type { ChannelId } from "../../channels/types.js"; import { createInboundChallenge, getVerificationStatus, revokeVerificationForChannel, verifyTrustedContact, } from "../../daemon/handlers/config-channels.js"; import { normalizePhoneNumber } from "../../util/phone.js"; import { DAEMON_INTERNAL_ASSISTANT_ID } from "../assistant-scope.js"; import { ACTOR_PRINCIPALS } from "../auth/route-policy.js"; import { cancelOutbound, normalizeTelegramDestination, resendOutbound, startOutbound, } from "../verification-outbound-actions.js"; import { verificationRateLimiter } from "../verification-rate-limiter.js"; import { BadRequestError, ConflictError, TooManyRequestsError, } from "./errors.js"; import type { RouteDefinition, RouteHandlerArgs } from "./types.js"; // --------------------------------------------------------------------------- // Handlers // --------------------------------------------------------------------------- /** * POST /v1/channel-verification-sessions * * Unified session creation: * - `purpose: "trusted_contact"` with `contactChannelId`: trusted contact verification * - `destination` present: outbound guardian verification * - Otherwise: inbound guardian challenge */ export async function handleCreateVerificationSession({ body, }: RouteHandlerArgs) { if (!body || typeof body !== "object") { throw new BadRequestError("Request body is required"); } const { channel, destination, rebind, conversationId, originConversationId, purpose: rawPurpose, contactChannelId, } = body as { channel?: ChannelId; destination?: string; rebind?: boolean; conversationId?: string; originConversationId?: string; purpose?: string; contactChannelId?: string; }; const purpose = rawPurpose ?? "guardian"; if (purpose === "trusted_contact" && !contactChannelId) { throw new BadRequestError( "contactChannelId is required for trusted_contact purpose", ); } // Trusted contact verification path if (purpose === "trusted_contact") { const result = await verifyTrustedContact( contactChannelId!, DAEMON_INTERNAL_ASSISTANT_ID, ); if (!result.success) { if (result.error === "rate_limited") { throw new TooManyRequestsError( (result as { message?: string }).message ?? "Rate limited", ); } if (result.error === "already_verified") { throw new ConflictError( (result as { message?: string }).message ?? "Already verified", ); } throw new BadRequestError( (result as { message?: string }).message ?? "Trusted contact verification failed", ); } return result; } if (destination) { // Outbound verification path — requires a channel if (!channel) { throw new BadRequestError('The "channel" field is required.'); } // Normalize destination to prevent rate-limit bypass via format variations let rateLimitKey: string | undefined = destination; if (rateLimitKey) { if (channel === "phone") { rateLimitKey = normalizePhoneNumber(rateLimitKey) ?? rateLimitKey; } else if (channel === "telegram") { rateLimitKey = normalizeTelegramDestination(rateLimitKey); } } if (rateLimitKey && verificationRateLimiter.isBlocked(rateLimitKey)) { throw new TooManyRequestsError( "Too many verification attempts for this identity. Please try again later.", ); } const result = await startOutbound({ channel, destination, rebind, originConversationId, }); if (!result.success && rateLimitKey) { verificationRateLimiter.recordFailure(rateLimitKey); } if (!result.success) { if (result.error === "rate_limited") { throw new TooManyRequestsError( (result as { message?: string }).message ?? "Rate limited", ); } throw new BadRequestError( (result as { message?: string }).message ?? "Outbound verification failed", ); } return result; } // Inbound challenge path const result = await createInboundChallenge(channel, rebind, conversationId); if (!result.success) { throw new BadRequestError( (result as { message?: string }).message ?? "Inbound challenge creation failed", ); } return result; } /** * GET /v1/channel-verification-sessions/status */ async function handleGetVerificationStatus({ queryParams = {}, body = {}, }: RouteHandlerArgs) { const channel = (queryParams.channel ?? (body as Record).channel) as ChannelId | undefined; return await getVerificationStatus(channel); } /** * POST /v1/channel-verification-sessions/resend */ export async function handleResendVerificationSession({ body, }: RouteHandlerArgs) { if (!body || typeof body !== "object") { throw new BadRequestError("Request body is required"); } const { channel, originConversationId } = body as { channel?: ChannelId; originConversationId?: string; }; if (!channel) { throw new BadRequestError('The "channel" field is required.'); } const result = await resendOutbound({ channel, originConversationId }); if (!result.success) { if (result.error === "rate_limited") { throw new TooManyRequestsError( (result as { message?: string }).message ?? "Rate limited", ); } throw new BadRequestError( (result as { message?: string }).message ?? "Resend failed", ); } return result; } /** * DELETE /v1/channel-verification-sessions */ export async function handleCancelVerificationSession({ body, }: RouteHandlerArgs) { if (!body || typeof body !== "object") { throw new BadRequestError("Request body is required"); } const { channel } = body as { channel?: ChannelId }; if (!channel) { throw new BadRequestError('The "channel" field is required.'); } await cancelOutbound({ channel }); await revokePendingSessions(channel); return { success: true, channel }; } /** * POST /v1/channel-verification-sessions/revoke */ async function handleRevokeVerificationBinding({ body = {}, }: RouteHandlerArgs) { const { channel } = body as { channel?: ChannelId }; const result = await revokeVerificationForChannel(channel); if (!result.success) { throw new BadRequestError( (result as { message?: string }).message ?? "Revocation failed", ); } return result; } // --------------------------------------------------------------------------- // Route definitions // --------------------------------------------------------------------------- export const ROUTES: RouteDefinition[] = [ { operationId: "channel_verification_sessions_create", endpoint: "channel-verification-sessions", method: "POST", policy: { requiredScopes: ["settings.write"], allowedPrincipalTypes: ACTOR_PRINCIPALS, }, summary: "Create verification session", description: "Create a channel verification session (inbound challenge, outbound, or trusted contact).", tags: ["channel-verification"], requestBody: z.object({ channel: z.string().describe("Channel ID"), destination: z.string().describe("Outbound destination"), rebind: z.boolean(), conversationId: z.string(), originConversationId: z.string(), purpose: z.string().describe("guardian or trusted_contact"), contactChannelId: z.string(), }), handler: handleCreateVerificationSession, }, { operationId: "channel_verification_sessions_resend", endpoint: "channel-verification-sessions/resend", method: "POST", policy: { requiredScopes: ["settings.write"], allowedPrincipalTypes: ACTOR_PRINCIPALS, }, summary: "Resend verification code", description: "Resend the outbound verification code.", tags: ["channel-verification"], requestBody: z.object({ channel: z.string(), originConversationId: z.string().optional(), }), handler: handleResendVerificationSession, }, { operationId: "channel_verification_sessions_cancel", endpoint: "channel-verification-sessions", method: "DELETE", policy: { requiredScopes: ["settings.write"], allowedPrincipalTypes: ACTOR_PRINCIPALS, }, summary: "Cancel verification sessions", description: "Cancel all active inbound and outbound verification sessions.", tags: ["channel-verification"], requestBody: z.object({ channel: z.string(), }), handler: handleCancelVerificationSession, }, { operationId: "channel_verification_sessions_revoke", endpoint: "channel-verification-sessions/revoke", method: "POST", policy: { requiredScopes: ["settings.write"], allowedPrincipalTypes: ACTOR_PRINCIPALS, }, summary: "Revoke verification binding", description: "Cancel all sessions and revoke the guardian binding.", tags: ["channel-verification"], requestBody: z.object({ channel: z.string(), }), handler: handleRevokeVerificationBinding, }, { operationId: "channel_verification_sessions_status", endpoint: "channel-verification-sessions/status", method: "GET", policy: { requiredScopes: ["settings.read"], allowedPrincipalTypes: ACTOR_PRINCIPALS, }, summary: "Get verification status", description: "Check guardian binding and verification session status.", tags: ["channel-verification"], queryParams: [ { name: "channel", schema: { type: "string" }, description: "Optional channel ID filter", }, ], handler: handleGetVerificationStatus, }, ];