feat(permissions): unknown-channel registration flow with owner approval

When the router sees a mention or DM on a messaging group that isn't wired
to any agent, it now escalates to an owner for approval instead of silently
dropping. Mirrors the existing unknown-sender approval pattern (ACTION-ITEMS
item 22).

Schema (migration 012):
- `messaging_groups.denied_at TEXT NULL` — timestamp set on deny so future
  mentions stop escalating. ALTER TABLE ADD COLUMN, FK-safe (unlike the
  rebuild that bit migration 011).
- `pending_channel_approvals` — PK on `messaging_group_id` gives free
  in-flight dedup. One card per channel, no spam on rapid retries.

Router:
- New hook `setChannelRequestGate(mg, event) => Promise<void>`, invoked
  from the no-wirings branch when the message was addressed to the bot
  (isMention=true). Hook is fire-and-forget.
- Checks `mg.denied_at` before escalating — denied channels drop silently
  and do not re-prompt.
- The two "no-wirings" branches (fresh auto-create and existing mg with
  no agents) are consolidated into one escalation path that calls the
  gate once. Without the module, behavior is log + record (no regression).

Permissions module:
- `channel-approval.ts::requestChannelApproval` — MVP picker: target
  agent is `getAllAgentGroups()[0]`, card names it explicitly ("Wire it
  to <Andy>?"). Approver via existing `pickApprover` + `pickApprovalDelivery`
  primitives.
- Response handler: same click-auth pattern as sender-approval (clicker
  must be the designated approver OR have admin privilege over the
  target agent group).
- Approve defaults per the feature spec:
    engage_mode = 'mention-sticky' for groups, 'pattern' + '.' for DMs
    sender_scope = 'known'
    ignored_message_policy = 'accumulate'
    session_mode = 'shared'
  DM vs group inferred from the original event's threadId (non-null →
  group) because the auto-created mg has a placeholder is_group=0 until
  the adapter fills it in.
- Triggering sender is auto-added to agent_group_members so sender_scope=
  'known' doesn't bounce the replayed message into a sender-approval
  cascade.
- Deny: stamps messaging_groups.denied_at, clears pending row.
- Failure modes — no owner, no agent groups, no reachable DM — log and
  drop without creating a pending row, letting a future attempt try
  again (same as sender-approval).

9 new integration tests cover every branch: mention triggers card, DM
triggers card, dedup, approve creates correct wiring + admits sender +
replays, approve-on-DM uses pattern/'.' defaults, deny sets denied_at
and future mentions drop silently, unauthorized clicker rejected,
no-owner drops, no-agent-groups drops.

168 tests pass (was 159; +9).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-20 14:34:00 +03:00
parent a4061a0012
commit 719f97e483
9 changed files with 882 additions and 18 deletions

View File

@@ -0,0 +1,52 @@
/**
* CRUD for pending_channel_approvals — the in-flight state for the
* unknown-channel registration flow. A row exists while an owner-approval
* card is outstanding; it's deleted on approve (after wiring is created)
* or deny (after denied_at is set on the messaging_group).
*
* PRIMARY KEY on messaging_group_id gives free in-flight dedup. A second
* mention/DM while a card is pending resolves via
* `hasInFlightChannelApproval` in the request flow and drops silently
* instead of spamming the owner.
*/
import { getDb } from '../../../db/connection.js';
export interface PendingChannelApproval {
messaging_group_id: string;
agent_group_id: string;
original_message: string;
approver_user_id: string;
created_at: string;
}
export function createPendingChannelApproval(row: PendingChannelApproval): void {
getDb()
.prepare(
`INSERT INTO pending_channel_approvals (
messaging_group_id, agent_group_id, original_message,
approver_user_id, created_at
)
VALUES (
@messaging_group_id, @agent_group_id, @original_message,
@approver_user_id, @created_at
)`,
)
.run(row);
}
export function getPendingChannelApproval(messagingGroupId: string): PendingChannelApproval | undefined {
return getDb()
.prepare('SELECT * FROM pending_channel_approvals WHERE messaging_group_id = ?')
.get(messagingGroupId) as PendingChannelApproval | undefined;
}
export function hasInFlightChannelApproval(messagingGroupId: string): boolean {
const row = getDb()
.prepare('SELECT 1 AS x FROM pending_channel_approvals WHERE messaging_group_id = ?')
.get(messagingGroupId) as { x: number } | undefined;
return row !== undefined;
}
export function deletePendingChannelApproval(messagingGroupId: string): void {
getDb().prepare('DELETE FROM pending_channel_approvals WHERE messaging_group_id = ?').run(messagingGroupId);
}