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)
|
||||
);
|
||||
`;
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user