Q101: a connector can hold several labelled company accounts, each granted and used by name

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-08 16:46:46 -04:00
1 parent 736c45c0ee
commit 4a6641fd49
23 files changed
+306 -151

No files matched your search

@@ -4,7 +4,7 @@ import { ComposioClient, type ComposioConnectedAccount } from "./composio-client
import { IntegrationKeyStore } from "../integration-key-store";
import { getComposioUserId } from "./composio-tool-bridge";
import { connectorDisplayName, connectorLogoUrl } from "./connector-brands";
import { normalizeSlug } from "./connector-grants";
import { DEFAULT_ACCOUNT_LABEL, normalizeAccountLabel, normalizeSlug } from "./connector-grants";
const CATALOG_TTL_MS = 10 * 60 * 1000;
const WAITING_STATUSES = new Set(["INITIALIZING", "INITIATED"]);
@@ -35,9 +35,9 @@ export class ComposioConnectionService {
return this.catalog(this.requireClient());
}
/** Starts a toolkit's connection; a reconnect waits beside an active account until refresh finds it authorized. */
connectToolkit(orgId: string, toolkitSlug: string): Promise<ConnectComposioToolkitResult> {
return this.serialize(orgId, () => this.connectToolkitNow(orgId, toolkitSlug));
/** Starts a toolkit account's connection; a reconnect waits beside an active account until refresh finds it authorized. */
connectToolkit(orgId: string, toolkitSlug: string, label: string): Promise<ConnectComposioToolkitResult> {
return this.serialize(orgId, () => this.connectToolkitNow(orgId, toolkitSlug, label));
}
/** See class docs: linearizes connect/refresh/disconnect for one org. */
@@ -48,14 +48,15 @@ export class ComposioConnectionService {
return next;
}
private async connectToolkitNow(orgId: string, toolkitSlug: string): Promise<ConnectComposioToolkitResult> {
private async connectToolkitNow(orgId: string, toolkitSlug: string, rawLabel: string): Promise<ConnectComposioToolkitResult> {
const slug = normalizeSlug(toolkitSlug);
const label = normalizeAccountLabel(rawLabel);
const client = this.requireClient();
const entry = (await this.catalog(client)).find((item) => item.slug === slug);
if (!entry) throw new Error(`No auth config for "${slug}" exists in the Composio project.`);
const previous = this.db.prepare(
"select id, status, pending_account_id from connector_accounts where org_id = ? and toolkit_slug = ?",
).get(orgId, slug) as { id: string; status: string; pending_account_id: string | null } | undefined;
"select id, status, pending_account_id from connector_accounts where org_id = ? and toolkit_slug = ? and label = ?",
).get(orgId, slug, label) as { id: string; status: string; pending_account_id: string | null } | undefined;
const account = await client.createConnectedAccount({
authConfigId: entry.authConfigId,
userId: getComposioUserId(this.db, orgId),
@@ -64,29 +65,28 @@ export class ComposioConnectionService {
const now = new Date().toISOString();
// The local row moves to the new account before any old one is deleted; refresh ignores accounts no row tracks.
if (previous?.status === "ACTIVE") {
this.db.prepare("update connector_accounts set pending_account_id = ?, updated_at = ? where org_id = ? and toolkit_slug = ?")
.run(account.id, now, orgId, slug);
console.info(`[connectors] ${slug} reconnect ${account.id} started; ${previous.id} stays in use until it is authorized`);
this.db.prepare("update connector_accounts set pending_account_id = ?, updated_at = ? where id = ?").run(account.id, now, previous.id);
console.info(`[connectors] ${slug}/${label} reconnect ${account.id} started; ${previous.id} stays in use until it is authorized`);
if (previous.pending_account_id) await client.deleteConnectedAccount(previous.pending_account_id);
return { accountId: account.id, redirectUrl: account.redirectUrl };
}
this.db.prepare(`
insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, auth_config_id, status, created_at, updated_at)
values (?, ?, ?, ?, ?, ?, ?, ?)
on conflict(org_id, toolkit_slug) do update set
insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, label, auth_config_id, status, created_at, updated_at)
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
on conflict(org_id, toolkit_slug, label) do update set
id = excluded.id,
toolkit_name = excluded.toolkit_name,
auth_config_id = excluded.auth_config_id,
status = excluded.status,
pending_account_id = null,
updated_at = excluded.updated_at
`).run(account.id, orgId, slug, entry.name, entry.authConfigId, "INITIALIZING", now, now);
`).run(account.id, orgId, slug, entry.name, label, entry.authConfigId, "INITIALIZING", now, now);
if (previous?.pending_account_id) await client.deleteConnectedAccount(previous.pending_account_id);
if (previous) await client.deleteConnectedAccount(previous.id);
return { accountId: account.id, redirectUrl: account.redirectUrl };
}
/** Mirrors Composio: tracked accounts take its status, gone ones are removed with their grants, untracked toolkits are adopted. */
/** Mirrors Composio: tracked accounts take its status, gone ones are removed with their grants, toolkits with no account here are adopted. */
refreshAccounts(orgId: string): Promise<void> {
return this.serialize(orgId, () => this.refreshAccountsNow(orgId));
}
@@ -99,36 +99,27 @@ export class ComposioConnectionService {
const reconnects = this.db.prepare("select id, toolkit_slug, pending_account_id from connector_accounts where org_id = ? and pending_account_id is not null")
.all(orgId) as Array<{ id: string; toolkit_slug: string; pending_account_id: string }>;
for (const row of reconnects) await this.settleReconnect(client, row, accounts.find((account) => account.id === row.pending_account_id), liveIds.has(row.id));
const localRows = this.db.prepare("select id, toolkit_slug, pending_account_id from connector_accounts where org_id = ?")
.all(orgId) as Array<{ id: string; toolkit_slug: string; pending_account_id: string | null }>;
const localRows = this.db.prepare("select id, toolkit_slug, label from connector_accounts where org_id = ?")
.all(orgId) as Array<{ id: string; toolkit_slug: string; label: string }>;
for (const row of localRows) {
if (liveIds.has(row.id)) continue;
this.removeLocalAccount(orgId, row.toolkit_slug);
if (!liveIds.has(row.id)) this.removeLocalAccount(orgId, row.toolkit_slug, row.label);
}
const tracked = new Map(localRows.filter((row) => liveIds.has(row.id)).map((row) => [row.toolkit_slug, row]));
const tracked = this.trackedIds(orgId);
const toolkitsHere = new Set(localRows.filter((row) => liveIds.has(row.id)).map((row) => row.toolkit_slug));
for (const account of accounts) {
if (!account.toolkitSlug) continue;
const row = tracked.get(account.toolkitSlug);
// A toolkit's row follows only its own account; others (a waiting reconnect, an orphan) never replace it.
if (row && row.id !== account.id) continue;
tracked.set(account.toolkitSlug, { id: account.id, toolkit_slug: account.toolkitSlug, pending_account_id: null });
if (tracked.has(account.id)) {
this.db.prepare("update connector_accounts set auth_config_id = ?, status = ?, updated_at = ? where id = ?")
.run(account.authConfigId, account.status, now, account.id);
continue;
}
// Only a toolkit with no account here is adopted; other untracked accounts (a waiting reconnect, an orphan) never join.
if (toolkitsHere.has(account.toolkitSlug)) continue;
toolkitsHere.add(account.toolkitSlug);
this.db.prepare(`
insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, auth_config_id, status, created_at, updated_at)
values (?, ?, ?, ?, ?, ?, ?, ?)
on conflict(org_id, toolkit_slug) do update set
auth_config_id = excluded.auth_config_id,
status = excluded.status,
updated_at = excluded.updated_at
`).run(
account.id,
orgId,
account.toolkitSlug,
connectorDisplayName(account.toolkitSlug),
account.authConfigId,
account.status,
account.createdAt || now,
now,
);
insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, label, auth_config_id, status, created_at, updated_at)
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(account.id, orgId, account.toolkitSlug, connectorDisplayName(account.toolkitSlug), DEFAULT_ACCOUNT_LABEL, account.authConfigId, account.status, account.createdAt || now, now);
}
}
@@ -153,34 +144,39 @@ export class ComposioConnectionService {
console.warn(`[connectors] ${row.toolkit_slug} reconnect ${row.pending_account_id} ended ${pending?.status ?? "missing"}; ${row.id} stays in use`);
}
/**
* Deletes the company's connection for a toolkit: removes the connected
* account in Composio (already-gone counts as deleted), then the local
* mirror row and every agent grant that pointed at it.
*/
disconnectToolkit(orgId: string, toolkitSlug: string): Promise<void> {
return this.serialize(orgId, () => this.disconnectToolkitNow(orgId, toolkitSlug));
/** Deletes one account of a toolkit in Composio, with untracked leftovers of that toolkit, then its row and the grants that named it. */
disconnectToolkit(orgId: string, toolkitSlug: string, label: string): Promise<void> {
return this.serialize(orgId, () => this.disconnectToolkitNow(orgId, toolkitSlug, label));
}
private async disconnectToolkitNow(orgId: string, toolkitSlug: string): Promise<void> {
private async disconnectToolkitNow(orgId: string, toolkitSlug: string, rawLabel: string): Promise<void> {
const slug = normalizeSlug(toolkitSlug);
const label = normalizeAccountLabel(rawLabel);
const row = this.db.prepare(
"select id, pending_account_id from connector_accounts where org_id = ? and toolkit_slug = ?",
).get(orgId, slug) as { id: string; pending_account_id: string | null } | undefined;
"select id, pending_account_id from connector_accounts where org_id = ? and toolkit_slug = ? and label = ?",
).get(orgId, slug, label) as { id: string; pending_account_id: string | null } | undefined;
if (!row) return;
const client = this.requireClient();
// Every account Composio holds for the toolkit goes, so an orphan from a failed delete cannot be adopted back.
// Untracked accounts of the toolkit go too, so an orphan from a failed delete cannot be adopted later.
const tracked = this.trackedIds(orgId);
const ids = new Set([row.id, ...(row.pending_account_id ? [row.pending_account_id] : [])]);
for (const account of await client.listConnectedAccounts(getComposioUserId(this.db, orgId))) {
if (account.toolkitSlug === slug) ids.add(account.id);
if (account.toolkitSlug === slug && !tracked.has(account.id)) ids.add(account.id);
}
for (const id of ids) await client.deleteConnectedAccount(id);
this.removeLocalAccount(orgId, slug);
this.removeLocalAccount(orgId, slug, label);
}
private removeLocalAccount(orgId: string, slug: string): void {
this.db.prepare("delete from connector_accounts where org_id = ? and toolkit_slug = ?").run(orgId, slug);
this.db.prepare("delete from agent_connector_grants where org_id = ? and toolkit_slug = ?").run(orgId, slug);
/** Every Composio account a row here stands for, waiting reconnects included. */
private trackedIds(orgId: string): Set<string> {
const rows = this.db.prepare("select id, pending_account_id from connector_accounts where org_id = ?")
.all(orgId) as Array<{ id: string; pending_account_id: string | null }>;
return new Set(rows.flatMap((row) => (row.pending_account_id ? [row.id, row.pending_account_id] : [row.id])));
}
private removeLocalAccount(orgId: string, slug: string, label: string): void {
this.db.prepare("delete from connector_accounts where org_id = ? and toolkit_slug = ? and label = ?").run(orgId, slug, label);
this.db.prepare("delete from agent_connector_grants where org_id = ? and toolkit_slug = ? and account_label = ?").run(orgId, slug, label);
}
private requireClient(): ComposioClient {
@@ -49,6 +49,7 @@ export function loadComposioTools(db: DatabaseSync, keys: IntegrationKeyStore, o
properties: {
action: { type: "string", description: "The action's name exactly as found, e.g. GITHUB_CREATE_A_PULL_REQUEST." },
arguments: { type: "object", description: "The action's input, following the schema the search returned." },
account: { type: "string", description: "Which account of the service, by label; needed only when you can reach several." },
confirm_not_done: { type: "boolean", description: "Only after an identical call's reply was lost and you checked in the service that it did not take effect." },
},
required: ["action", "arguments"],
@@ -109,14 +110,12 @@ async function runAction(db: DatabaseSync, apiKey: string, orgId: string, agentI
if (!new ConnectorGrantsService(db).toolkitsForAgent(orgId, agentId).includes(toolkit)) {
return `ERROR: You have no grant for the "${toolkit}" connector. Ask the CEO to grant it to you (grant_connector).`;
}
const account = db.prepare(
"select id from connector_accounts where org_id = ? and toolkit_slug = ? and status = 'ACTIVE'",
).get(orgId, toolkit) as { id: string } | undefined;
if (!account) return `ERROR: The "${toolkit}" connector is no longer connected. Ask the board to reconnect it on the Connectors page.`;
if (args.account !== undefined && typeof args.account !== "string") return "ERROR: account must be the label of one of the service's accounts.";
const account = new ConnectorGrantsService(db).accountForAgent(orgId, agentId, toolkit, args.account ?? null);
const input = typeof args.arguments === "object" && args.arguments !== null && !Array.isArray(args.arguments) ? args.arguments as Record<string, unknown> : null;
if (!input) return "ERROR: arguments must be an object matching the action's input.";
if (args.confirm_not_done !== undefined && typeof args.confirm_not_done !== "boolean") return "ERROR: confirm_not_done must be true or false.";
const started = beginConnectorExecution(db, { orgId, agentId, runId: currentRunId(), action: action.name, args: input, confirmedNotDone: args.confirm_not_done === true });
const started = beginConnectorExecution(db, { orgId, agentId, runId: currentRunId(), action: action.name, accountLabel: account.label, args: input, confirmedNotDone: args.confirm_not_done === true });
if ("blockedBy" in started) {
const lost = started.blockedBy.detail ? `: ${started.blockedBy.detail}` : "";
return `ERROR: An identical ${action.name} call at ${started.blockedBy.createdAt} may already have run; its reply was lost${lost}. Check in ${toolkit} whether it took effect. If it did not, call again with confirm_not_done: true.`;
@@ -5,27 +5,27 @@ import { createId } from "../../../shared/ids";
/** How an action run ended: unknown means it was sent and its reply was lost, so it may have taken effect. */
export type ConnectorExecutionOutcome = "succeeded" | "failed" | "unknown";
/** Records an action run before it is sent; an identical call whose outcome is pending or unknown blocks it until the agent confirms it did not happen. */
/** Records an action run before it is sent; an identical call on the same account whose outcome is pending or unknown blocks it until the agent confirms it did not happen. */
export function beginConnectorExecution(
db: DatabaseSync,
input: { orgId: string; agentId: string; runId: string | null; action: string; args: Record<string, unknown>; confirmedNotDone: boolean },
input: { orgId: string; agentId: string; runId: string | null; action: string; accountLabel: string; args: Record<string, unknown>; confirmedNotDone: boolean },
): { id: string } | { blockedBy: { createdAt: string; detail: string | null } } {
const hash = createHash("sha256").update(canonicalJson(input.args)).digest("hex");
const open = db.prepare(`
select created_at, detail from connector_executions
where org_id = ? and action = ? and arguments_hash = ? and state in ('pending', 'unknown') order by rowid desc limit 1
`).get(input.orgId, input.action, hash) as { created_at: string; detail: string | null } | undefined;
where org_id = ? and action = ? and account_label = ? and arguments_hash = ? and state in ('pending', 'unknown') order by rowid desc limit 1
`).get(input.orgId, input.action, input.accountLabel, hash) as { created_at: string; detail: string | null } | undefined;
const now = new Date().toISOString();
if (open && !input.confirmedNotDone) return { blockedBy: { createdAt: open.created_at, detail: open.detail } };
if (open) {
db.prepare("update connector_executions set state = 'resolved', updated_at = ? where org_id = ? and action = ? and arguments_hash = ? and state in ('pending', 'unknown')")
.run(now, input.orgId, input.action, hash);
db.prepare("update connector_executions set state = 'resolved', updated_at = ? where org_id = ? and action = ? and account_label = ? and arguments_hash = ? and state in ('pending', 'unknown')")
.run(now, input.orgId, input.action, input.accountLabel, hash);
}
const id = createId("cex");
db.prepare(`
insert into connector_executions(id, org_id, agent_id, run_id, action, arguments_hash, state, created_at, updated_at)
values (?, ?, ?, ?, ?, ?, 'pending', ?, ?)
`).run(id, input.orgId, input.agentId, input.runId, input.action, hash, now, now);
insert into connector_executions(id, org_id, agent_id, run_id, action, account_label, arguments_hash, state, created_at, updated_at)
values (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)
`).run(id, input.orgId, input.agentId, input.runId, input.action, input.accountLabel, hash, now, now);
return { id };
}
+72 -31
View File
@@ -1,11 +1,16 @@
import type { DatabaseSync } from "node:sqlite";
import { DEFAULT_CONNECTOR_ACCOUNT_LABEL } from "../../../shared/commands/composio";
import { createId } from "../../../shared/ids";
export const DEFAULT_ACCOUNT_LABEL = DEFAULT_CONNECTOR_ACCOUNT_LABEL;
const MAX_LABEL_CHARS = 40;
export interface ConnectorAccountRow {
id: string;
orgId: string;
toolkitSlug: string;
toolkitName: string;
label: string;
status: string;
}
@@ -14,31 +19,28 @@ export interface ConnectorGrantRow {
orgId: string;
agentId: string;
toolkitSlug: string;
accountLabel: string;
}
/**
* Which agents may use which connected toolkits. Connections belong to the
* company (one connected account per toolkit per org); the CEO carries every
* active connection by role and hands individual toolkits to individual
* agents. Both the tool loadout and the execution guard read from here.
*/
/** Which agents may use which labelled company accounts: the CEO all active ones by role, others one per toolkit by grant. */
export class ConnectorGrantsService {
constructor(private readonly db: DatabaseSync) {}
grant(input: { orgId: string; agentId: string; toolkitSlug: string; grantedByType: "agent" | "employee"; grantedById: string | null }): void {
grant(input: { orgId: string; agentId: string; toolkitSlug: string; accountLabel: string | null; grantedByType: "agent" | "employee"; grantedById: string | null }): string {
const agent = this.db.prepare("select id from agents where id = ? and org_id = ? and lifecycle = 'active'")
.get(input.agentId, input.orgId);
if (!agent) throw new Error("Grant target must be an active agent in this organization.");
const slug = normalizeSlug(input.toolkitSlug);
const account = this.db.prepare(
"select id from connector_accounts where org_id = ? and toolkit_slug = ? and status = 'ACTIVE'",
).get(input.orgId, slug);
if (!account) throw new Error(`No active connection for toolkit "${slug}". Connect it on the Connectors page first.`);
const label = this.chooseAccount(input.orgId, slug, input.accountLabel).label;
this.db.prepare(`
insert into agent_connector_grants(id, org_id, agent_id, toolkit_slug, granted_by_type, granted_by_id, created_at)
values (?, ?, ?, ?, ?, ?, ?)
on conflict(org_id, agent_id, toolkit_slug) do nothing
`).run(createId("cgr"), input.orgId, input.agentId, slug, input.grantedByType, input.grantedById, new Date().toISOString());
insert into agent_connector_grants(id, org_id, agent_id, toolkit_slug, account_label, granted_by_type, granted_by_id, created_at)
values (?, ?, ?, ?, ?, ?, ?, ?)
on conflict(org_id, agent_id, toolkit_slug) do update set
account_label = excluded.account_label,
granted_by_type = excluded.granted_by_type,
granted_by_id = excluded.granted_by_id
`).run(createId("cgr"), input.orgId, input.agentId, slug, label, input.grantedByType, input.grantedById, new Date().toISOString());
return label;
}
revoke(input: { orgId: string; agentId: string; toolkitSlug: string }): void {
@@ -46,51 +48,82 @@ export class ConnectorGrantsService {
.run(input.orgId, input.agentId, normalizeSlug(input.toolkitSlug));
}
/**
* Toolkits this agent may use. The CEO carries every actively connected
* toolkit by role; every other agent needs a grant AND an active
* connection.
*/
/** Toolkits this agent may use: every active one for the CEO; for others, those whose granted account is active. */
toolkitsForAgent(orgId: string, agentId: string): string[] {
const acting = this.db.prepare("select role_level from agents where id = ? and org_id = ?")
.get(agentId, orgId) as { role_level: string } | undefined;
if (acting?.role_level === "c_suite") {
return this.activeAccounts(orgId).map((account) => account.toolkitSlug);
if (this.isCeo(orgId, agentId)) {
return [...new Set(this.activeAccounts(orgId).map((account) => account.toolkitSlug))];
}
const rows = this.db.prepare(`
select g.toolkit_slug as slug
from agent_connector_grants g
join connector_accounts a on a.org_id = g.org_id and a.toolkit_slug = g.toolkit_slug and a.status = 'ACTIVE'
join connector_accounts a on a.org_id = g.org_id and a.toolkit_slug = g.toolkit_slug and a.label = g.account_label and a.status = 'ACTIVE'
where g.org_id = ? and g.agent_id = ?
order by g.toolkit_slug asc
`).all(orgId, agentId) as Array<{ slug: string }>;
return rows.map((row) => row.slug);
}
/** The active account this agent acts through for a toolkit; the CEO names one when the toolkit has several. */
accountForAgent(orgId: string, agentId: string, toolkitSlug: string, accountLabel: string | null): ConnectorAccountRow {
const slug = normalizeSlug(toolkitSlug);
if (this.isCeo(orgId, agentId)) return this.chooseAccount(orgId, slug, accountLabel);
const grant = this.db.prepare("select account_label from agent_connector_grants where org_id = ? and agent_id = ? and toolkit_slug = ?")
.get(orgId, agentId, slug) as { account_label: string } | undefined;
if (!grant) throw new Error(`You have no grant for the "${slug}" connector. Ask the CEO to grant it to you (grant_connector).`);
if (accountLabel !== null && normalizeAccountLabel(accountLabel) !== grant.account_label) {
throw new Error(`Your grant for "${slug}" is the "${grant.account_label}" account, not "${accountLabel}".`);
}
const account = this.activeAccounts(orgId).find((row) => row.toolkitSlug === slug && row.label === grant.account_label);
if (!account) throw new Error(`The "${slug}" account "${grant.account_label}" is no longer connected. Ask the board to reconnect it on the Connectors page.`);
return account;
}
activeAccounts(orgId: string): ConnectorAccountRow[] {
const rows = this.db.prepare(`
select id, org_id, toolkit_slug, toolkit_name, status
select id, org_id, toolkit_slug, toolkit_name, label, status
from connector_accounts
where org_id = ? and status = 'ACTIVE'
order by toolkit_slug asc
`).all(orgId) as Array<{ id: string; org_id: string; toolkit_slug: string; toolkit_name: string; status: string }>;
order by toolkit_slug asc, label asc
`).all(orgId) as Array<{ id: string; org_id: string; toolkit_slug: string; toolkit_name: string; label: string; status: string }>;
return rows.map((row) => ({
id: row.id,
orgId: row.org_id,
toolkitSlug: row.toolkit_slug,
toolkitName: row.toolkit_name,
label: row.label,
status: row.status,
}));
}
grantsForOrg(orgId: string): ConnectorGrantRow[] {
const rows = this.db.prepare(`
select id, org_id, agent_id, toolkit_slug
select id, org_id, agent_id, toolkit_slug, account_label
from agent_connector_grants
where org_id = ?
order by toolkit_slug asc
`).all(orgId) as Array<{ id: string; org_id: string; agent_id: string; toolkit_slug: string }>;
return rows.map((row) => ({ id: row.id, orgId: row.org_id, agentId: row.agent_id, toolkitSlug: row.toolkit_slug }));
`).all(orgId) as Array<{ id: string; org_id: string; agent_id: string; toolkit_slug: string; account_label: string }>;
return rows.map((row) => ({ id: row.id, orgId: row.org_id, agentId: row.agent_id, toolkitSlug: row.toolkit_slug, accountLabel: row.account_label }));
}
/** The named active account of a toolkit, or its only one; several accounts with no name is an error that lists them. */
private chooseAccount(orgId: string, slug: string, accountLabel: string | null): ConnectorAccountRow {
const accounts = this.activeAccounts(orgId).filter((account) => account.toolkitSlug === slug);
if (accounts.length === 0) throw new Error(`No active connection for toolkit "${slug}". Connect it on the Connectors page first.`);
const labels = accounts.map((account) => account.label).join(", ");
if (accountLabel === null) {
if (accounts.length > 1) throw new Error(`"${slug}" has several accounts (${labels}); name one with account.`);
return accounts[0];
}
const label = normalizeAccountLabel(accountLabel);
const account = accounts.find((row) => row.label === label);
if (!account) throw new Error(`"${slug}" has no active account "${label}"; its accounts: ${labels}.`);
return account;
}
private isCeo(orgId: string, agentId: string): boolean {
const acting = this.db.prepare("select role_level from agents where id = ? and org_id = ?")
.get(agentId, orgId) as { role_level: string } | undefined;
return acting?.role_level === "c_suite";
}
}
@@ -99,3 +132,11 @@ export function normalizeSlug(slug: string): string {
if (!normalized) throw new Error("Toolkit slug is required.");
return normalized;
}
/** Account labels are trimmed and lower-case so "Sales" and "sales " name the same account. */
export function normalizeAccountLabel(label: string): string {
const normalized = label.trim().toLowerCase();
if (!normalized) throw new Error("Account label is required.");
if (normalized.length > MAX_LABEL_CHARS) throw new Error(`Account label must be at most ${MAX_LABEL_CHARS} characters.`);
return normalized;
}