feat(v2): user-level privilege model + cold DM infra + init-first-agent skill
Replaces the agent-group-centric "main group" concept with user-level
privileges and adds the cold-DM infrastructure needed for proactive
outbound messaging (pairing, approvals, welcome flows).
Privilege model
- New tables: users, user_roles (owner global-only; admin global or
scoped to an agent_group), agent_group_members (explicit non-
privileged access; admin/owner imply membership), user_dms (cold-DM
resolution cache).
- Removed agent_groups.is_admin, messaging_groups.admin_user_id. Replaced
with messaging_groups.unknown_sender_policy (strict | request_approval
| public) for per-chat unknown-sender gating.
- src/access.ts: canAccessAgentGroup, pickApprover, pickApprovalDelivery.
- src/router.ts: access gate on every inbound, honoring
unknown_sender_policy for unknown senders.
- src/channels/telegram.ts: pairing interceptor upserts the paired user
and promotes them to owner if hasAnyOwner() is false (first-pair-wins).
Cold DM infrastructure
- ChannelAdapter.openDM?(handle) — optional method. Chat-SDK-bridge wires
it to chat.openDM() for resolution-required channels (Discord, Slack,
Teams, Webex, gChat); direct-addressable channels (Telegram, WhatsApp,
iMessage, Matrix, Resend) fall through to the handle directly.
- src/user-dm.ts: ensureUserDm(userId) — resolves + caches via user_dms.
Approval routing
- onecli-approvals + delivery use pickApprover + pickApprovalDelivery:
scoped admins → global admins → owners (dedup), first reachable via
ensureUserDm, same-channel-kind tie-break. Approvals land in the
approver's DM, not the origin chat.
Delivery fixes
- delivery.ts ACL rejection now throws instead of returning undefined —
the outer loop previously marked rejected messages as delivered.
- Implicit-origin allow: session.messaging_group_id === target skips the
destination check.
- createMessagingGroupAgent auto-creates the companion agent_destinations
row (normalized local_name from the messaging group's name, collision-
broken within the agent's namespace).
Container
- container-runner.ts: /workspace/global always read-only; drops
NANOCLAW_IS_ADMIN; adds NANOCLAW_ADMIN_USER_IDS (owners + global admins
+ scoped admins for this agent group). Agent-runner poll-loop gates
slash commands against that set.
New skill: /init-first-agent
- Walks the operator through standing up the first agent for a channel:
channel pick → identity lookup (reads each channel SKILL.md's
## Channel Info > how-to-find-id) → DM platform_id resolution (direct-
addressable, cold-DM via "user DMs bot first + sqlite lookup", or
Telegram pair-code fallback) → run scripts/init-first-agent.ts →
verify via tail of nanoclaw.log.
- scripts/init-first-agent.ts: parameterized helper that upserts the
user + grants owner (if none), creates dm-with-<display-name> agent
group + initGroupFilesystem, reuses/creates the DM messaging_group,
wires it (auto-creates destination), resolves the session, and writes
a kind:'chat' / sender:'system' welcome message into inbound.db. Host
sweep wakes the container and the agent DMs the operator via the
normal delivery path.
/manage-channels rewrite
- Drops --is-main / --jid / main-vs-non-main isolation references.
- First-channel flow delegates to /init-first-agent.
- Explains createMessagingGroupAgent auto-creates destinations.
- Adds a privileged-users show section.
setup/
- register.ts: drop --is-main, --jid, --local-name, --trigger
requiresTrigger defaults; call initGroupFilesystem; normalize to
v2 schema (no is_admin, no admin_user_id, sets unknown_sender_policy
'strict'); let createMessagingGroupAgent handle the destination row.
- pair-telegram.ts: emit PAIRED_USER_ID (namespaced "telegram:<id>")
instead of ADMIN_USER_ID; update header comment.
- register.test.ts deleted — was v1-only, tested a registered_groups
table that no longer exists.
Docs
- v2-architecture-diagram.{md,html}: ER diagram updated to drop
is_admin/admin_user_id, add unknown_sender_policy, and include
users/user_roles/agent_group_members/user_dms.
- v2-architecture-draft.md: approval-routing paragraph rewritten for
pickApprover/pickApprovalDelivery/ensureUserDm; SQL schema block
updated; admin-verification paragraph references
NANOCLAW_ADMIN_USER_IDS.
- v2-setup-wiring.md: entity-model sketch rewritten.
- v2-checklist.md: marked privilege refactor / container filtering /
approval routing / unknown-sender gating done; removed obsolete
admin_user_id and main-vs-non-main items.
Scripts
- scripts/init-first-agent.ts (new) replaces scripts/welcome-owner-dm.ts
(removed; welcome-owner was a Discord-specific one-off).
- test-v2-host.ts, test-v2-channel-e2e.ts, seed-discord.ts: drop
is_admin + admin_user_id, use unknown_sender_policy.
Tests
- src/access.test.ts (new): 14 tests for canAccessAgentGroup, role
helpers, pickApprover, ensureUserDm, pickApprovalDelivery.
- src/db/db-v2.test.ts: adds 3 tests for the auto-created
agent_destinations row (normalized name, no duplicates, collision
break within an agent group).
- host-core.test.ts, channel-registry.test.ts: updated fixtures to
use unknown_sender_policy: 'public' where the test exercises routing
rather than the access gate.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
46
src/db/agent-group-members.ts
Normal file
46
src/db/agent-group-members.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { AgentGroupMember } from '../types.js';
|
||||
import { getDb } from './connection.js';
|
||||
import { isAdminOfAgentGroup, isGlobalAdmin, isOwner } from './user-roles.js';
|
||||
|
||||
export function addMember(row: AgentGroupMember): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id, added_by, added_at)
|
||||
VALUES (@user_id, @agent_group_id, @added_by, @added_at)`,
|
||||
)
|
||||
.run(row);
|
||||
}
|
||||
|
||||
export function removeMember(userId: string, agentGroupId: string): void {
|
||||
getDb()
|
||||
.prepare('DELETE FROM agent_group_members WHERE user_id = ? AND agent_group_id = ?')
|
||||
.run(userId, agentGroupId);
|
||||
}
|
||||
|
||||
export function getMembers(agentGroupId: string): AgentGroupMember[] {
|
||||
return getDb()
|
||||
.prepare('SELECT * FROM agent_group_members WHERE agent_group_id = ? ORDER BY added_at')
|
||||
.all(agentGroupId) as AgentGroupMember[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the user "known" in this agent group?
|
||||
* Owner, global admin, and scoped admin are implicitly members.
|
||||
*/
|
||||
export function isMember(userId: string, agentGroupId: string): boolean {
|
||||
if (isOwner(userId) || isGlobalAdmin(userId) || isAdminOfAgentGroup(userId, agentGroupId)) {
|
||||
return true;
|
||||
}
|
||||
const row = getDb()
|
||||
.prepare('SELECT 1 FROM agent_group_members WHERE user_id = ? AND agent_group_id = ? LIMIT 1')
|
||||
.get(userId, agentGroupId);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
/** Direct row lookup — does not honor the admin/owner implicit-membership rule. */
|
||||
export function hasMembershipRow(userId: string, agentGroupId: string): boolean {
|
||||
const row = getDb()
|
||||
.prepare('SELECT 1 FROM agent_group_members WHERE user_id = ? AND agent_group_id = ? LIMIT 1')
|
||||
.get(userId, agentGroupId);
|
||||
return !!row;
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import { getDb } from './connection.js';
|
||||
export function createAgentGroup(group: AgentGroup): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO agent_groups (id, name, folder, is_admin, agent_provider, container_config, created_at)
|
||||
VALUES (@id, @name, @folder, @is_admin, @agent_provider, @container_config, @created_at)`,
|
||||
`INSERT INTO agent_groups (id, name, folder, agent_provider, container_config, created_at)
|
||||
VALUES (@id, @name, @folder, @agent_provider, @container_config, @created_at)`,
|
||||
)
|
||||
.run(group);
|
||||
}
|
||||
@@ -22,10 +22,6 @@ export function getAllAgentGroups(): AgentGroup[] {
|
||||
return getDb().prepare('SELECT * FROM agent_groups ORDER BY name').all() as AgentGroup[];
|
||||
}
|
||||
|
||||
export function getAdminAgentGroup(): AgentGroup | undefined {
|
||||
return getDb().prepare('SELECT * FROM agent_groups WHERE is_admin = 1 LIMIT 1').get() as AgentGroup | undefined;
|
||||
}
|
||||
|
||||
export function updateAgentGroup(
|
||||
id: string,
|
||||
updates: Partial<Pick<AgentGroup, 'name' | 'agent_provider' | 'container_config'>>,
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
getAgentGroup,
|
||||
getAgentGroupByFolder,
|
||||
getAllAgentGroups,
|
||||
getAdminAgentGroup,
|
||||
updateAgentGroup,
|
||||
deleteAgentGroup,
|
||||
createMessagingGroup,
|
||||
@@ -66,7 +65,6 @@ describe('agent groups', () => {
|
||||
id: 'ag-1',
|
||||
name: 'Test Agent',
|
||||
folder: 'test-agent',
|
||||
is_admin: 0,
|
||||
agent_provider: null,
|
||||
container_config: null,
|
||||
created_at: now(),
|
||||
@@ -93,14 +91,6 @@ describe('agent groups', () => {
|
||||
expect(getAllAgentGroups()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should find admin group', () => {
|
||||
createAgentGroup(ag());
|
||||
createAgentGroup({ ...ag(), id: 'ag-admin', name: 'Admin', folder: 'admin', is_admin: 1 });
|
||||
const admin = getAdminAgentGroup();
|
||||
expect(admin).toBeDefined();
|
||||
expect(admin!.id).toBe('ag-admin');
|
||||
});
|
||||
|
||||
it('should update', () => {
|
||||
createAgentGroup(ag());
|
||||
updateAgentGroup('ag-1', { name: 'Updated' });
|
||||
@@ -128,7 +118,7 @@ describe('messaging groups', () => {
|
||||
platform_id: 'chan-123',
|
||||
name: 'General',
|
||||
is_group: 1,
|
||||
admin_user_id: 'user-1',
|
||||
unknown_sender_policy: 'strict' as const,
|
||||
created_at: now(),
|
||||
});
|
||||
|
||||
@@ -172,7 +162,6 @@ describe('messaging group agents', () => {
|
||||
id: 'ag-1',
|
||||
name: 'Agent',
|
||||
folder: 'agent',
|
||||
is_admin: 0,
|
||||
agent_provider: null,
|
||||
container_config: null,
|
||||
created_at: now(),
|
||||
@@ -183,7 +172,7 @@ describe('messaging group agents', () => {
|
||||
platform_id: 'chan-1',
|
||||
name: 'Gen',
|
||||
is_group: 1,
|
||||
admin_user_id: null,
|
||||
unknown_sender_policy: 'strict',
|
||||
created_at: now(),
|
||||
});
|
||||
});
|
||||
@@ -212,7 +201,6 @@ describe('messaging group agents', () => {
|
||||
id: 'ag-2',
|
||||
name: 'Agent2',
|
||||
folder: 'agent2',
|
||||
is_admin: 0,
|
||||
agent_provider: null,
|
||||
container_config: null,
|
||||
created_at: now(),
|
||||
@@ -243,6 +231,47 @@ describe('messaging group agents', () => {
|
||||
it('should enforce foreign key on agent_group_id', () => {
|
||||
expect(() => createMessagingGroupAgent({ ...mga(), agent_group_id: 'nonexistent' })).toThrow();
|
||||
});
|
||||
|
||||
it('auto-creates an agent_destinations row for the wiring', async () => {
|
||||
const { getDestinationByTarget, getDestinations } = await import('./agent-destinations.js');
|
||||
createMessagingGroupAgent(mga());
|
||||
|
||||
const dest = getDestinationByTarget('ag-1', 'channel', 'mg-1');
|
||||
expect(dest).toBeDefined();
|
||||
expect(dest!.local_name).toBe('gen'); // normalized from mg.name='Gen'
|
||||
expect(getDestinations('ag-1')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not duplicate destination row on re-wiring', async () => {
|
||||
const { getDestinations } = await import('./agent-destinations.js');
|
||||
createMessagingGroupAgent(mga());
|
||||
// Re-create the same wiring throws (PK unique), but even if we got the
|
||||
// row in some other way (e.g. via createDestination directly followed
|
||||
// by createMessagingGroupAgent), we should not end up with two rows.
|
||||
deleteMessagingGroupAgent('mga-1');
|
||||
createMessagingGroupAgent(mga());
|
||||
expect(getDestinations('ag-1')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('breaks local_name collisions within an agent group', async () => {
|
||||
const { getDestinations } = await import('./agent-destinations.js');
|
||||
// Two messaging groups with the same `name` wired to the same agent
|
||||
// should get distinct local_names (gen, gen-2).
|
||||
createMessagingGroupAgent(mga());
|
||||
createMessagingGroup({
|
||||
id: 'mg-2',
|
||||
channel_type: 'discord',
|
||||
platform_id: 'chan-2',
|
||||
name: 'Gen',
|
||||
is_group: 1,
|
||||
unknown_sender_policy: 'strict',
|
||||
created_at: now(),
|
||||
});
|
||||
createMessagingGroupAgent({ ...mga(), id: 'mga-2', messaging_group_id: 'mg-2' });
|
||||
|
||||
const dests = getDestinations('ag-1').map((d) => d.local_name).sort();
|
||||
expect(dests).toEqual(['gen', 'gen-2']);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Sessions ──
|
||||
@@ -253,7 +282,6 @@ describe('sessions', () => {
|
||||
id: 'ag-1',
|
||||
name: 'Agent',
|
||||
folder: 'agent',
|
||||
is_admin: 0,
|
||||
agent_provider: null,
|
||||
container_config: null,
|
||||
created_at: now(),
|
||||
@@ -264,7 +292,7 @@ describe('sessions', () => {
|
||||
platform_id: 'chan-1',
|
||||
name: 'Gen',
|
||||
is_group: 1,
|
||||
admin_user_id: null,
|
||||
unknown_sender_policy: 'strict',
|
||||
created_at: now(),
|
||||
});
|
||||
});
|
||||
@@ -349,7 +377,6 @@ describe('pending questions', () => {
|
||||
id: 'ag-1',
|
||||
name: 'Agent',
|
||||
folder: 'agent',
|
||||
is_admin: 0,
|
||||
agent_provider: null,
|
||||
container_config: null,
|
||||
created_at: now(),
|
||||
|
||||
@@ -5,10 +5,25 @@ export {
|
||||
getAgentGroup,
|
||||
getAgentGroupByFolder,
|
||||
getAllAgentGroups,
|
||||
getAdminAgentGroup,
|
||||
updateAgentGroup,
|
||||
deleteAgentGroup,
|
||||
} from './agent-groups.js';
|
||||
export { createUser, upsertUser, getUser, getAllUsers, updateDisplayName, deleteUser } from './users.js';
|
||||
export {
|
||||
grantRole,
|
||||
revokeRole,
|
||||
getUserRoles,
|
||||
isOwner,
|
||||
isGlobalAdmin,
|
||||
isAdminOfAgentGroup,
|
||||
hasAdminPrivilege,
|
||||
getOwners,
|
||||
hasAnyOwner,
|
||||
getGlobalAdmins,
|
||||
getAdminsOfAgentGroup,
|
||||
} from './user-roles.js';
|
||||
export { addMember, removeMember, getMembers, isMember, hasMembershipRow } from './agent-group-members.js';
|
||||
export { upsertUserDm, getUserDm, getUserDmsForUser, deleteUserDm } from './user-dms.js';
|
||||
export {
|
||||
createMessagingGroup,
|
||||
getMessagingGroup,
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { MessagingGroup, MessagingGroupAgent } from '../types.js';
|
||||
import {
|
||||
createDestination,
|
||||
getDestinationByName,
|
||||
getDestinationByTarget,
|
||||
normalizeName,
|
||||
} from './agent-destinations.js';
|
||||
import { getDb } from './connection.js';
|
||||
|
||||
// ── Messaging Groups ──
|
||||
@@ -6,8 +12,8 @@ import { getDb } from './connection.js';
|
||||
export function createMessagingGroup(group: MessagingGroup): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, admin_user_id, created_at)
|
||||
VALUES (@id, @channel_type, @platform_id, @name, @is_group, @admin_user_id, @created_at)`,
|
||||
`INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at)
|
||||
VALUES (@id, @channel_type, @platform_id, @name, @is_group, @unknown_sender_policy, @created_at)`,
|
||||
)
|
||||
.run(group);
|
||||
}
|
||||
@@ -32,7 +38,7 @@ export function getMessagingGroupsByChannel(channelType: string): MessagingGroup
|
||||
|
||||
export function updateMessagingGroup(
|
||||
id: string,
|
||||
updates: Partial<Pick<MessagingGroup, 'name' | 'is_group' | 'admin_user_id'>>,
|
||||
updates: Partial<Pick<MessagingGroup, 'name' | 'is_group' | 'unknown_sender_policy'>>,
|
||||
): void {
|
||||
const fields: string[] = [];
|
||||
const values: Record<string, unknown> = { id };
|
||||
@@ -56,6 +62,19 @@ export function deleteMessagingGroup(id: string): void {
|
||||
|
||||
// ── Messaging Group Agents ──
|
||||
|
||||
/**
|
||||
* Wire a messaging group to an agent group. Also auto-creates the matching
|
||||
* `agent_destinations` row so the agent can deliver to this chat as a
|
||||
* target, not just reply to the origin. Without this, routing to chats that
|
||||
* aren't the session's origin (agent-shared sessions, cross-channel sends)
|
||||
* would require an operator to hand-insert destination rows every time.
|
||||
*
|
||||
* The destination row is skipped if one already exists for the same target,
|
||||
* so re-wiring is a no-op. The local_name uses the messaging group's `name`
|
||||
* field when set, falling back to `${channel_type}-${mg_id prefix}`, with
|
||||
* a numeric suffix to break collisions within the agent's namespace. This
|
||||
* mirrors the backfill logic in migration 004.
|
||||
*/
|
||||
export function createMessagingGroupAgent(mga: MessagingGroupAgent): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
@@ -63,6 +82,30 @@ export function createMessagingGroupAgent(mga: MessagingGroupAgent): void {
|
||||
VALUES (@id, @messaging_group_id, @agent_group_id, @trigger_rules, @response_scope, @session_mode, @priority, @created_at)`,
|
||||
)
|
||||
.run(mga);
|
||||
|
||||
// Auto-create an agent_destinations row so delivery's ACL doesn't block
|
||||
// outbound messages that target this chat.
|
||||
const existing = getDestinationByTarget(mga.agent_group_id, 'channel', mga.messaging_group_id);
|
||||
if (existing) return;
|
||||
|
||||
const mg = getMessagingGroup(mga.messaging_group_id);
|
||||
if (!mg) return;
|
||||
|
||||
const base = normalizeName(mg.name || `${mg.channel_type}-${mga.messaging_group_id.slice(0, 8)}`);
|
||||
let localName = base;
|
||||
let suffix = 2;
|
||||
while (getDestinationByName(mga.agent_group_id, localName)) {
|
||||
localName = `${base}-${suffix}`;
|
||||
suffix++;
|
||||
}
|
||||
|
||||
createDestination({
|
||||
agent_group_id: mga.agent_group_id,
|
||||
local_name: localName,
|
||||
target_type: 'channel',
|
||||
target_id: mga.messaging_group_id,
|
||||
created_at: mga.created_at,
|
||||
});
|
||||
}
|
||||
|
||||
export function getMessagingGroupAgents(messagingGroupId: string): MessagingGroupAgent[] {
|
||||
|
||||
@@ -11,20 +11,19 @@ export const migration001: Migration = {
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
folder TEXT NOT NULL UNIQUE,
|
||||
is_admin INTEGER DEFAULT 0,
|
||||
agent_provider TEXT,
|
||||
container_config TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE messaging_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_type TEXT NOT NULL,
|
||||
platform_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
is_group INTEGER DEFAULT 0,
|
||||
admin_user_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
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 'strict',
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(channel_type, platform_id)
|
||||
);
|
||||
|
||||
@@ -40,6 +39,50 @@ export const migration001: Migration = {
|
||||
UNIQUE(messaging_group_id, agent_group_id)
|
||||
);
|
||||
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- role ∈ {owner, admin}
|
||||
-- owner: agent_group_id must be NULL (always global)
|
||||
-- admin: agent_group_id NULL = global, else scoped
|
||||
CREATE TABLE user_roles (
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
role TEXT NOT NULL,
|
||||
agent_group_id TEXT REFERENCES agent_groups(id),
|
||||
granted_by TEXT REFERENCES users(id),
|
||||
granted_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, role, agent_group_id)
|
||||
);
|
||||
CREATE INDEX idx_user_roles_scope ON user_roles(agent_group_id, role);
|
||||
|
||||
-- "known" membership in an agent group. Admin @ A implies membership
|
||||
-- without needing a row (invariant enforced in code).
|
||||
CREATE TABLE agent_group_members (
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
|
||||
added_by TEXT REFERENCES users(id),
|
||||
added_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, agent_group_id)
|
||||
);
|
||||
|
||||
-- DM channel cache: for each (user, channel) pair, which messaging_group
|
||||
-- row is their direct-message channel. Populated on demand by
|
||||
-- ensureUserDm() — either from adapter.openDM() for channels that
|
||||
-- distinguish user id from DM chat id (Discord, Slack, Teams) or by
|
||||
-- pointing directly at the user's handle for channels where they're
|
||||
-- the same (Telegram, WhatsApp, iMessage, email, Matrix).
|
||||
CREATE TABLE user_dms (
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
channel_type TEXT NOT NULL,
|
||||
messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id),
|
||||
resolved_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, channel_type)
|
||||
);
|
||||
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
|
||||
|
||||
@@ -5,26 +5,27 @@
|
||||
*/
|
||||
|
||||
export const SCHEMA = `
|
||||
-- Agent workspaces: folder, skills, CLAUDE.md, container config
|
||||
-- Agent workspaces: folder, skills, CLAUDE.md, container config.
|
||||
-- All workspaces are equal; privilege lives on users, not groups.
|
||||
CREATE TABLE agent_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
folder TEXT NOT NULL UNIQUE,
|
||||
is_admin INTEGER DEFAULT 0,
|
||||
agent_provider TEXT,
|
||||
container_config TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Platform groups/channels
|
||||
-- Platform groups/channels. unknown_sender_policy governs what happens
|
||||
-- when a sender we've never seen before posts in this chat.
|
||||
CREATE TABLE messaging_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_type TEXT NOT NULL,
|
||||
platform_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
is_group INTEGER DEFAULT 0,
|
||||
admin_user_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
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 'strict', -- 'strict' | 'request_approval' | 'public'
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(channel_type, platform_id)
|
||||
);
|
||||
|
||||
@@ -41,6 +42,52 @@ CREATE TABLE messaging_group_agents (
|
||||
UNIQUE(messaging_group_id, agent_group_id)
|
||||
);
|
||||
|
||||
-- Users are messaging-platform identifiers, namespaced: "phone:+1555...",
|
||||
-- "tg:123", "discord:456", "email:a@x.com". A single human can own multiple
|
||||
-- user rows if they have identifiers on unrelated channels (no linking yet).
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Role grants on users. Privilege is user-level, not group-level.
|
||||
-- role ∈ {owner, admin}
|
||||
-- owner: always global (agent_group_id IS NULL)
|
||||
-- admin: agent_group_id NULL = global, else scoped to that agent group
|
||||
-- Invariant: admin @ A implies membership in A (no row needed).
|
||||
CREATE TABLE user_roles (
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
role TEXT NOT NULL,
|
||||
agent_group_id TEXT REFERENCES agent_groups(id),
|
||||
granted_by TEXT REFERENCES users(id),
|
||||
granted_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, role, agent_group_id)
|
||||
);
|
||||
CREATE INDEX idx_user_roles_scope ON user_roles(agent_group_id, role);
|
||||
|
||||
-- "Known" membership in an agent group. Required for an unprivileged user
|
||||
-- to interact with a workspace. Admin @ A is implicitly a member of A.
|
||||
CREATE TABLE agent_group_members (
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
|
||||
added_by TEXT REFERENCES users(id),
|
||||
added_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, agent_group_id)
|
||||
);
|
||||
|
||||
-- Cached mapping from (user, channel) to the DM messaging group. Lets the
|
||||
-- host initiate cold DMs (pairing, approvals) without reprobing the
|
||||
-- platform API on every send. Populated lazily by ensureUserDm().
|
||||
CREATE TABLE user_dms (
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
channel_type TEXT NOT NULL,
|
||||
messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id),
|
||||
resolved_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, channel_type)
|
||||
);
|
||||
|
||||
-- Sessions: one folder = one session = one container when running
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -105,9 +152,9 @@ CREATE TABLE IF NOT EXISTS delivered (
|
||||
);
|
||||
|
||||
-- Destination map for this session's agent.
|
||||
-- Host overwrites on every container wake AND on demand (admin rewires, new child agents, etc.).
|
||||
-- Container queries this live on every lookup, so admin changes take effect
|
||||
-- mid-session without requiring a container restart.
|
||||
-- Host overwrites on every container wake AND on demand (rewires, new child
|
||||
-- agents, etc.). Container queries this live on every lookup, so changes
|
||||
-- take effect mid-session without requiring a container restart.
|
||||
CREATE TABLE IF NOT EXISTS destinations (
|
||||
name TEXT PRIMARY KEY,
|
||||
display_name TEXT,
|
||||
|
||||
28
src/db/user-dms.ts
Normal file
28
src/db/user-dms.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { UserDm } from '../types.js';
|
||||
import { getDb } from './connection.js';
|
||||
|
||||
export function upsertUserDm(row: UserDm): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO user_dms (user_id, channel_type, messaging_group_id, resolved_at)
|
||||
VALUES (@user_id, @channel_type, @messaging_group_id, @resolved_at)
|
||||
ON CONFLICT(user_id, channel_type) DO UPDATE SET
|
||||
messaging_group_id = excluded.messaging_group_id,
|
||||
resolved_at = excluded.resolved_at`,
|
||||
)
|
||||
.run(row);
|
||||
}
|
||||
|
||||
export function getUserDm(userId: string, channelType: string): UserDm | undefined {
|
||||
return getDb()
|
||||
.prepare('SELECT * FROM user_dms WHERE user_id = ? AND channel_type = ?')
|
||||
.get(userId, channelType) as UserDm | undefined;
|
||||
}
|
||||
|
||||
export function getUserDmsForUser(userId: string): UserDm[] {
|
||||
return getDb().prepare('SELECT * FROM user_dms WHERE user_id = ?').all(userId) as UserDm[];
|
||||
}
|
||||
|
||||
export function deleteUserDm(userId: string, channelType: string): void {
|
||||
getDb().prepare('DELETE FROM user_dms WHERE user_id = ? AND channel_type = ?').run(userId, channelType);
|
||||
}
|
||||
85
src/db/user-roles.ts
Normal file
85
src/db/user-roles.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { UserRole, UserRoleKind } from '../types.js';
|
||||
import { getDb } from './connection.js';
|
||||
|
||||
/**
|
||||
* Grant a role. Owner rows must have agent_group_id = null (enforced here,
|
||||
* not by schema, so callers get a clean error path).
|
||||
*/
|
||||
export function grantRole(row: UserRole): void {
|
||||
if (row.role === 'owner' && row.agent_group_id !== null) {
|
||||
throw new Error('owner role must be global (agent_group_id = null)');
|
||||
}
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at)
|
||||
VALUES (@user_id, @role, @agent_group_id, @granted_by, @granted_at)`,
|
||||
)
|
||||
.run(row);
|
||||
}
|
||||
|
||||
export function revokeRole(userId: string, role: UserRoleKind, agentGroupId: string | null): void {
|
||||
if (agentGroupId === null) {
|
||||
getDb()
|
||||
.prepare('DELETE FROM user_roles WHERE user_id = ? AND role = ? AND agent_group_id IS NULL')
|
||||
.run(userId, role);
|
||||
} else {
|
||||
getDb()
|
||||
.prepare('DELETE FROM user_roles WHERE user_id = ? AND role = ? AND agent_group_id = ?')
|
||||
.run(userId, role, agentGroupId);
|
||||
}
|
||||
}
|
||||
|
||||
export function getUserRoles(userId: string): UserRole[] {
|
||||
return getDb().prepare('SELECT * FROM user_roles WHERE user_id = ?').all(userId) as UserRole[];
|
||||
}
|
||||
|
||||
export function isOwner(userId: string): boolean {
|
||||
const row = getDb()
|
||||
.prepare('SELECT 1 FROM user_roles WHERE user_id = ? AND role = ? AND agent_group_id IS NULL LIMIT 1')
|
||||
.get(userId, 'owner');
|
||||
return !!row;
|
||||
}
|
||||
|
||||
export function isGlobalAdmin(userId: string): boolean {
|
||||
const row = getDb()
|
||||
.prepare('SELECT 1 FROM user_roles WHERE user_id = ? AND role = ? AND agent_group_id IS NULL LIMIT 1')
|
||||
.get(userId, 'admin');
|
||||
return !!row;
|
||||
}
|
||||
|
||||
export function isAdminOfAgentGroup(userId: string, agentGroupId: string): boolean {
|
||||
const row = getDb()
|
||||
.prepare('SELECT 1 FROM user_roles WHERE user_id = ? AND role = ? AND agent_group_id = ? LIMIT 1')
|
||||
.get(userId, 'admin', agentGroupId);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
/** Any admin privilege over this agent group: global admin OR scoped admin. */
|
||||
export function hasAdminPrivilege(userId: string, agentGroupId: string): boolean {
|
||||
return isOwner(userId) || isGlobalAdmin(userId) || isAdminOfAgentGroup(userId, agentGroupId);
|
||||
}
|
||||
|
||||
export function getOwners(): UserRole[] {
|
||||
return getDb()
|
||||
.prepare('SELECT * FROM user_roles WHERE role = ? AND agent_group_id IS NULL ORDER BY granted_at')
|
||||
.all('owner') as UserRole[];
|
||||
}
|
||||
|
||||
export function hasAnyOwner(): boolean {
|
||||
const row = getDb()
|
||||
.prepare('SELECT 1 FROM user_roles WHERE role = ? AND agent_group_id IS NULL LIMIT 1')
|
||||
.get('owner');
|
||||
return !!row;
|
||||
}
|
||||
|
||||
export function getGlobalAdmins(): UserRole[] {
|
||||
return getDb()
|
||||
.prepare('SELECT * FROM user_roles WHERE role = ? AND agent_group_id IS NULL ORDER BY granted_at')
|
||||
.all('admin') as UserRole[];
|
||||
}
|
||||
|
||||
export function getAdminsOfAgentGroup(agentGroupId: string): UserRole[] {
|
||||
return getDb()
|
||||
.prepare('SELECT * FROM user_roles WHERE role = ? AND agent_group_id = ? ORDER BY granted_at')
|
||||
.all('admin', agentGroupId) as UserRole[];
|
||||
}
|
||||
38
src/db/users.ts
Normal file
38
src/db/users.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { User } from '../types.js';
|
||||
import { getDb } from './connection.js';
|
||||
|
||||
export function createUser(user: User): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO users (id, kind, display_name, created_at)
|
||||
VALUES (@id, @kind, @display_name, @created_at)`,
|
||||
)
|
||||
.run(user);
|
||||
}
|
||||
|
||||
export function upsertUser(user: User): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO users (id, kind, display_name, created_at)
|
||||
VALUES (@id, @kind, @display_name, @created_at)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
display_name = COALESCE(excluded.display_name, users.display_name)`,
|
||||
)
|
||||
.run(user);
|
||||
}
|
||||
|
||||
export function getUser(id: string): User | undefined {
|
||||
return getDb().prepare('SELECT * FROM users WHERE id = ?').get(id) as User | undefined;
|
||||
}
|
||||
|
||||
export function getAllUsers(): User[] {
|
||||
return getDb().prepare('SELECT * FROM users ORDER BY created_at').all() as User[];
|
||||
}
|
||||
|
||||
export function updateDisplayName(id: string, displayName: string): void {
|
||||
getDb().prepare('UPDATE users SET display_name = ? WHERE id = ?').run(displayName, id);
|
||||
}
|
||||
|
||||
export function deleteUser(id: string): void {
|
||||
getDb().prepare('DELETE FROM users WHERE id = ?').run(id);
|
||||
}
|
||||
Reference in New Issue
Block a user