From fae64f9155b37ca6f99b40cc1cf9adbd3983a1e5 Mon Sep 17 00:00:00 2001 From: Harrison Ngo Date: Thu, 26 Feb 2026 11:03:59 -0500 Subject: [PATCH] docs: channel-agnostic rollout and operator runbook for trusted contacts Co-Authored-By: Claude --- ARCHITECTURE.md | 3 + assistant/ARCHITECTURE.md | 49 +++ assistant/docs/runbook-trusted-contacts.md | 283 ++++++++++++ .../trusted-contact-multichannel.test.ts | 407 ++++++++++++++++++ 4 files changed, 742 insertions(+) create mode 100644 assistant/docs/runbook-trusted-contacts.md create mode 100644 assistant/src/__tests__/trusted-contact-multichannel.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 31065a444a7..b55bc464293 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -14,6 +14,8 @@ This file is the cross-system architecture index. Detailed designs live in domai | Assistant scheduling deep dive | [`assistant/docs/architecture/scheduling.md`](assistant/docs/architecture/scheduling.md) | | Assistant security deep dive | [`assistant/docs/architecture/security.md`](assistant/docs/architecture/security.md) | | Gateway SMS parity checklist | [`gateway/docs/sms-twilio-parity-checklist.md`](gateway/docs/sms-twilio-parity-checklist.md) | +| Trusted contact access design | [`assistant/docs/trusted-contact-access.md`](assistant/docs/trusted-contact-access.md) | +| Trusted contacts operator runbook | [`assistant/docs/runbook-trusted-contacts.md`](assistant/docs/runbook-trusted-contacts.md) | ## Cross-Cutting Invariants @@ -21,6 +23,7 @@ This file is the cross-system architecture index. Detailed designs live in domai - Production LLM calls go through the provider abstraction, not provider SDKs in feature code. - Notification producers emit through `emitNotificationSignal()` to preserve decisioning and audit invariants. - Memory extraction/recall must enforce actor-role provenance gates for untrusted actors. +- Trusted contact ingress ACL is channel-agnostic; identity binding adapts per channel (chat ID, E.164 phone, external user ID) without channel-specific branching. ## System Overview diff --git a/assistant/ARCHITECTURE.md b/assistant/ARCHITECTURE.md index 278ca34f0ec..6fdb24bd7f4 100644 --- a/assistant/ARCHITECTURE.md +++ b/assistant/ARCHITECTURE.md @@ -126,6 +126,55 @@ These can be set via environment variables or stored in the credential vault (ke **SMS Compliance & Admin**: The `twilio_config` IPC contract extends beyond credential and number management with compliance and admin actions: `sms_compliance_status` detects toll-free vs local number type and fetches verification status; `sms_submit_tollfree_verification`, `sms_update_tollfree_verification`, and `sms_delete_tollfree_verification` manage the Twilio toll-free verification lifecycle; `release_number` removes a phone number from the Twilio account and clears all local references. All compliance actions validate required fields and Twilio enum values before calling the API. +### Trusted Contact Access (Channel-Agnostic) + +External users who are not the guardian can gain access to the assistant through a guardian-mediated verification flow. The flow is channel-agnostic — it works identically on Telegram, SMS, voice, and any future channel. + +**Full design doc:** [`docs/trusted-contact-access.md`](docs/trusted-contact-access.md) + +**Flow summary:** +1. Unknown user messages the assistant on any channel. +2. Ingress ACL (`inbound-message-handler.ts`) rejects the message and emits an `ingress.access_request` notification signal to the guardian. +3. Guardian approves or denies via callback button or conversational intent (routed through `guardian-approval-interception.ts`). +4. On approval, an identity-bound verification session with a 6-digit code is created (`access-request-decision.ts` → `channel-guardian-service.ts`). +5. Guardian gives the code to the requester out-of-band. +6. Requester enters the code; identity binding is verified, the challenge is consumed, and an active member record is created in `assistant_ingress_members`. +7. All subsequent messages are accepted through the ingress ACL. + +**Channel-agnostic design:** The entire flow operates on abstract `ChannelId` and `externalUserId`/`externalChatId` fields. Identity binding adapts per channel: Telegram uses chat IDs, SMS/voice use E.164 phone numbers, HTTP API uses caller-provided identity. No channel-specific branching exists in the trusted contact code paths. + +**Lifecycle states:** `requested → pending_guardian → verification_pending → active | denied | expired` + +**Notification signals:** The flow emits signals at each lifecycle transition via `emitNotificationSignal()`: +- `ingress.access_request` — non-member denied, guardian notified +- `ingress.trusted_contact.guardian_decision` — guardian approved or denied +- `ingress.trusted_contact.verification_sent` — code created and delivered +- `ingress.trusted_contact.activated` — requester verified, member active +- `ingress.trusted_contact.denied` — guardian explicitly denied + +**HTTP API (for management):** + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/v1/ingress/members` | GET | List trusted contacts (filterable by channel, status, policy) | +| `/v1/ingress/members` | POST | Upsert a member (add/update trusted contact) | +| `/v1/ingress/members/:id` | DELETE | Revoke a trusted contact | +| `/v1/ingress/members/:id/block` | POST | Block a member | + +**Key source files:** + +| File | Purpose | +|------|---------| +| `src/runtime/routes/inbound-message-handler.ts` | Ingress ACL, non-member rejection, verification code interception | +| `src/runtime/routes/access-request-decision.ts` | Guardian decision → verification session creation | +| `src/runtime/routes/guardian-approval-interception.ts` | Routes guardian decisions (button + conversational) to access request handler | +| `src/runtime/channel-guardian-service.ts` | Verification challenge lifecycle, identity binding, rate limiting | +| `src/runtime/routes/ingress-routes.ts` | HTTP API handlers for member/invite management | +| `src/runtime/ingress-service.ts` | Business logic for member CRUD | +| `src/memory/ingress-member-store.ts` | Member record persistence | +| `src/memory/channel-guardian-store.ts` | Approval request and verification challenge persistence | +| `src/config/vellum-skills/trusted-contacts/SKILL.md` | Skill teaching the assistant to manage contacts via HTTP API | + --- diff --git a/assistant/docs/runbook-trusted-contacts.md b/assistant/docs/runbook-trusted-contacts.md new file mode 100644 index 00000000000..c99697995e5 --- /dev/null +++ b/assistant/docs/runbook-trusted-contacts.md @@ -0,0 +1,283 @@ +# Trusted Contacts — Operator Runbook + +Operational procedures for inspecting, managing, and debugging the trusted contact access flow. All HTTP commands use the runtime API (default `http://localhost:7821`) with bearer authentication. + +## Prerequisites + +```bash +# Read the bearer token +TOKEN=$(cat ~/.vellum/http-token) + +# Base URL (adjust if using a non-default port) +BASE=http://localhost:7821 +``` + +## 1. Inspect Trusted Contacts (Members) + +### List all active trusted contacts + +```bash +curl -s "$BASE/v1/ingress/members?status=active" \ + -H "Authorization: Bearer $TOKEN" | jq +``` + +### Filter by channel + +```bash +# Telegram contacts only +curl -s "$BASE/v1/ingress/members?sourceChannel=telegram&status=active" \ + -H "Authorization: Bearer $TOKEN" | jq + +# SMS contacts only +curl -s "$BASE/v1/ingress/members?sourceChannel=sms&status=active" \ + -H "Authorization: Bearer $TOKEN" | jq +``` + +### List all members (including revoked and blocked) + +```bash +curl -s "$BASE/v1/ingress/members" \ + -H "Authorization: Bearer $TOKEN" | jq +``` + +Response shape: +```json +{ + "ok": true, + "members": [ + { + "id": "uuid", + "sourceChannel": "telegram", + "externalUserId": "123456789", + "externalChatId": "123456789", + "displayName": "Alice", + "username": "alice_handle", + "status": "active", + "policy": "allow", + "lastSeenAt": 1700000000000, + "createdAt": 1699000000000 + } + ] +} +``` + +## 2. Inspect Pending Access Requests + +Access requests are stored in the `channel_guardian_approval_requests` table. Use SQLite to inspect pending requests directly. + +### Via SQLite CLI + +```bash +sqlite3 ~/.vellum/workspace/data/db/assistant.db \ + "SELECT id, channel, requester_external_user_id, requester_chat_id, \ + guardian_external_user_id, status, tool_name, created_at, expires_at \ + FROM channel_guardian_approval_requests \ + WHERE tool_name = 'ingress_access_request' AND status = 'pending' \ + ORDER BY created_at DESC;" +``` + +### Check all access requests (including resolved) + +```bash +sqlite3 ~/.vellum/workspace/data/db/assistant.db \ + "SELECT id, channel, requester_external_user_id, status, \ + decided_by_external_user_id, created_at \ + FROM channel_guardian_approval_requests \ + WHERE tool_name = 'ingress_access_request' \ + ORDER BY created_at DESC LIMIT 20;" +``` + +## 3. Inspect Pending Verification Sessions + +Verification challenges are stored in `channel_guardian_verification_challenges`. Active sessions have `status = 'awaiting_response'` and `expires_at > now`. + +```bash +sqlite3 ~/.vellum/workspace/data/db/assistant.db \ + "SELECT id, channel, status, identity_binding_status, \ + expected_external_user_id, expected_chat_id, expected_phone_e164, \ + expires_at, created_at \ + FROM channel_guardian_verification_challenges \ + WHERE status IN ('awaiting_response', 'pending_bootstrap') \ + AND expires_at > $(date +%s)000 \ + ORDER BY created_at DESC;" +``` + +## 4. Force-Revoke a Trusted Contact + +### Via HTTP API + +First, find the member's `id` from the list endpoint, then revoke: + +```bash +# Find the member +MEMBER_ID=$(curl -s "$BASE/v1/ingress/members?sourceChannel=telegram&status=active" \ + -H "Authorization: Bearer $TOKEN" | jq -r '.members[] | select(.externalUserId == "TARGET_USER_ID") | .id') + +# Revoke with reason +curl -s -X DELETE "$BASE/v1/ingress/members/$MEMBER_ID" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"reason": "Revoked by operator"}' | jq +``` + +### Block a member (stronger than revoke) + +Blocking prevents the member from re-entering the flow without explicit unblocking. + +```bash +curl -s -X POST "$BASE/v1/ingress/members/$MEMBER_ID/block" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"reason": "Blocked by operator"}' | jq +``` + +### Via SQLite (emergency) + +If the HTTP API is unavailable: + +```bash +sqlite3 ~/.vellum/workspace/data/db/assistant.db \ + "UPDATE assistant_ingress_members \ + SET status = 'revoked', revoked_reason = 'Emergency operator revocation', \ + updated_at = $(date +%s)000 \ + WHERE external_user_id = 'TARGET_USER_ID' AND source_channel = 'telegram';" +``` + +## 5. Debug Verification Failures + +### Check rate limit state + +If a user is getting "invalid or expired code" errors, they may be rate-limited: + +```bash +sqlite3 ~/.vellum/workspace/data/db/assistant.db \ + "SELECT * FROM channel_guardian_rate_limits \ + WHERE external_user_id = 'TARGET_USER_ID' \ + OR chat_id = 'TARGET_CHAT_ID' \ + ORDER BY created_at DESC LIMIT 5;" +``` + +### Reset rate limits for a user + +```bash +sqlite3 ~/.vellum/workspace/data/db/assistant.db \ + "DELETE FROM channel_guardian_rate_limits \ + WHERE external_user_id = 'TARGET_USER_ID' AND channel = 'telegram';" +``` + +### Check verification challenge state + +```bash +sqlite3 ~/.vellum/workspace/data/db/assistant.db \ + "SELECT id, channel, status, identity_binding_status, \ + expected_external_user_id, expected_chat_id, expected_phone_e164, \ + expires_at, consumed_by_external_user_id \ + FROM channel_guardian_verification_challenges \ + WHERE expected_external_user_id = 'TARGET_USER_ID' \ + OR expected_chat_id = 'TARGET_CHAT_ID' \ + ORDER BY created_at DESC LIMIT 5;" +``` + +### Common verification failure causes + +| Symptom | Likely cause | Resolution | +|---------|-------------|------------| +| "Invalid or expired code" (correct code) | Identity mismatch: the code was entered from a different user/chat than expected | Verify the requester is using the same account that originally requested access | +| "Invalid or expired code" (correct code, correct user) | Rate-limited (5+ failures in 15 min window) | Wait 30 minutes or reset rate limits via SQLite | +| "Invalid or expired code" (old code) | Code TTL expired (10 min) | Guardian must re-approve to generate a new code | +| Code never delivered to guardian | `deliverChannelReply` failed | Check daemon logs for "Failed to deliver verification code to guardian" | +| No notification to guardian | No guardian binding for channel | Verify guardian is bound: check `channel_guardian_bindings` table | + +## 6. Check Notification Delivery Status + +### Check if the access request notification was delivered + +```bash +sqlite3 ~/.vellum/workspace/data/db/assistant.db \ + "SELECT ne.id, ne.source_event_name, ne.dedupe_key, ne.created_at, \ + nd.channel, nd.status, nd.confidence \ + FROM notification_events ne \ + LEFT JOIN notification_decisions nd ON nd.event_id = ne.id \ + WHERE ne.source_event_name LIKE 'ingress.%' \ + ORDER BY ne.created_at DESC LIMIT 20;" +``` + +### Check delivery records + +```bash +sqlite3 ~/.vellum/workspace/data/db/assistant.db \ + "SELECT ndel.id, ndel.channel, ndel.status, ndel.error_message, \ + ndel.created_at, ne.source_event_name \ + FROM notification_deliveries ndel \ + JOIN notification_events ne ON ne.id = ndel.event_id \ + WHERE ne.source_event_name LIKE 'ingress.%' \ + ORDER BY ndel.created_at DESC LIMIT 20;" +``` + +### Check lifecycle signals + +```bash +sqlite3 ~/.vellum/workspace/data/db/assistant.db \ + "SELECT source_event_name, source_channel, dedupe_key, created_at \ + FROM notification_events \ + WHERE source_event_name LIKE 'ingress.trusted_contact.%' \ + ORDER BY created_at DESC LIMIT 20;" +``` + +## 7. Manually Add a Trusted Contact (Bypass Verification) + +If the verification flow cannot be completed, an operator can directly create an active member: + +```bash +curl -s -X POST "$BASE/v1/ingress/members" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "sourceChannel": "telegram", + "externalUserId": "123456789", + "externalChatId": "123456789", + "displayName": "Alice", + "policy": "allow", + "status": "active" + }' | jq +``` + +For SMS contacts, use the E.164 phone number as the external user/chat ID: + +```bash +curl -s -X POST "$BASE/v1/ingress/members" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "sourceChannel": "sms", + "externalUserId": "+15551234567", + "externalChatId": "+15551234567", + "displayName": "Bob", + "policy": "allow", + "status": "active" + }' | jq +``` + +## 8. Clean Up Expired Data + +### Purge expired verification sessions + +Expired sessions are already invisible to the verification flow (filtered by `expires_at`), but you can clean them up: + +```bash +sqlite3 ~/.vellum/workspace/data/db/assistant.db \ + "DELETE FROM channel_guardian_verification_challenges \ + WHERE expires_at < $(date +%s)000 \ + AND status IN ('awaiting_response', 'pending_bootstrap');" +``` + +### Purge expired approval requests + +The `sweepExpiredGuardianApprovals()` timer handles this automatically every 60 seconds, but manual cleanup: + +```bash +sqlite3 ~/.vellum/workspace/data/db/assistant.db \ + "UPDATE channel_guardian_approval_requests \ + SET status = 'expired' \ + WHERE status = 'pending' AND expires_at < $(date +%s)000;" +``` diff --git a/assistant/src/__tests__/trusted-contact-multichannel.test.ts b/assistant/src/__tests__/trusted-contact-multichannel.test.ts new file mode 100644 index 00000000000..f16c0f215d6 --- /dev/null +++ b/assistant/src/__tests__/trusted-contact-multichannel.test.ts @@ -0,0 +1,407 @@ +/** + * Tests verifying the trusted contact flow is channel-agnostic. + * + * The access request -> guardian notification -> verification -> activation + * flow should work identically across Telegram, SMS, and voice channels. + * These tests confirm no Telegram-specific assumptions leaked into the + * trusted contact code paths. + */ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test'; + +// --------------------------------------------------------------------------- +// Test isolation: in-memory SQLite via temp directory +// --------------------------------------------------------------------------- + +const testDir = mkdtempSync(join(tmpdir(), 'trusted-contact-multichannel-')); + +mock.module('../util/platform.js', () => ({ + getRootDir: () => testDir, + getDataDir: () => testDir, + isMacOS: () => process.platform === 'darwin', + isLinux: () => process.platform === 'linux', + isWindows: () => process.platform === 'win32', + getSocketPath: () => join(testDir, 'test.sock'), + getPidPath: () => join(testDir, 'test.pid'), + getDbPath: () => join(testDir, 'test.db'), + getLogPath: () => join(testDir, 'test.log'), + ensureDataDir: () => {}, + normalizeAssistantId: (id: string) => id === 'self' ? 'self' : id, + readHttpToken: () => 'test-bearer-token', +})); + +mock.module('../util/logger.js', () => ({ + getLogger: () => new Proxy({} as Record, { + get: () => () => {}, + }), +})); + +mock.module('../security/secret-ingress.js', () => ({ + checkIngressForSecrets: () => ({ blocked: false }), +})); + +mock.module('../config/env.js', () => ({ + getGatewayInternalBaseUrl: () => 'http://127.0.0.1:7830', +})); + +const emitSignalCalls: Array> = []; +mock.module('../notifications/emit-signal.js', () => ({ + emitNotificationSignal: async (params: Record) => { + emitSignalCalls.push(params); + return { + signalId: 'mock-signal-id', + deduplicated: false, + dispatched: true, + reason: 'mock', + deliveryResults: [], + }; + }, +})); + +const deliverReplyCalls: Array<{ url: string; payload: Record }> = []; +mock.module('../runtime/gateway-client.js', () => ({ + deliverChannelReply: async (url: string, payload: Record) => { + deliverReplyCalls.push({ url, payload }); + }, +})); + +mock.module('../runtime/approval-message-composer.js', () => ({ + composeApprovalMessage: () => 'mock approval message', + composeApprovalMessageGenerative: async () => 'mock generative message', +})); + +import { + createBinding, + findPendingAccessRequestForRequester, +} from '../memory/channel-guardian-store.js'; +import { + createOutboundSession, + validateAndConsumeChallenge, +} from '../runtime/channel-guardian-service.js'; +import { findMember, upsertMember } from '../memory/ingress-member-store.js'; +import { initializeDb, resetDb } from '../memory/db.js'; +import { handleChannelInbound } from '../runtime/routes/channel-routes.js'; +import { + handleAccessRequestDecision, +} from '../runtime/routes/access-request-decision.js'; + +initializeDb(); + +afterAll(() => { + resetDb(); + try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ } +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const TEST_BEARER_TOKEN = 'test-token'; + +function resetState(): void { + const { getDb } = require('../memory/db.js'); + const db = getDb(); + db.run('DELETE FROM channel_guardian_approval_requests'); + db.run('DELETE FROM channel_guardian_bindings'); + db.run('DELETE FROM channel_guardian_verification_challenges'); + db.run('DELETE FROM channel_guardian_rate_limits'); + db.run('DELETE FROM channel_inbound_events'); + db.run('DELETE FROM conversations'); + db.run('DELETE FROM notification_events'); + db.run('DELETE FROM assistant_ingress_members'); + emitSignalCalls.length = 0; + deliverReplyCalls.length = 0; +} + +interface ChannelTestConfig { + channel: 'telegram' | 'sms' | 'voice'; + deliverEndpoint: string; + /** SMS/voice use phone E.164 as identifiers */ + senderExternalUserId: string; + externalChatId: string; + guardianExternalUserId: string; + guardianChatId: string; +} + +const CHANNEL_CONFIGS: ChannelTestConfig[] = [ + { + channel: 'telegram', + deliverEndpoint: '/deliver/telegram', + senderExternalUserId: 'tg-user-456', + externalChatId: 'tg-chat-456', + guardianExternalUserId: 'tg-guardian-789', + guardianChatId: 'tg-guardian-chat-789', + }, + { + channel: 'sms', + deliverEndpoint: '/deliver/sms', + senderExternalUserId: '+15551234567', + externalChatId: '+15551234567', + guardianExternalUserId: '+15559876543', + guardianChatId: '+15559876543', + }, +]; + +function buildInboundRequest( + config: ChannelTestConfig, + overrides: Record = {}, +): Request { + const body: Record = { + sourceChannel: config.channel, + interface: config.channel, + externalChatId: config.externalChatId, + externalMessageId: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + content: 'Hello, can I use this assistant?', + senderExternalUserId: config.senderExternalUserId, + senderName: 'Test Requester', + senderUsername: 'test_requester', + replyCallbackUrl: `http://localhost:7830${config.deliverEndpoint}`, + ...overrides, + }; + + return new Request('http://localhost:8080/channels/inbound', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Gateway-Origin': TEST_BEARER_TOKEN, + }, + body: JSON.stringify(body), + }); +} + +// --------------------------------------------------------------------------- +// Parameterized tests for each channel +// --------------------------------------------------------------------------- + +for (const config of CHANNEL_CONFIGS) { + describe(`trusted contact flow on ${config.channel} channel`, () => { + beforeEach(() => { + resetState(); + }); + + test('non-member message is denied with rejection reply', async () => { + const req = buildInboundRequest(config); + const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN); + const json = await resp.json() as Record; + + expect(json.denied).toBe(true); + expect(json.reason).toBe('not_a_member'); + expect(deliverReplyCalls.length).toBe(1); + expect((deliverReplyCalls[0].payload as Record).text).toContain("you haven't been approved"); + }); + + test('guardian is notified when a non-member messages', async () => { + createBinding({ + assistantId: 'self', + channel: config.channel, + guardianExternalUserId: config.guardianExternalUserId, + guardianDeliveryChatId: config.guardianChatId, + }); + + const req = buildInboundRequest(config); + const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN); + const json = await resp.json() as Record; + + expect(json.denied).toBe(true); + + // Notification signal was emitted for the correct channel + expect(emitSignalCalls.length).toBe(1); + expect(emitSignalCalls[0].sourceEventName).toBe('ingress.access_request'); + expect(emitSignalCalls[0].sourceChannel).toBe(config.channel); + + const payload = emitSignalCalls[0].contextPayload as Record; + expect(payload.senderExternalUserId).toBe(config.senderExternalUserId); + + // Approval request was created for the correct channel + const pending = findPendingAccessRequestForRequester( + 'self', + config.channel, + config.senderExternalUserId, + 'ingress_access_request', + ); + expect(pending).not.toBeNull(); + expect(pending!.channel).toBe(config.channel); + }); + + test('verification creates active member for channel', () => { + const session = createOutboundSession({ + assistantId: 'self', + channel: config.channel, + expectedExternalUserId: config.senderExternalUserId, + expectedChatId: config.externalChatId, + identityBindingStatus: 'bound', + destinationAddress: config.externalChatId, + }); + + const result = validateAndConsumeChallenge( + 'self', + config.channel, + session.secret, + config.senderExternalUserId, + config.externalChatId, + 'test_requester', + 'Test Requester', + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.verificationType).toBe('trusted_contact'); + } + + upsertMember({ + assistantId: 'self', + sourceChannel: config.channel, + externalUserId: config.senderExternalUserId, + externalChatId: config.externalChatId, + status: 'active', + policy: 'allow', + displayName: 'Test Requester', + username: 'test_requester', + }); + + const member = findMember({ + assistantId: 'self', + sourceChannel: config.channel, + externalUserId: config.senderExternalUserId, + }); + + expect(member).not.toBeNull(); + expect(member!.status).toBe('active'); + expect(member!.policy).toBe('allow'); + expect(member!.sourceChannel).toBe(config.channel); + }); + + test('no cross-channel leakage between member records', () => { + // Create a member for this channel + upsertMember({ + assistantId: 'self', + sourceChannel: config.channel, + externalUserId: config.senderExternalUserId, + externalChatId: config.externalChatId, + status: 'active', + policy: 'allow', + }); + + // Should be found on this channel + const sameChanMember = findMember({ + assistantId: 'self', + sourceChannel: config.channel, + externalUserId: config.senderExternalUserId, + }); + expect(sameChanMember).not.toBeNull(); + + // Should NOT be found on a different channel + const otherChannel = config.channel === 'telegram' ? 'sms' : 'telegram'; + const crossChanMember = findMember({ + assistantId: 'self', + sourceChannel: otherChannel, + externalUserId: config.senderExternalUserId, + }); + expect(crossChanMember).toBeNull(); + }); + }); +} + +// --------------------------------------------------------------------------- +// SMS-specific: phone E.164 identity binding +// --------------------------------------------------------------------------- + +describe('SMS identity binding with E.164 phone numbers', () => { + beforeEach(() => { + resetState(); + }); + + test('SMS verification session binds to phone E.164', () => { + const phone = '+15551234567'; + const session = createOutboundSession({ + assistantId: 'self', + channel: 'sms', + expectedExternalUserId: phone, + expectedPhoneE164: phone, + expectedChatId: phone, + identityBindingStatus: 'bound', + destinationAddress: phone, + }); + + // Verify with matching phone identity + const result = validateAndConsumeChallenge( + 'self', 'sms', session.secret, + phone, phone, + ); + expect(result.success).toBe(true); + if (result.success) { + expect(result.verificationType).toBe('trusted_contact'); + } + }); + + test('SMS verification rejects mismatched phone identity', () => { + const expectedPhone = '+15551234567'; + const wrongPhone = '+15559999999'; + + const session = createOutboundSession({ + assistantId: 'self', + channel: 'sms', + expectedExternalUserId: expectedPhone, + expectedPhoneE164: expectedPhone, + expectedChatId: expectedPhone, + identityBindingStatus: 'bound', + destinationAddress: expectedPhone, + }); + + // Try to verify with a different phone (anti-oracle: same error message) + const result = validateAndConsumeChallenge( + 'self', 'sms', session.secret, + wrongPhone, wrongPhone, + ); + expect(result.success).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Cross-channel: same user on different channels gets separate sessions +// --------------------------------------------------------------------------- + +describe('cross-channel isolation', () => { + beforeEach(() => { + resetState(); + }); + + test('verification sessions are scoped per channel', () => { + // Create sessions on both channels + const telegramSession = createOutboundSession({ + assistantId: 'self', + channel: 'telegram', + expectedExternalUserId: 'user-123', + expectedChatId: 'chat-123', + identityBindingStatus: 'bound', + destinationAddress: 'chat-123', + }); + + const smsSession = createOutboundSession({ + assistantId: 'self', + channel: 'sms', + expectedExternalUserId: '+15551234567', + expectedPhoneE164: '+15551234567', + expectedChatId: '+15551234567', + identityBindingStatus: 'bound', + destinationAddress: '+15551234567', + }); + + // Telegram code should not work on SMS channel + const wrongChannelResult = validateAndConsumeChallenge( + 'self', 'sms', telegramSession.secret, + '+15551234567', '+15551234567', + ); + expect(wrongChannelResult.success).toBe(false); + + // SMS code should work on SMS channel + const correctChannelResult = validateAndConsumeChallenge( + 'self', 'sms', smsSession.secret, + '+15551234567', '+15551234567', + ); + expect(correctChannelResult.success).toBe(true); + }); +});