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:
gavrielc
2026-04-15 00:02:39 +03:00
parent 8430e543c1
commit 0d3326aae5
45 changed files with 1875 additions and 981 deletions

View 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;
}

View File

@@ -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'>>,

View File

@@ -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(),

View File

@@ -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,

View File

@@ -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[] {

View File

@@ -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),

View File

@@ -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
View 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
View 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
View 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);
}