feat(permissions): unknown-sender request_approval flow + flipped default policy
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) <noreply@anthropic.com>
This commit is contained in:
57
src/db/migrations/011-pending-sender-approvals.ts
Normal file
57
src/db/migrations/011-pending-sender-approvals.ts
Normal file
@@ -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;
|
||||
`);
|
||||
},
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
`;
|
||||
|
||||
/**
|
||||
|
||||
59
src/modules/permissions/db/pending-sender-approvals.ts
Normal file
59
src/modules/permissions/db/pending-sender-approvals.ts
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
@@ -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<boolean> {
|
||||
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);
|
||||
|
||||
265
src/modules/permissions/sender-approval.test.ts
Normal file
265
src/modules/permissions/sender-approval.test.ts
Normal file
@@ -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<typeof vi.fn>).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();
|
||||
});
|
||||
});
|
||||
152
src/modules/permissions/sender-approval.ts
Normal file
152
src/modules/permissions/sender-approval.ts
Normal file
@@ -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<void> {
|
||||
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';
|
||||
@@ -145,7 +145,10 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user