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:
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);
|
||||
}
|
||||
Reference in New Issue
Block a user