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:
@@ -100,6 +100,20 @@ export function deleteMessagingGroup(id: string): void {
|
||||
getDb().prepare('DELETE FROM messaging_groups WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a messaging group as denied by the owner (channel-registration flow).
|
||||
* Future mentions on this channel silently drop until an admin explicitly
|
||||
* wires it via `createMessagingGroupAgent`, which implicitly clears the
|
||||
* denied state by making `agentCount > 0` — the router's denied-channel
|
||||
* check sits on the `agentCount === 0` branch.
|
||||
*
|
||||
* Passing null unsets the flag (used by tests or a future "unblock channel"
|
||||
* admin command).
|
||||
*/
|
||||
export function setMessagingGroupDeniedAt(id: string, deniedAt: string | null): void {
|
||||
getDb().prepare('UPDATE messaging_groups SET denied_at = ? WHERE id = ?').run(deniedAt, id);
|
||||
}
|
||||
|
||||
// ── Messaging Group Agents ──
|
||||
|
||||
/**
|
||||
|
||||
48
src/db/migrations/012-channel-registration.ts
Normal file
48
src/db/migrations/012-channel-registration.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Unknown-channel registration flow.
|
||||
*
|
||||
* When a channel that isn't wired to any agent group receives a mention or
|
||||
* DM, the router escalates to the owner for approval before wiring. Approve
|
||||
* creates a `messaging_group_agents` row (with conservative defaults) and
|
||||
* replays the triggering event. Deny marks the channel denied forever
|
||||
* (stored as a timestamp on `messaging_groups.denied_at`) so future
|
||||
* messages on that channel drop silently without re-prompting.
|
||||
*
|
||||
* Two changes:
|
||||
* 1. `messaging_groups.denied_at TEXT NULL` — set on deny, checked in the
|
||||
* router before re-escalating. ALTER TABLE ADD COLUMN is FK-safe
|
||||
* unlike the table rebuild that bit us in migration 011.
|
||||
* 2. `pending_channel_approvals` table. PRIMARY KEY on
|
||||
* `messaging_group_id` gives free in-flight dedup — a second mention
|
||||
* while the card is pending is silently dropped by INSERT OR IGNORE,
|
||||
* preventing card spam.
|
||||
*/
|
||||
import type Database from 'better-sqlite3';
|
||||
import type { Migration } from './index.js';
|
||||
|
||||
export const migration012: Migration = {
|
||||
version: 12,
|
||||
name: 'channel-registration',
|
||||
up: (db: Database.Database) => {
|
||||
// 1. Add denied_at to messaging_groups. Idempotent guard in case the
|
||||
// column was added by some other path before this migration ran.
|
||||
const cols = db.prepare("PRAGMA table_info('messaging_groups')").all() as Array<{ name: string }>;
|
||||
if (!cols.some((c) => c.name === 'denied_at')) {
|
||||
db.exec(`ALTER TABLE messaging_groups ADD COLUMN denied_at TEXT`);
|
||||
}
|
||||
|
||||
// 2. pending_channel_approvals.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS pending_channel_approvals (
|
||||
messaging_group_id TEXT PRIMARY KEY REFERENCES messaging_groups(id),
|
||||
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
|
||||
-- The agent the approved wiring will target.
|
||||
-- Picked at request time (currently: earliest
|
||||
-- agent_group by created_at).
|
||||
original_message TEXT NOT NULL, -- JSON serialized InboundEvent
|
||||
approver_user_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
},
|
||||
};
|
||||
@@ -8,6 +8,7 @@ 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 { migration012 } from './012-channel-registration.js';
|
||||
import { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js';
|
||||
import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js';
|
||||
|
||||
@@ -27,6 +28,7 @@ const migrations: Migration[] = [
|
||||
migration009,
|
||||
migration010,
|
||||
migration011,
|
||||
migration012,
|
||||
];
|
||||
|
||||
export function runMigrations(db: Database.Database): void {
|
||||
|
||||
Reference in New Issue
Block a user