refactor(modules): extract permissions as optional module
Moves user-roles / users / agent-group-members / user-dms / dropped-messages / user-dm / canAccessAgentGroup into src/modules/permissions/. Module registers a single inbound-gate that owns sender resolution, access decision, unknown-sender policy, and drop-audit recording. Router slimmed from 357 → 179 lines; the inline fallback chain (extractAndUpsertUser / enforceAccess / handleUnknownSender / recordDroppedMessage) is gone — without the permissions module core defaults to allow-all with userId=null. container-runner's admin-ID query is now inline SQL guarded by sqlite_master on user_roles, keeping core free of any import from the permissions module. The container-side formatter falls back to permissionless mode when NANOCLAW_ADMIN_USER_IDS is empty: every sender with an identifiable senderId is treated as admin. Module contract doc formalizes the tier model and the dependency rule (core ← default modules ← optional modules). One transitional violation flagged: src/access.ts (core) imports from the permissions module for its remaining approver-picking helpers; resolves in the planned PR #7 re-tier. Validation: host build clean, 137/137 host tests, 17/17 container tests, typecheck clean, service boots to "NanoClaw running" with permissions module registering its gate and clean SIGTERM shutdown. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
215
src/router.ts
215
src/router.ts
@@ -1,32 +1,22 @@
|
||||
/**
|
||||
* Inbound message routing for v2.
|
||||
* Inbound message routing.
|
||||
*
|
||||
* Channel adapter event → resolve messaging group → access gate → resolve
|
||||
* agent group → resolve/create session → write messages_in → wake container.
|
||||
* Channel adapter event → resolve messaging group → pick agent → inbound
|
||||
* gate (if set) → resolve/create session → write messages_in → wake
|
||||
* container.
|
||||
*
|
||||
* Privilege / access model:
|
||||
* - Owners and global admins: always allowed
|
||||
* - Scoped admins: allowed in their agent group
|
||||
* - Known members (agent_group_members row): allowed in that agent group
|
||||
* - Everyone else: message is dropped per `messaging_groups.unknown_sender_policy`
|
||||
* (strict / request_approval / public)
|
||||
*
|
||||
* Sender normalization: we derive a namespaced user id from the message
|
||||
* content. This is best-effort — native adapters put `sender` in content,
|
||||
* chat-sdk-bridge adapters put `senderId`. Adapters should populate both
|
||||
* wherever possible so the gate can land on a real user row.
|
||||
* Access model lives in the permissions module via `setInboundGate`. Without
|
||||
* the module, the gate is unset and every message routes through
|
||||
* (downstream code tolerates `userId=null`). Drops by policy are only
|
||||
* recorded when the permissions module is loaded; core just logs.
|
||||
*/
|
||||
import { canAccessAgentGroup } from './access.js';
|
||||
import { getChannelAdapter } from './channels/channel-registry.js';
|
||||
import { isMember } from './db/agent-group-members.js';
|
||||
import { getMessagingGroupByPlatform, createMessagingGroup, getMessagingGroupAgents } from './db/messaging-groups.js';
|
||||
import { upsertUser, getUser } from './db/users.js';
|
||||
import { startTypingRefresh } from './modules/typing/index.js';
|
||||
import { log } from './log.js';
|
||||
import { resolveSession, writeSessionMessage } from './session-manager.js';
|
||||
import { wakeContainer } from './container-runner.js';
|
||||
import { getSession } from './db/sessions.js';
|
||||
import { recordDroppedMessage } from './db/dropped-messages.js';
|
||||
import type { MessagingGroup, MessagingGroupAgent } from './types.js';
|
||||
|
||||
function generateId(): string {
|
||||
@@ -46,17 +36,15 @@ export interface InboundEvent {
|
||||
}
|
||||
|
||||
/**
|
||||
* Inbound gate registry.
|
||||
* Inbound gate hook.
|
||||
*
|
||||
* A module (permissions, today) can register a single gate function that
|
||||
* owns sender resolution + access decision. Without a registered gate,
|
||||
* core falls back to the inline `extractAndUpsertUser` +
|
||||
* `enforceAccess` + `handleUnknownSender` chain.
|
||||
* The permissions module registers a gate that owns sender resolution +
|
||||
* access decision + unknown-sender policy + drop-audit recording. Without
|
||||
* a gate, core defaults to allow-all with `userId=null`.
|
||||
*
|
||||
* Takes the raw event so the gate can read sender fields from
|
||||
* `event.message.content`. Returns either allowed=true with a `userId`
|
||||
* (null if unresolved) or allowed=false with a reason; core drops the
|
||||
* message on refusal.
|
||||
* (null if unresolved) or allowed=false with a reason; core drops on refusal.
|
||||
*/
|
||||
export type InboundGateResult =
|
||||
| { allowed: true; userId: string | null }
|
||||
@@ -79,9 +67,7 @@ export function setInboundGate(fn: InboundGateFn): void {
|
||||
*/
|
||||
export async function routeInbound(event: InboundEvent): Promise<void> {
|
||||
// 0. Apply the adapter's thread policy. Non-threaded adapters (Telegram,
|
||||
// WhatsApp, iMessage, email) collapse threads to the channel — the
|
||||
// agent always replies to the main channel regardless of where the
|
||||
// inbound came from.
|
||||
// WhatsApp, iMessage, email) collapse threads to the channel.
|
||||
const adapter = getChannelAdapter(event.channelType);
|
||||
if (adapter && !adapter.supportsThreads) {
|
||||
event = { ...event, threadId: null };
|
||||
@@ -109,8 +95,7 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Resolve agent groups wired to this messaging group. (The gate runs
|
||||
// after this so it can decide based on the target agent group.)
|
||||
// 2. Resolve agent groups wired to this messaging group.
|
||||
const agents = getMessagingGroupAgents(mg.id);
|
||||
if (agents.length === 0) {
|
||||
log.warn('MESSAGE DROPPED — no agent groups wired to this channel. Run setup register step to configure.', {
|
||||
@@ -118,43 +103,21 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
|
||||
channelType: event.channelType,
|
||||
platformId: event.platformId,
|
||||
});
|
||||
const parsed = safeParseContent(event.message.content);
|
||||
recordDroppedMessage({
|
||||
channel_type: event.channelType,
|
||||
platform_id: event.platformId,
|
||||
user_id: parsed.senderId ?? null,
|
||||
sender_name: parsed.sender ?? null,
|
||||
reason: 'no_agent_wired',
|
||||
messaging_group_id: mg.id,
|
||||
agent_group_id: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Pick the best matching agent (highest priority, trigger matching in future)
|
||||
const match = pickAgent(agents, event);
|
||||
if (!match) {
|
||||
log.warn('MESSAGE DROPPED — no agent matched trigger rules', {
|
||||
messagingGroupId: mg.id,
|
||||
channelType: event.channelType,
|
||||
});
|
||||
const parsed = safeParseContent(event.message.content);
|
||||
recordDroppedMessage({
|
||||
channel_type: event.channelType,
|
||||
platform_id: event.platformId,
|
||||
user_id: parsed.senderId ?? null,
|
||||
sender_name: parsed.sender ?? null,
|
||||
reason: 'no_trigger_match',
|
||||
messaging_group_id: mg.id,
|
||||
agent_group_id: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Inbound gate: sender resolution + access decision. If a module
|
||||
// registered a gate, it owns the whole thing (it can upsert users,
|
||||
// check roles, etc.). Otherwise fall back to the inline chain.
|
||||
let userId: string | null;
|
||||
// 3. Inbound gate (if the permissions module is loaded). Otherwise
|
||||
// allow-all with userId=null — downstream code tolerates null.
|
||||
let userId: string | null = null;
|
||||
if (inboundGate) {
|
||||
const result = inboundGate(event, mg, match.agent_group_id);
|
||||
userId = result.userId;
|
||||
@@ -167,23 +130,13 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
userId = extractAndUpsertUser(event);
|
||||
if (mg.unknown_sender_policy !== 'public') {
|
||||
const gate = enforceAccess(userId, match.agent_group_id);
|
||||
if (!gate.allowed) {
|
||||
handleUnknownSender(mg, userId, match.agent_group_id, gate.reason, event);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Resolve or create session.
|
||||
// 4. Resolve or create session.
|
||||
//
|
||||
// Adapter thread policy overrides the wiring's session_mode: if the adapter
|
||||
// is threaded, each thread gets its own session regardless of what the
|
||||
// wiring says, because "thread = session" is the first-class model for
|
||||
// threaded platforms. Agent-shared is preserved because it expresses a
|
||||
// wiring says. Agent-shared is preserved because it expresses a
|
||||
// cross-channel intent the adapter can't know about.
|
||||
//
|
||||
// Exception: DMs (is_group=0). Sub-threads within a DM are a UX affordance,
|
||||
@@ -195,7 +148,7 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
|
||||
}
|
||||
const { session, created } = resolveSession(match.agent_group_id, mg.id, event.threadId, effectiveSessionMode);
|
||||
|
||||
// 6. Write message to session DB
|
||||
// 5. Write message to session DB
|
||||
writeSessionMessage(session.agent_group_id, session.id, {
|
||||
id: event.message.id || generateId(),
|
||||
kind: event.message.kind,
|
||||
@@ -214,16 +167,10 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
|
||||
created,
|
||||
});
|
||||
|
||||
// 7. Show typing indicator while the agent processes. Refresh on a short
|
||||
// interval so platforms like Discord (which auto-expire typing after
|
||||
// ~10s) keep showing it for the full thinking window. Gated on the
|
||||
// heartbeat file's mtime after an initial grace period, so typing stops
|
||||
// as soon as the agent goes idle — not when the container eventually
|
||||
// exits. Container-runner also calls stopTypingRefresh on exit as a
|
||||
// fast-path cleanup.
|
||||
// 6. Show typing indicator while the agent processes.
|
||||
startTypingRefresh(session.id, session.agent_group_id, event.channelType, event.platformId, event.threadId);
|
||||
|
||||
// 8. Wake container
|
||||
// 7. Wake container
|
||||
const freshSession = getSession(session.id);
|
||||
if (freshSession) {
|
||||
await wakeContainer(freshSession);
|
||||
@@ -239,119 +186,3 @@ function pickAgent(agents: MessagingGroupAgent[], _event: InboundEvent): Messagi
|
||||
// TODO: apply trigger_rules matching (pattern, mentionOnly, etc.)
|
||||
return agents[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort sender extraction. Returns a namespaced user id like
|
||||
* `telegram:123` or null if nothing usable is present.
|
||||
*
|
||||
* Side-effect: upserts the user into the `users` table so access/approval
|
||||
* lookups can find them on subsequent messages.
|
||||
*
|
||||
* The namespace uses the channel_type as `kind` for now — e.g. `whatsapp:...`
|
||||
* rather than `phone:...`. That's imprecise (a phone number is really the
|
||||
* identifier, not the channel) but it keeps the first cut simple. A proper
|
||||
* kind mapping (channel → kind) can happen when we start linking identities
|
||||
* across channels.
|
||||
*/
|
||||
function extractAndUpsertUser(event: InboundEvent): string | null {
|
||||
let content: Record<string, unknown>;
|
||||
try {
|
||||
content = JSON.parse(event.message.content) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
// chat-sdk-bridge serializes author info as a nested `author.userId` and
|
||||
// does NOT populate top-level `senderId`. Older adapters (v1, native) put
|
||||
// `senderId` or `sender` directly at the top level. Check all three.
|
||||
const senderIdField = typeof content.senderId === 'string' ? content.senderId : undefined;
|
||||
const senderField = typeof content.sender === 'string' ? content.sender : undefined;
|
||||
const author =
|
||||
typeof content.author === 'object' && content.author !== null
|
||||
? (content.author as Record<string, unknown>)
|
||||
: undefined;
|
||||
const authorUserId = typeof author?.userId === 'string' ? (author.userId as string) : undefined;
|
||||
const senderName =
|
||||
(typeof content.senderName === 'string' ? content.senderName : undefined) ??
|
||||
(typeof author?.fullName === 'string' ? (author.fullName as string) : undefined) ??
|
||||
(typeof author?.userName === 'string' ? (author.userName as string) : undefined);
|
||||
|
||||
const rawHandle = senderIdField ?? senderField ?? authorUserId;
|
||||
if (!rawHandle) return null;
|
||||
|
||||
// If the raw handle already contains ':' it's pre-namespaced (the older
|
||||
// adapters put it in that form). Otherwise prepend the channel type.
|
||||
const userId = rawHandle.includes(':') ? rawHandle : `${event.channelType}:${rawHandle}`;
|
||||
if (!getUser(userId)) {
|
||||
upsertUser({
|
||||
id: userId,
|
||||
kind: event.channelType,
|
||||
display_name: senderName ?? null,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
function enforceAccess(userId: string | null, agentGroupId: string): { allowed: boolean; reason: string } {
|
||||
if (!userId) return { allowed: false, reason: 'unknown_user' };
|
||||
const decision = canAccessAgentGroup(userId, agentGroupId);
|
||||
if (decision.allowed) return { allowed: true, reason: decision.reason };
|
||||
return { allowed: false, reason: decision.reason };
|
||||
}
|
||||
|
||||
function handleUnknownSender(
|
||||
mg: MessagingGroup,
|
||||
userId: string | null,
|
||||
agentGroupId: string,
|
||||
accessReason: string,
|
||||
event: InboundEvent,
|
||||
): void {
|
||||
const parsed = safeParseContent(event.message.content);
|
||||
const dropRecord = {
|
||||
channel_type: event.channelType,
|
||||
platform_id: event.platformId,
|
||||
user_id: userId,
|
||||
sender_name: parsed.sender ?? null,
|
||||
reason: `unknown_sender_${mg.unknown_sender_policy}`,
|
||||
messaging_group_id: mg.id,
|
||||
agent_group_id: agentGroupId,
|
||||
};
|
||||
|
||||
// In 'strict' mode we just drop. In 'request_approval' mode we log and
|
||||
// queue an approval to add the sender as a member — the approval flow
|
||||
// itself is a follow-up (needs an action kind like `add_group_member`).
|
||||
if (mg.unknown_sender_policy === 'strict') {
|
||||
log.info('MESSAGE DROPPED — unknown sender (strict policy)', {
|
||||
messagingGroupId: mg.id,
|
||||
agentGroupId,
|
||||
userId,
|
||||
accessReason,
|
||||
});
|
||||
recordDroppedMessage(dropRecord);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mg.unknown_sender_policy === 'request_approval') {
|
||||
log.info('MESSAGE DROPPED — unknown sender (approval flow TODO)', {
|
||||
messagingGroupId: mg.id,
|
||||
agentGroupId,
|
||||
userId,
|
||||
accessReason,
|
||||
});
|
||||
recordDroppedMessage(dropRecord);
|
||||
return;
|
||||
}
|
||||
|
||||
// Should be unreachable — 'public' was handled before the gate.
|
||||
// Ensure the membership invariant isn't in an odd state.
|
||||
void isMember;
|
||||
}
|
||||
|
||||
function safeParseContent(raw: string): { text?: string; sender?: string; senderId?: string } {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return { text: raw };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user