From 622a370815b3ac9ef499005d0713ff9afb114d23 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 20 Apr 2026 01:36:11 +0300 Subject: [PATCH] feat(permissions): unknown-sender request_approval flow + flipped default policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an unknown sender writes into a wired messaging group, surface the situation to an admin instead of silently dropping. Flow: 1. Router → access gate → handleUnknownSender (policy='request_approval') 2. Fire-and-forget requestSenderApproval: pickApprover + pickApprovalDelivery pick a reachable admin DM; deliver an Approve / Deny card; insert a pending_sender_approvals row carrying the original InboundEvent JSON. 3. In-flight dedup: UNIQUE(messaging_group_id, sender_identity) — a retry from the same stranger while pending is silently dropped, not re-carded. 4. Admin clicks → Chat SDK bridge → onAction → host response-registry. The new handleSenderApprovalResponse in the permissions module claims responses whose questionId matches a pending_sender_approvals row. 5. approve: addMember(stranger, agent_group) + replay the stored event via routeInbound — the second attempt clears the gate because the user is now known. 6. deny: delete the pending row. No denial persistence (ACTION-ITEMS item 5 decision) — a future attempt triggers a fresh card. Schema: - Migration 011 adds pending_sender_approvals (id, mg_id, agent_group_id, sender_identity, sender_name, original_message JSON, approver_user_id, created_at, UNIQUE(mg_id, sender_identity)). - Also flips messaging_groups.unknown_sender_policy default from 'strict' to 'request_approval' (rebuild-table). Existing rows unchanged — only the default applied to new rows flips. - Router auto-create for unknown platform/chat drops the hardcoded 'strict' override; schema default applies. - src/db/schema.ts reference updated to match. Why default-flip: users wire their DM during setup and don't discover that 'strict' means "silent drop of everyone not in user_roles/members". The approval flow is the safe default — the admin sees the stranger, explicitly decides. 'public' stays opt-in for truly open channels. Failure modes (row NOT created so a future attempt can try again): - No eligible approver configured (fresh install before first owner). - No reachable DM for any approver. - Delivery adapter missing. Tests (src/modules/permissions/sender-approval.test.ts, 4 cases): - First unknown message → card delivered + row created - Retry while pending → dedup'd (1 card, 1 row) - Approve → member added + message replayed + container woken - Deny → row cleared + no member added Closes: ACTION-ITEMS item 5. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../011-pending-sender-approvals.ts | 57 ++++ src/db/migrations/index.ts | 2 + src/db/schema.ts | 22 +- .../db/pending-sender-approvals.ts | 59 ++++ src/modules/permissions/index.ts | 87 +++++- .../permissions/sender-approval.test.ts | 265 ++++++++++++++++++ src/modules/permissions/sender-approval.ts | 152 ++++++++++ src/router.ts | 5 +- 8 files changed, 645 insertions(+), 4 deletions(-) create mode 100644 src/db/migrations/011-pending-sender-approvals.ts create mode 100644 src/modules/permissions/db/pending-sender-approvals.ts create mode 100644 src/modules/permissions/sender-approval.test.ts create mode 100644 src/modules/permissions/sender-approval.ts diff --git a/src/db/migrations/011-pending-sender-approvals.ts b/src/db/migrations/011-pending-sender-approvals.ts new file mode 100644 index 0000000..cb47039 --- /dev/null +++ b/src/db/migrations/011-pending-sender-approvals.ts @@ -0,0 +1,57 @@ +/** + * Unknown-sender approval flow. When `unknown_sender_policy = 'request_approval'` + * a non-member message triggers a card to the most appropriate admin. An + * in-flight entry in this table dedups concurrent attempts from the same + * sender; the row is cleared on approve / deny. + * + * Also flips the `messaging_groups.unknown_sender_policy` default from 'strict' + * to 'request_approval' so fresh wirings don't silently swallow messages from + * users the admin hasn't added yet. Existing rows are left as-is (silent + * upgrade would change established behavior without the admin asking for it). + */ +import type Database from 'better-sqlite3'; +import type { Migration } from './index.js'; + +export const migration011: Migration = { + version: 11, + name: 'pending-sender-approvals', + up: (db: Database.Database) => { + db.exec(` + CREATE TABLE IF NOT EXISTS pending_sender_approvals ( + id TEXT PRIMARY KEY, + messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id), + agent_group_id TEXT NOT NULL REFERENCES agent_groups(id), + sender_identity TEXT NOT NULL, -- namespaced user id (channel_type:handle) + sender_name TEXT, + original_message TEXT NOT NULL, -- JSON serialized InboundEvent + approver_user_id TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(messaging_group_id, sender_identity) + ); + CREATE INDEX IF NOT EXISTS idx_pending_sender_approvals_mg + ON pending_sender_approvals(messaging_group_id); + `); + + // Default-flip: fresh messaging_groups default to request_approval instead + // of silently dropping. SQLite doesn't support modifying column DEFAULTs + // in place, so we rebuild the table via the classic rename-copy-drop + // pattern. Existing rows keep their current unknown_sender_policy value. + db.exec(` + CREATE TABLE messaging_groups_new ( + id TEXT PRIMARY KEY, + channel_type TEXT NOT NULL, + platform_id TEXT NOT NULL, + name TEXT, + is_group INTEGER DEFAULT 0, + unknown_sender_policy TEXT NOT NULL DEFAULT 'request_approval', + created_at TEXT NOT NULL, + UNIQUE(channel_type, platform_id) + ); + INSERT INTO messaging_groups_new + SELECT id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at + FROM messaging_groups; + DROP TABLE messaging_groups; + ALTER TABLE messaging_groups_new RENAME TO messaging_groups; + `); + }, +}; diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index d220688..1015f40 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -7,6 +7,7 @@ import { moduleAgentToAgentDestinations } from './module-agent-to-agent-destinat import { migration008 } from './008-dropped-messages.js'; import { migration009 } from './009-drop-pending-credentials.js'; import { migration010 } from './010-engage-modes.js'; +import { migration011 } from './011-pending-sender-approvals.js'; import { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js'; import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js'; @@ -25,6 +26,7 @@ const migrations: Migration[] = [ migration008, migration009, migration010, + migration011, ]; export function runMigrations(db: Database.Database): void { diff --git a/src/db/schema.ts b/src/db/schema.ts index 9dd887e..aa33fae 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -25,7 +25,11 @@ CREATE TABLE messaging_groups ( platform_id TEXT NOT NULL, name TEXT, is_group INTEGER DEFAULT 0, - unknown_sender_policy TEXT NOT NULL DEFAULT 'strict', -- 'strict' | 'request_approval' | 'public' + unknown_sender_policy TEXT NOT NULL DEFAULT 'request_approval', + -- 'strict' | 'request_approval' | 'public' + -- Default is request_approval so silent drops don't + -- mystery-break users who wired their DM during + -- setup and haven't explicitly marked it public. created_at TEXT NOT NULL, UNIQUE(channel_type, platform_id) ); @@ -123,6 +127,22 @@ CREATE TABLE pending_questions ( options_json TEXT NOT NULL, created_at TEXT NOT NULL ); + +-- Pending approvals for unknown senders (unknown_sender_policy='request_approval'). +-- In-flight dedup via UNIQUE(messaging_group_id, sender_identity): a second +-- message from the same unknown sender while a card is pending is silently +-- dropped instead of spamming the admin. +CREATE TABLE pending_sender_approvals ( + id TEXT PRIMARY KEY, + messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id), + agent_group_id TEXT NOT NULL REFERENCES agent_groups(id), + sender_identity TEXT NOT NULL, -- namespaced user id (channel_type:handle) + sender_name TEXT, + original_message TEXT NOT NULL, -- JSON of the original InboundEvent + approver_user_id TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(messaging_group_id, sender_identity) +); `; /** diff --git a/src/modules/permissions/db/pending-sender-approvals.ts b/src/modules/permissions/db/pending-sender-approvals.ts new file mode 100644 index 0000000..9f7e3a4 --- /dev/null +++ b/src/modules/permissions/db/pending-sender-approvals.ts @@ -0,0 +1,59 @@ +/** + * CRUD for pending_sender_approvals — the in-flight state for the + * request_approval unknown-sender flow. Rows are created when an unknown + * sender writes into a wired messaging group with that policy, and are + * deleted on admin approve (after adding the user as a member) or deny. + * + * UNIQUE(messaging_group_id, sender_identity) enforces in-flight dedup: + * a retry / second message from the same unknown sender while a card is + * still pending is silently dropped instead of spamming the admin. + */ +import { getDb } from '../../../db/connection.js'; + +export interface PendingSenderApproval { + id: string; + messaging_group_id: string; + agent_group_id: string; + sender_identity: string; + sender_name: string | null; + original_message: string; + approver_user_id: string; + created_at: string; +} + +export function createPendingSenderApproval(row: PendingSenderApproval): void { + getDb() + .prepare( + `INSERT INTO pending_sender_approvals ( + id, messaging_group_id, agent_group_id, sender_identity, + sender_name, original_message, approver_user_id, created_at + ) + VALUES ( + @id, @messaging_group_id, @agent_group_id, @sender_identity, + @sender_name, @original_message, @approver_user_id, @created_at + )`, + ) + .run(row); +} + +export function getPendingSenderApproval(id: string): PendingSenderApproval | undefined { + return getDb() + .prepare('SELECT * FROM pending_sender_approvals WHERE id = ?') + .get(id) as PendingSenderApproval | undefined; +} + +export function hasInFlightSenderApproval( + messagingGroupId: string, + senderIdentity: string, +): boolean { + const row = getDb() + .prepare( + 'SELECT 1 AS x FROM pending_sender_approvals WHERE messaging_group_id = ? AND sender_identity = ?', + ) + .get(messagingGroupId, senderIdentity) as { x: number } | undefined; + return row !== undefined; +} + +export function deletePendingSenderApproval(id: string): void { + getDb().prepare('DELETE FROM pending_sender_approvals WHERE id = ?').run(id); +} diff --git a/src/modules/permissions/index.ts b/src/modules/permissions/index.ts index ca97f8f..1d505b6 100644 --- a/src/modules/permissions/index.ts +++ b/src/modules/permissions/index.ts @@ -17,16 +17,24 @@ */ import { recordDroppedMessage } from '../../db/dropped-messages.js'; import { + routeInbound, setAccessGate, setSenderResolver, setSenderScopeGate, type AccessGateResult, type InboundEvent, } from '../../router.js'; +import { registerResponseHandler, type ResponsePayload } from '../../response-registry.js'; import { log } from '../../log.js'; import type { MessagingGroup, MessagingGroupAgent } from '../../types.js'; import { canAccessAgentGroup } from './access.js'; +import { addMember } from './db/agent-group-members.js'; +import { + deletePendingSenderApproval, + getPendingSenderApproval, +} from './db/pending-sender-approvals.js'; import { getUser, upsertUser } from './db/users.js'; +import { requestSenderApproval } from './sender-approval.js'; function extractAndUpsertUser(event: InboundEvent): string | null { let content: Record; @@ -82,11 +90,12 @@ function handleUnknownSender( event: InboundEvent, ): void { const parsed = safeParseContent(event.message.content); + const senderName = parsed.sender ?? null; const dropRecord = { channel_type: event.channelType, platform_id: event.platformId, user_id: userId, - sender_name: parsed.sender ?? null, + sender_name: senderName, reason: `unknown_sender_${mg.unknown_sender_policy}`, messaging_group_id: mg.id, agent_group_id: agentGroupId, @@ -104,13 +113,27 @@ function handleUnknownSender( } if (mg.unknown_sender_policy === 'request_approval') { - log.info('MESSAGE DROPPED — unknown sender (approval flow TODO)', { + log.info('MESSAGE DROPPED — unknown sender (approval requested)', { messagingGroupId: mg.id, agentGroupId, userId, accessReason, }); recordDroppedMessage(dropRecord); + // Fire-and-forget; pick-approver + delivery + row-insert are all async. + // If it fails it logs internally — the user's message still stays dropped + // either way. Requires a resolved userId (senderResolver populates users + // row before the gate fires); if we got here without one, there's nothing + // to identify for approval and we just stay in the "silent strict" branch. + if (userId) { + requestSenderApproval({ + messagingGroupId: mg.id, + agentGroupId, + senderIdentity: userId, + senderName, + event, + }).catch((err) => log.error('Sender-approval flow threw', { err })); + } return; } @@ -156,3 +179,63 @@ setSenderScopeGate( return { allowed: false, reason: `sender_scope_${decision.reason}` }; }, ); + +/** + * Response handler for the unknown-sender approval card. + * + * Claim rule: questionId matches a row in pending_sender_approvals. If no + * such row, return false so the next handler (approvals module, OneCLI, + * interactive) gets a shot. + * + * Approve: add the sender to agent_group_members + re-invoke routeInbound + * with the stored event. The second routing attempt clears the gate because + * the user is now a member. + * + * Deny: delete the row (no "deny list" — a future message re-triggers a + * fresh card per ACTION-ITEMS item 5 "no denial persistence"). + */ +async function handleSenderApprovalResponse(payload: ResponsePayload): Promise { + const row = getPendingSenderApproval(payload.questionId); + if (!row) return false; + + const approverId = payload.userId ?? row.approver_user_id; + const approved = payload.value === 'approve'; + + if (approved) { + addMember({ + user_id: row.sender_identity, + agent_group_id: row.agent_group_id, + added_by: approverId, + added_at: new Date().toISOString(), + }); + log.info('Unknown sender approved — member added', { + approvalId: row.id, + senderIdentity: row.sender_identity, + agentGroupId: row.agent_group_id, + approverId, + }); + + // Clear the pending row BEFORE re-routing so the gate check on the + // second attempt doesn't see the in-flight row and short-circuit. + deletePendingSenderApproval(row.id); + + try { + const event = JSON.parse(row.original_message) as InboundEvent; + await routeInbound(event); + } catch (err) { + log.error('Failed to replay message after sender approval', { approvalId: row.id, err }); + } + return true; + } + + log.info('Unknown sender denied', { + approvalId: row.id, + senderIdentity: row.sender_identity, + agentGroupId: row.agent_group_id, + approverId, + }); + deletePendingSenderApproval(row.id); + return true; +} + +registerResponseHandler(handleSenderApprovalResponse); diff --git a/src/modules/permissions/sender-approval.test.ts b/src/modules/permissions/sender-approval.test.ts new file mode 100644 index 0000000..a02c742 --- /dev/null +++ b/src/modules/permissions/sender-approval.test.ts @@ -0,0 +1,265 @@ +/** + * Integration tests for the unknown-sender request_approval flow + * (ACTION-ITEMS item 5). + * + * Covers: + * - request_approval policy fires `requestSenderApproval` on first unknown + * message from a sender + * - In-flight dedup: second message from the same sender while pending is + * silently dropped (no second card, no second row) + * - Approve path: member added, original message replayed via routeInbound, + * container woken + * - Deny path: pending row deleted, no member added + */ +import fs from 'fs'; +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; + +import { initTestDb, closeDb, runMigrations } from '../../db/index.js'; +import { createAgentGroup } from '../../db/agent-groups.js'; +import { createMessagingGroup, createMessagingGroupAgent } from '../../db/messaging-groups.js'; +import { upsertUser } from './db/users.js'; +import { grantRole } from './db/user-roles.js'; + +// Mock container runner — prevent actual docker spawn. +vi.mock('../../container-runner.js', () => ({ + wakeContainer: vi.fn().mockResolvedValue(undefined), + isContainerRunning: vi.fn().mockReturnValue(false), + getActiveContainerCount: vi.fn().mockReturnValue(0), + killContainer: vi.fn(), +})); + +// Mock delivery adapter — record card deliveries for assertions. +const deliverMock = vi.fn().mockResolvedValue('plat-msg-id'); +vi.mock('../../delivery.js', () => ({ + getDeliveryAdapter: () => ({ + deliver: deliverMock, + }), +})); + +// Mock ensureUserDm to return the approver's existing messaging group +// instead of hitting a real openDM RPC. +vi.mock('./user-dm.js', () => ({ + ensureUserDm: vi.fn(async (userId: string) => { + const { getDb } = await import('../../db/connection.js'); + const row = getDb() + .prepare( + `SELECT mg.* FROM messaging_groups mg + JOIN user_dms ud ON ud.messaging_group_id = mg.id + WHERE ud.user_id = ?`, + ) + .get(userId); + return row; + }), +})); + +vi.mock('../../config.js', async () => { + const actual = await vi.importActual('../../config.js'); + return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-sender-approval' }; +}); + +const TEST_DIR = '/tmp/nanoclaw-test-sender-approval'; + +function now() { + return new Date().toISOString(); +} + +beforeEach(async () => { + if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true }); + fs.mkdirSync(TEST_DIR, { recursive: true }); + const db = initTestDb(); + runMigrations(db); + + // Side-effect imports: register hooks (permissions module) AFTER the + // mocks are in place so the access gate / response handler pick up the + // mocked delivery + user-dm helpers. + await import('./index.js'); + + // Fixtures: agent group, messaging group with request_approval, wiring, + // owner + DM messaging group for approver delivery. + createAgentGroup({ id: 'ag-1', name: 'Agent', folder: 'agent', agent_provider: null, created_at: now() }); + + createMessagingGroup({ + id: 'mg-chat', + channel_type: 'telegram', + platform_id: 'chat-123', + name: 'Group Chat', + is_group: 1, + unknown_sender_policy: 'request_approval', + created_at: now(), + }); + createMessagingGroupAgent({ + id: 'mga-1', + messaging_group_id: 'mg-chat', + agent_group_id: 'ag-1', + engage_mode: 'pattern', + engage_pattern: '.', + sender_scope: 'all', + ignored_message_policy: 'drop', + session_mode: 'shared', + priority: 0, + created_at: now(), + }); + + // Owner user + their DM messaging group (pickApprover + ensureUserDm target). + upsertUser({ id: 'telegram:owner', kind: 'telegram', display_name: 'Owner', created_at: now() }); + grantRole({ + user_id: 'telegram:owner', + role: 'owner', + agent_group_id: null, + granted_by: null, + granted_at: now(), + }); + createMessagingGroup({ + id: 'mg-dm-owner', + channel_type: 'telegram', + platform_id: 'dm-owner', + name: 'Owner DM', + is_group: 0, + unknown_sender_policy: 'public', + created_at: now(), + }); + const { getDb } = await import('../../db/connection.js'); + getDb() + .prepare( + `INSERT INTO user_dms (user_id, channel_type, messaging_group_id, resolved_at) + VALUES (?, ?, ?, ?)`, + ) + .run('telegram:owner', 'telegram', 'mg-dm-owner', now()); + + deliverMock.mockClear(); +}); + +afterEach(() => { + closeDb(); + if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true }); +}); + +function stranger(text: string) { + return { + channelType: 'telegram', + platformId: 'chat-123', + threadId: null, + message: { + id: `stranger-${Math.random().toString(36).slice(2, 8)}`, + kind: 'chat' as const, + content: JSON.stringify({ + senderId: 'tg:stranger', + senderName: 'Stranger', + text, + }), + timestamp: now(), + }, + }; +} + +describe('unknown-sender request_approval flow', () => { + it('delivers an approval card on first unknown message', async () => { + const { routeInbound } = await import('../../router.js'); + await routeInbound(stranger('hi')); + + // Wait for the fire-and-forget requestSenderApproval to resolve. + await new Promise((r) => setTimeout(r, 10)); + + expect(deliverMock).toHaveBeenCalledTimes(1); + const [channel, platformId, thread, kind, content] = deliverMock.mock.calls[0]; + expect(channel).toBe('telegram'); + expect(platformId).toBe('dm-owner'); // delivered to owner's DM + expect(thread).toBeNull(); + expect(kind).toBe('chat-sdk'); + const payload = JSON.parse(content as string); + expect(payload.type).toBe('ask_question'); + expect(payload.questionId).toMatch(/^nsa-/); + + const { getDb } = await import('../../db/connection.js'); + const rows = getDb().prepare('SELECT * FROM pending_sender_approvals').all(); + expect(rows).toHaveLength(1); + }); + + it('dedups a second message from the same stranger while pending', async () => { + const { routeInbound } = await import('../../router.js'); + await routeInbound(stranger('hello')); + await new Promise((r) => setTimeout(r, 10)); + await routeInbound(stranger('are you there?')); + await new Promise((r) => setTimeout(r, 10)); + + expect(deliverMock).toHaveBeenCalledTimes(1); + const { getDb } = await import('../../db/connection.js'); + const count = ( + getDb().prepare('SELECT COUNT(*) AS c FROM pending_sender_approvals').get() as { c: number } + ).c; + expect(count).toBe(1); + }); + + it('approve → adds member and replays the original message', async () => { + const { routeInbound } = await import('../../router.js'); + const { getResponseHandlers } = await import('../../response-registry.js'); + const { wakeContainer } = await import('../../container-runner.js'); + (wakeContainer as unknown as ReturnType).mockClear(); + + await routeInbound(stranger('please let me in')); + await new Promise((r) => setTimeout(r, 10)); + + const { getDb } = await import('../../db/connection.js'); + const pending = getDb().prepare('SELECT id FROM pending_sender_approvals').get() as { id: string }; + expect(pending).toBeDefined(); + + // Fire the approve click through the response-handler chain. + for (const handler of getResponseHandlers()) { + const claimed = await handler({ + questionId: pending.id, + value: 'approve', + userId: 'telegram:owner', + channelType: 'telegram', + platformId: 'dm-owner', + threadId: null, + }); + if (claimed) break; + } + + // Member row added for the stranger against the wired agent group. + const member = getDb() + .prepare('SELECT 1 AS x FROM agent_group_members WHERE user_id = ? AND agent_group_id = ?') + .get('tg:stranger', 'ag-1'); + expect(member).toBeDefined(); + + // Pending row cleared. + const stillPending = getDb().prepare('SELECT COUNT(*) AS c FROM pending_sender_approvals').get() as { c: number }; + expect(stillPending.c).toBe(0); + + // Message replayed + container woken. + expect(wakeContainer).toHaveBeenCalled(); + }); + + it('deny → deletes the pending row without adding a member', async () => { + const { routeInbound } = await import('../../router.js'); + const { getResponseHandlers } = await import('../../response-registry.js'); + + await routeInbound(stranger('hello')); + await new Promise((r) => setTimeout(r, 10)); + + const { getDb } = await import('../../db/connection.js'); + const pending = getDb().prepare('SELECT id FROM pending_sender_approvals').get() as { id: string }; + expect(pending).toBeDefined(); + + for (const handler of getResponseHandlers()) { + const claimed = await handler({ + questionId: pending.id, + value: 'reject', + userId: 'telegram:owner', + channelType: 'telegram', + platformId: 'dm-owner', + threadId: null, + }); + if (claimed) break; + } + + const count = ( + getDb().prepare('SELECT COUNT(*) AS c FROM pending_sender_approvals').get() as { c: number } + ).c; + expect(count).toBe(0); + const member = getDb() + .prepare('SELECT 1 AS x FROM agent_group_members WHERE user_id = ? AND agent_group_id = ?') + .get('tg:stranger', 'ag-1'); + expect(member).toBeUndefined(); + }); +}); diff --git a/src/modules/permissions/sender-approval.ts b/src/modules/permissions/sender-approval.ts new file mode 100644 index 0000000..be60280 --- /dev/null +++ b/src/modules/permissions/sender-approval.ts @@ -0,0 +1,152 @@ +/** + * Unknown-sender approval flow. + * + * When `messaging_groups.unknown_sender_policy = 'request_approval'` and a + * non-member writes into a wired chat, the access gate drops the routing + * attempt and calls `requestSenderApproval` to: + * + * 1. Pick an eligible approver (owner / admin of the agent group). + * 2. Open / reuse a DM to that approver on a reachable channel. + * 3. Deliver an Approve / Deny card. + * 4. Record a pending_sender_approvals row that holds the original message + * so it can be re-routed on approve. + * + * On approve: the handler in index.ts adds an agent_group_members row for + * the sender and re-invokes routeInbound with the stored event — the second + * routing attempt passes the gate because the user is now a member. + * + * Failure modes (logged + row NOT created, so the dedup gate lets a future + * attempt try again): + * - No eligible approver in user_roles — fresh install, no owner yet. + * - Approver has no reachable DM (no user_dms row + channel can't + * openDM) — e.g. owner hasn't registered on any channel we're wired to. + * - Delivery adapter missing. + * + * Dedup: `pending_sender_approvals` has UNIQUE(messaging_group_id, + * sender_identity). A retry / rapid second message from the same unknown + * sender is silently dropped (no duplicate card sent). + */ +import { normalizeOptions, type RawOption } from '../../channels/ask-question.js'; +import { getMessagingGroup } from '../../db/messaging-groups.js'; +import { getDeliveryAdapter } from '../../delivery.js'; +import { log } from '../../log.js'; +import type { InboundEvent } from '../../router.js'; +import { pickApprovalDelivery, pickApprover } from '../approvals/primitive.js'; +import { createPendingSenderApproval, hasInFlightSenderApproval } from './db/pending-sender-approvals.js'; + +const APPROVAL_OPTIONS: RawOption[] = [ + { label: 'Allow', selectedLabel: '✅ Allowed', value: 'approve' }, + { label: 'Deny', selectedLabel: '❌ Denied', value: 'reject' }, +]; + +function generateId(): string { + return `nsa-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +export interface RequestSenderApprovalInput { + messagingGroupId: string; + agentGroupId: string; + senderIdentity: string; // namespaced user id (channel_type:handle) + senderName: string | null; + event: InboundEvent; +} + +export async function requestSenderApproval(input: RequestSenderApprovalInput): Promise { + const { messagingGroupId, agentGroupId, senderIdentity, senderName, event } = input; + + // In-flight dedup: don't spam the admin if the same unknown sender + // retries while a card is already pending. + if (hasInFlightSenderApproval(messagingGroupId, senderIdentity)) { + log.debug('Unknown-sender approval already in flight — dropping retry', { + messagingGroupId, + senderIdentity, + }); + return; + } + + const approvers = pickApprover(agentGroupId); + if (approvers.length === 0) { + log.warn('Unknown-sender approval skipped — no owner or admin configured', { + messagingGroupId, + agentGroupId, + senderIdentity, + }); + return; + } + + const originMg = getMessagingGroup(messagingGroupId); + const originChannelType = originMg?.channel_type ?? ''; + const target = await pickApprovalDelivery(approvers, originChannelType); + if (!target) { + log.warn('Unknown-sender approval skipped — no DM channel for any approver', { + messagingGroupId, + agentGroupId, + senderIdentity, + }); + return; + } + + const approvalId = generateId(); + const senderDisplay = senderName && senderName.length > 0 ? senderName : senderIdentity; + const originName = originMg?.name ?? originMg?.platform_id ?? 'an unfamiliar chat'; + + const title = '👤 New sender'; + const question = `${senderDisplay} wants to talk to your agent in ${originName}. Allow?`; + + createPendingSenderApproval({ + id: approvalId, + messaging_group_id: messagingGroupId, + agent_group_id: agentGroupId, + sender_identity: senderIdentity, + sender_name: senderName, + original_message: JSON.stringify(event), + approver_user_id: target.userId, + created_at: new Date().toISOString(), + }); + + const adapter = getDeliveryAdapter(); + if (!adapter) { + // Without a delivery adapter, the card can't be sent. Log + leave the + // row in place so the admin can see it via DB or manual tooling; the + // dedup gate will suppress further cards until it's cleared. + log.error('Unknown-sender approval row created but no delivery adapter is wired', { + approvalId, + }); + return; + } + + try { + await adapter.deliver( + target.messagingGroup.channel_type, + target.messagingGroup.platform_id, + null, + 'chat-sdk', + JSON.stringify({ + type: 'ask_question', + questionId: approvalId, + title, + question, + options: APPROVAL_OPTIONS, + }), + ); + log.info('Unknown-sender approval card delivered', { + approvalId, + senderIdentity, + approver: target.userId, + messagingGroupId, + agentGroupId, + }); + } catch (err) { + log.error('Unknown-sender approval card delivery failed', { + approvalId, + err, + }); + } +} + +/** + * Option value the admin clicked that means "allow" — shared with the + * response handler so the two sides can't drift. + */ +export const APPROVE_VALUE = 'approve'; +export const REJECT_VALUE = 'reject'; diff --git a/src/router.ts b/src/router.ts index 9b54cb2..cb4ee93 100644 --- a/src/router.ts +++ b/src/router.ts @@ -145,7 +145,10 @@ export async function routeInbound(event: InboundEvent): Promise { platform_id: event.platformId, name: null, is_group: 0, - unknown_sender_policy: 'strict', + // Let the schema default (currently 'request_approval') apply rather + // than hardcoding 'strict' — the schema is the source of truth for + // the default policy. See migration 011. + unknown_sender_policy: 'request_approval', created_at: new Date().toISOString(), }; createMessagingGroup(mg);