@indemn/cli 1.7.7 → 1.7.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.mcp.json +1 -1
  4. package/README.md +5 -3
  5. package/dist/cli/auth.d.ts.map +1 -1
  6. package/dist/cli/auth.js +95 -62
  7. package/dist/cli/auth.js.map +1 -1
  8. package/dist/mcp/registry.d.ts +12 -0
  9. package/dist/mcp/registry.d.ts.map +1 -1
  10. package/dist/mcp/registry.js +36 -3
  11. package/dist/mcp/registry.js.map +1 -1
  12. package/dist/mcp/server.d.ts.map +1 -1
  13. package/dist/mcp/server.js +15 -1
  14. package/dist/mcp/server.js.map +1 -1
  15. package/dist/mcp/tool-meta.d.ts.map +1 -1
  16. package/dist/mcp/tool-meta.js +47 -2
  17. package/dist/mcp/tool-meta.js.map +1 -1
  18. package/dist/mcp/tools.d.ts.map +1 -1
  19. package/dist/mcp/tools.js +139 -4
  20. package/dist/mcp/tools.js.map +1 -1
  21. package/dist/sdk/access.d.ts +60 -0
  22. package/dist/sdk/access.d.ts.map +1 -0
  23. package/dist/sdk/access.js +115 -0
  24. package/dist/sdk/access.js.map +1 -0
  25. package/dist/sdk/auth.d.ts +7 -0
  26. package/dist/sdk/auth.d.ts.map +1 -1
  27. package/dist/sdk/auth.js +21 -2
  28. package/dist/sdk/auth.js.map +1 -1
  29. package/dist/sdk/browser-login.d.ts +49 -0
  30. package/dist/sdk/browser-login.d.ts.map +1 -0
  31. package/dist/sdk/browser-login.js +159 -0
  32. package/dist/sdk/browser-login.js.map +1 -0
  33. package/dist/sdk/campaigns.d.ts +82 -3
  34. package/dist/sdk/campaigns.d.ts.map +1 -1
  35. package/dist/sdk/campaigns.js +106 -4
  36. package/dist/sdk/campaigns.js.map +1 -1
  37. package/dist/sdk/client.d.ts +2 -0
  38. package/dist/sdk/client.d.ts.map +1 -1
  39. package/dist/sdk/client.js +31 -3
  40. package/dist/sdk/client.js.map +1 -1
  41. package/dist/sdk/credentials.d.ts +2 -0
  42. package/dist/sdk/credentials.d.ts.map +1 -1
  43. package/dist/sdk/credentials.js +4 -0
  44. package/dist/sdk/credentials.js.map +1 -1
  45. package/dist/sdk/eval.d.ts +3 -3
  46. package/dist/sdk/eval.js +3 -3
  47. package/dist/sdk/types.d.ts +3 -0
  48. package/dist/sdk/types.d.ts.map +1 -1
  49. package/dist/sdk/types.js +0 -1
  50. package/dist/sdk/types.js.map +1 -1
  51. package/package.json +1 -1
  52. package/src/cli/auth.ts +93 -63
  53. package/src/mcp/registry.ts +43 -4
  54. package/src/mcp/server.ts +20 -1
  55. package/src/mcp/tool-meta.ts +49 -2
  56. package/src/mcp/tools.ts +177 -5
  57. package/src/sdk/access.ts +143 -0
  58. package/src/sdk/auth.ts +23 -2
  59. package/src/sdk/browser-login.ts +193 -0
  60. package/src/sdk/campaigns.ts +125 -4
  61. package/src/sdk/client.ts +25 -3
  62. package/src/sdk/credentials.ts +5 -0
  63. package/src/sdk/eval.ts +3 -3
  64. package/src/sdk/types.ts +3 -0
  65. package/test/mcp/__snapshots__/tools-snapshot.test.ts.snap +7 -0
  66. package/test/mcp/access.test.ts +158 -0
  67. package/test/mcp/campaigns.test.ts +165 -0
  68. package/test/mcp/tools-snapshot.test.ts +2 -2
  69. package/test/sdk/auth.test.ts +2 -1
  70. package/test/sdk/browser-login.test.ts +147 -0
  71. package/test/sdk/campaigns.test.ts +76 -18
  72. package/test/sdk/client.test.ts +134 -1
  73. package/test/sdk/credentials.test.ts +6 -1
@@ -0,0 +1,143 @@
1
+ import { IndemnClient } from './client.js';
2
+
3
+ /**
4
+ * What the signed-in person may do, as copilot-server reports it
5
+ * (GET /auth/me/access). Display information only: copilot-server and
6
+ * darwin-service still decide every request. The CLI uses it to hide MCP
7
+ * tools the person can't use anywhere and to explain refusals by role.
8
+ * Unknown access (older server, offline) hides nothing.
9
+ */
10
+ export interface AccessProject {
11
+ project_id: string;
12
+ name: string | null;
13
+ role: string;
14
+ capabilities: string[];
15
+ }
16
+
17
+ export interface AccessOrg {
18
+ org_id: string;
19
+ org_name: string;
20
+ role: string | null;
21
+ is_org_admin: boolean;
22
+ capabilities: string[];
23
+ projects: AccessProject[];
24
+ }
25
+
26
+ export interface Access {
27
+ user: { id: string; email: string; name: string | null };
28
+ is_indemn_staff: boolean;
29
+ capabilities: string[];
30
+ orgs: AccessOrg[];
31
+ fetched_at?: string;
32
+ }
33
+
34
+ type Scope = 'user' | 'org' | 'project';
35
+
36
+ /** MCP tools whose use depends on a role, staff email or org setting (copilot config/cliCapabilities.js). */
37
+ export const TOOL_REQUIRES: Readonly<Record<string, { scope: Scope; capability: string }>> = Object.freeze({
38
+ create_sandbox_org: { scope: 'user', capability: 'org:sandbox_create' },
39
+ set_org_admin_settings: { scope: 'user', capability: 'org:admin_settings' },
40
+ get_conversation: { scope: 'project', capability: 'conversations:read' },
41
+ chat_with_agent: { scope: 'project', capability: 'conversations:read' },
42
+ mark_conversation_test: { scope: 'project', capability: 'conversations:mark_test' },
43
+ list_tickets: { scope: 'org', capability: 'tickets' },
44
+ get_ticket: { scope: 'org', capability: 'tickets' },
45
+ create_ticket: { scope: 'org', capability: 'tickets' },
46
+ update_ticket: { scope: 'org', capability: 'tickets' },
47
+ archive_ticket: { scope: 'org', capability: 'tickets' },
48
+ list_ticket_rules: { scope: 'org', capability: 'tickets' },
49
+ save_ticket_rule: { scope: 'org', capability: 'tickets' },
50
+ delete_ticket_rule: { scope: 'org', capability: 'tickets' },
51
+ });
52
+
53
+ let memo: Access | null = null;
54
+
55
+ /** The last known access: this process's, else the one saved with the login (config credential only). */
56
+ export function currentAccess(client?: IndemnClient): Access | null {
57
+ if (memo) return memo;
58
+ // A host session (INDEMN_TOKEN) may be a different identity than the saved login; don't use its access.
59
+ const source = (client ?? new IndemnClient()).getCredential()?.source;
60
+ return source === 'config' ? IndemnClient.loadConfig()?.access ?? null : null;
61
+ }
62
+
63
+ /** Defend against a malformed payload: every array field defaults to empty, bad orgs are dropped. */
64
+ function normalize(raw: Access): Pick<Access, 'capabilities' | 'orgs'> {
65
+ const strings = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []);
66
+ const orgs = (Array.isArray(raw.orgs) ? raw.orgs : [])
67
+ .filter((o): o is AccessOrg => Boolean(o) && typeof o.org_id === 'string')
68
+ .map((o) => ({
69
+ ...o,
70
+ capabilities: strings(o.capabilities),
71
+ projects: (Array.isArray(o.projects) ? o.projects : [])
72
+ .filter((pr): pr is AccessProject => Boolean(pr) && typeof pr.project_id === 'string')
73
+ .map((pr) => ({ ...pr, capabilities: strings(pr.capabilities) })),
74
+ }));
75
+ return { capabilities: strings(raw.capabilities), orgs };
76
+ }
77
+
78
+ /** Forget the in-process copy (tests, logout). */
79
+ export function resetAccess(): void {
80
+ memo = null;
81
+ }
82
+
83
+ /**
84
+ * Ask copilot again. Saved with the login only when the credential is the saved
85
+ * login; a host's session (INDEMN_TOKEN) never writes to this machine's config.
86
+ * Returns null when it can't be read (older server, offline): callers then hide nothing.
87
+ */
88
+ export async function refreshAccess(client: IndemnClient): Promise<Access | null> {
89
+ try {
90
+ const raw = await client.copilotGet<Access>('/auth/me/access');
91
+ if (!raw || !Array.isArray(raw.orgs)) return null;
92
+ const stamped: Access = { ...raw, ...normalize(raw), fetched_at: new Date().toISOString() };
93
+ memo = stamped;
94
+ if (client.getCredential()?.source === 'config') IndemnClient.saveConfig({ access: stamped });
95
+ return stamped;
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+
101
+ function orgsInView(access: Access, onlyOrgId?: string): AccessOrg[] {
102
+ return onlyOrgId ? access.orgs.filter((o) => o.org_id === onlyOrgId) : access.orgs;
103
+ }
104
+
105
+ /** True unless access is known and says the tool can't be used in any org/project in view. */
106
+ export function usableSomewhere(tool: string, access: Access | null, onlyOrgId?: string): boolean {
107
+ const rule = TOOL_REQUIRES[tool];
108
+ if (!rule || !access) return true;
109
+ if (rule.scope === 'user') return access.capabilities.includes(rule.capability);
110
+ const orgs = orgsInView(access, onlyOrgId);
111
+ if (rule.scope === 'org') return orgs.some((o) => o.capabilities.includes(rule.capability));
112
+ return orgs.some((o) => o.projects.some((p) => p.capabilities.includes(rule.capability)));
113
+ }
114
+
115
+ /** The person's role in an org, for messages ("agent in Acme"). */
116
+ export function roleIn(access: Access | null, orgId: string | undefined): string | null {
117
+ if (!access || !orgId) return null;
118
+ const org = access.orgs.find((o) => o.org_id === orgId);
119
+ if (!org) return null;
120
+ return `${org.role ?? 'member'} in ${org.org_name}`;
121
+ }
122
+
123
+ /**
124
+ * For org-scoped requirements: why this org can't be used, or null. Project-scoped
125
+ * requirements are decided by the server (the project comes from the agent).
126
+ */
127
+ export function orgDenial(tool: string, access: Access | null, orgId: string | undefined): string | null {
128
+ const rule = TOOL_REQUIRES[tool];
129
+ if (!rule || !access || rule.scope === 'project') return null;
130
+ if (rule.scope === 'user') {
131
+ if (access.capabilities.includes(rule.capability)) return null;
132
+ return rule.capability === 'org:sandbox_create'
133
+ ? 'Only the Indemn support account can create sandbox organizations.'
134
+ : 'This is for Indemn staff only.';
135
+ }
136
+ if (!orgId) return null;
137
+ const org = access.orgs.find((o) => o.org_id === orgId);
138
+ if (!org) return null; // not in the list: let the server answer
139
+ if (org.capabilities.includes(rule.capability)) return null;
140
+ return rule.capability === 'tickets'
141
+ ? `Tickets are not turned on for ${org.org_name}.`
142
+ : `Your role (${org.role ?? 'member'} in ${org.org_name}) does not allow this.`;
143
+ }
package/src/sdk/auth.ts CHANGED
@@ -3,6 +3,8 @@ import { getAuth, signInWithEmailAndPassword } from '@firebase/auth';
3
3
  import { DEFAULT_HOSTS, IndemnClient } from './client.js';
4
4
  import { exchangeIdToken, toStoredSession } from './session.js';
5
5
  import type { Credential } from './credentials.js';
6
+ import { browserSignIn, type BrowserLoginOptions } from './browser-login.js';
7
+ import { refreshAccess, resetAccess } from './access.js';
6
8
  import type { Environment, EnvironmentHosts, OrgSummary } from './types.js';
7
9
 
8
10
  // Firebase client configs per environment — public configuration (not secrets),
@@ -62,7 +64,21 @@ export async function login(email: string, password: string, env: Environment =
62
64
  idToken,
63
65
  opts?.mfaCodePrompt,
64
66
  );
67
+ return saveLogin(env, { ...session, email: session.email ?? email });
68
+ }
69
+
70
+ /**
71
+ * Log in through the browser: the dashboard's sign-in (with the emailed code when
72
+ * the account uses it) approves this computer, and the CLI gets its own 24-hour
73
+ * session. Same storage and same rules as the password login.
74
+ */
75
+ export async function loginWithBrowser(env: Environment = 'dev', opts?: BrowserLoginOptions): Promise<LoginResult> {
76
+ const session = await browserSignIn(env, opts);
77
+ return saveLogin(env, session);
78
+ }
65
79
 
80
+ /** Keep a fresh copilot session with the person's orgs, replacing any earlier login. */
81
+ async function saveLogin(env: Environment, session: { token: string; userId: string; email?: string }): Promise<LoginResult> {
66
82
  // Which orgs this person belongs to, asked with the new session itself.
67
83
  const authed = new IndemnClient({ environment: env, token: session.token });
68
84
  const res = await authed.copilotGet<{ userOrganizations?: Array<{ _id: string; name: string }> }>('/organization');
@@ -73,13 +89,17 @@ export async function login(email: string, password: string, env: Environment =
73
89
  IndemnClient.saveConfig({
74
90
  environment: env,
75
91
  user_id: session.userId,
76
- email: session.email ?? email,
92
+ email: session.email,
77
93
  session: stored,
78
94
  orgs,
79
95
  hosts: DEFAULT_HOSTS,
80
96
  });
81
97
 
82
- return { userId: session.userId, email: session.email ?? email, environment: env, orgs, expiresAt: stored.expires_at };
98
+ // What the person may do, kept with the login (best effort: an older server has no such endpoint).
99
+ resetAccess();
100
+ await refreshAccess(new IndemnClient({ environment: env }));
101
+
102
+ return { userId: session.userId, email: session.email ?? '', environment: env, orgs, expiresAt: stored.expires_at };
83
103
  }
84
104
 
85
105
  /** Revoke the session on the server, then forget it locally. */
@@ -96,6 +116,7 @@ export async function logout(): Promise<{ revoked: boolean }> {
96
116
  }
97
117
  }
98
118
  IndemnClient.clearConfig();
119
+ resetAccess();
99
120
  return { revoked };
100
121
  }
101
122
 
@@ -0,0 +1,193 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
2
+ import { createServer, type Server } from 'node:http';
3
+ import { spawn } from 'node:child_process';
4
+ import type { AddressInfo } from 'node:net';
5
+ import { IndemnClient } from './client.js';
6
+ import { CliError, indemnHost } from '../core/runtime.js';
7
+ import type { Environment } from './types.js';
8
+
9
+ /**
10
+ * `indemn login` through the browser (OAuth-style loopback + PKCE):
11
+ * 1. listen on 127.0.0.1 (random port) and open the dashboard's /cli-login page
12
+ * with a random `state` and the SHA-256 challenge of a secret verifier;
13
+ * 2. the person signs in there (with the emailed code if their account uses it)
14
+ * and approves; the browser comes back to 127.0.0.1 with a one-time code;
15
+ * 3. the CLI trades code + verifier for its own 24-hour session.
16
+ * No token ever travels in a URL, and the verifier never leaves this process.
17
+ */
18
+
19
+ export const LOGIN_TIMEOUT_MS = 5 * 60_000;
20
+
21
+ export interface Pkce {
22
+ verifier: string;
23
+ challenge: string;
24
+ state: string;
25
+ }
26
+
27
+ export function createPkce(): Pkce {
28
+ const verifier = randomBytes(32).toString('base64url');
29
+ const challenge = createHash('sha256').update(verifier).digest('base64url');
30
+ const state = randomBytes(32).toString('base64url');
31
+ return { verifier, challenge, state };
32
+ }
33
+
34
+ function sameSecret(a: string, b: string): boolean {
35
+ const x = Buffer.from(a);
36
+ const y = Buffer.from(b);
37
+ return x.length === y.length && timingSafeEqual(x, y);
38
+ }
39
+
40
+ /** The dashboard that serves /cli-login: INDEMN_DASHBOARD_URL, else the copilot host without `/api`. */
41
+ export function dashboardUrl(copilotHost: string): string {
42
+ const explicit = process.env.INDEMN_DASHBOARD_URL;
43
+ if (explicit) return indemnHost(explicit, 'INDEMN_DASHBOARD_URL').replace(/\/+$/, '');
44
+ return copilotHost.replace(/\/+$/, '').replace(/\/api$/, '');
45
+ }
46
+
47
+ export function approveUrl(dashboard: string, port: number, pkce: Pkce): string {
48
+ const query = new URLSearchParams({ port: String(port), state: pkce.state, challenge: pkce.challenge });
49
+ return `${dashboard}/#/cli-login?${query.toString()}`;
50
+ }
51
+
52
+ export type CallbackResult = { code: string } | { error: string };
53
+
54
+ export interface Loopback {
55
+ port: number;
56
+ /** First callback whose `state` matches; others are refused and ignored. */
57
+ result: Promise<CallbackResult>;
58
+ close: () => void;
59
+ }
60
+
61
+ const PAGE = (title: string, body: string) =>
62
+ `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title></head>` +
63
+ `<body style="font-family:system-ui,sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem">` +
64
+ `<h1 style="font-size:1.1rem">${title}</h1><p>${body}</p></body></html>`;
65
+
66
+ /** Listen on 127.0.0.1 only, for one `GET /callback` carrying our `state`. */
67
+ export async function startLoopback(state: string, timeoutMs: number = LOGIN_TIMEOUT_MS): Promise<Loopback> {
68
+ let settle!: (r: CallbackResult) => void;
69
+ let fail!: (e: Error) => void;
70
+ const result = new Promise<CallbackResult>((resolve, reject) => {
71
+ settle = resolve;
72
+ fail = reject;
73
+ });
74
+
75
+ let done = false;
76
+ const server: Server = createServer((req, res) => {
77
+ res.setHeader('Cache-Control', 'no-store');
78
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
79
+ // The page has no subresources and echoes nothing; these just harden it.
80
+ res.setHeader('Referrer-Policy', 'no-referrer');
81
+ res.setHeader('X-Content-Type-Options', 'nosniff');
82
+ res.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'");
83
+ // A raw socket can send a target that URL() rejects; never let that crash login.
84
+ let url: URL;
85
+ try {
86
+ url = new URL(req.url ?? '/', 'http://127.0.0.1');
87
+ } catch {
88
+ res.writeHead(400).end(PAGE('Bad request', 'Nothing to see here.'));
89
+ return;
90
+ }
91
+ // DNS-rebinding defence: only our own loopback Host is served.
92
+ const host = (req.headers.host ?? '').toLowerCase();
93
+ if (host && !/^(127\.0\.0\.1|localhost)(:\d+)?$/.test(host)) {
94
+ res.writeHead(400).end(PAGE('Bad request', 'Nothing to see here.'));
95
+ return;
96
+ }
97
+ if (req.method !== 'GET' || url.pathname !== '/callback' || done) {
98
+ res.writeHead(404).end(PAGE('Not found', 'Nothing to see here.'));
99
+ return;
100
+ }
101
+ const got = url.searchParams.get('state') ?? '';
102
+ if (!sameSecret(got, state)) {
103
+ res.writeHead(400).end(PAGE('Sign-in link mismatch', 'This sign-in did not come from your terminal. Run <code>indemn login</code> again.'));
104
+ return;
105
+ }
106
+ const code = url.searchParams.get('code');
107
+ const error = url.searchParams.get('error');
108
+ done = true;
109
+ if (code) {
110
+ res.writeHead(200).end(PAGE('Signed in to the Indemn CLI', 'You can close this tab and return to your terminal.'));
111
+ settle({ code });
112
+ } else {
113
+ res.writeHead(200).end(PAGE('Sign-in cancelled', 'The CLI was not signed in. You can close this tab.'));
114
+ settle({ error: error || 'access_denied' });
115
+ }
116
+ });
117
+
118
+ await new Promise<void>((resolve, reject) => {
119
+ server.once('error', reject);
120
+ server.listen(0, '127.0.0.1', () => resolve());
121
+ });
122
+ const { port } = server.address() as AddressInfo;
123
+
124
+ const timer = setTimeout(() => {
125
+ if (!done) {
126
+ done = true;
127
+ fail(new CliError('Timed out waiting for the browser (5 minutes). Run `indemn login` again.'));
128
+ }
129
+ }, timeoutMs);
130
+ timer.unref();
131
+
132
+ const close = () => {
133
+ clearTimeout(timer);
134
+ server.closeAllConnections?.();
135
+ server.close();
136
+ };
137
+ result.then(close, close);
138
+ return { port, result, close };
139
+ }
140
+
141
+ /** Open a URL in the default browser without a shell. Resolves false if nothing could be started. */
142
+ export function openBrowser(url: string): Promise<boolean> {
143
+ const [cmd, args] =
144
+ process.platform === 'darwin' ? ['open', [url]]
145
+ : process.platform === 'win32' ? ['rundll32', ['url.dll,FileProtocolHandler', url]]
146
+ : ['xdg-open', [url]];
147
+ return new Promise((resolve) => {
148
+ try {
149
+ const child = spawn(cmd, args as string[], { stdio: 'ignore', detached: true });
150
+ child.once('error', () => resolve(false));
151
+ child.once('spawn', () => {
152
+ child.unref();
153
+ resolve(true);
154
+ });
155
+ } catch {
156
+ resolve(false);
157
+ }
158
+ });
159
+ }
160
+
161
+ export interface BrowserLoginOptions {
162
+ /** Opens the approve page; defaults to the system browser. */
163
+ open?: (url: string) => Promise<boolean>;
164
+ /** Told the URL (always), so it can be opened by hand. */
165
+ showUrl?: (url: string, opened: boolean) => void;
166
+ timeoutMs?: number;
167
+ }
168
+
169
+ /** Run the browser flow; returns the copilot session (without the "JWT " prefix). */
170
+ export async function browserSignIn(env: Environment, opts: BrowserLoginOptions = {}): Promise<{ token: string; userId: string; email?: string }> {
171
+ const client = new IndemnClient({ environment: env });
172
+ const pkce = createPkce();
173
+ const loop = await startLoopback(pkce.state, opts.timeoutMs);
174
+ try {
175
+ const url = approveUrl(dashboardUrl(client.getHosts().copilot), loop.port, pkce);
176
+ const opened = await (opts.open ?? openBrowser)(url);
177
+ opts.showUrl?.(url, opened);
178
+
179
+ const outcome = await loop.result;
180
+ if ('error' in outcome) throw new CliError('Sign-in was cancelled in the browser.');
181
+
182
+ let res: { token: string; user: { _id: string; email?: string } };
183
+ try {
184
+ res = await client.copilotPostUnauth('/auth/cli/token', { code: outcome.code, code_verifier: pkce.verifier });
185
+ } catch {
186
+ throw new CliError('The sign-in code was not accepted (it may have expired). Run `indemn login` again.');
187
+ }
188
+ if (!res?.token || !res.user?._id) throw new CliError('Sign-in returned no session. Run `indemn login` again.');
189
+ return { token: res.token.replace(/^JWT\s+/i, ''), userId: String(res.user._id), email: res.user.email };
190
+ } finally {
191
+ loop.close();
192
+ }
193
+ }
@@ -1,7 +1,7 @@
1
1
  import type { IndemnClient } from './client.js';
2
2
  import { CliError, EXIT } from '../core/runtime.js';
3
3
 
4
- /** Paths of the campaigns service the CLI may read. Everything else, and every write, is refused. */
4
+ /** Paths of the campaigns service the CLI may read. Everything else is refused. */
5
5
  const READABLE = [
6
6
  /^\/campaigns$/,
7
7
  /^\/campaigns\/[\w-]+$/,
@@ -11,12 +11,44 @@ const READABLE = [
11
11
  /^\/templates\/[\w-]+\/versions$/,
12
12
  /^\/template-skills$/,
13
13
  /^\/template-skills\/[\w-]+$/,
14
+ /^\/books$/,
15
+ /^\/books\/[\w-]+$/,
14
16
  ];
15
17
 
18
+ type WriteMethod = 'POST' | 'PUT' | 'PATCH';
19
+ /** What the CLI may change: campaigns and their drafts (touchpoints live there), publish and status, templates
20
+ * (each change a new version), template skills and books. Never sending or resolving anything, book runs,
21
+ * uploads, test ticks or enrollments, and no deletes: those act on real customers or cannot be undone. */
22
+ const WRITABLE: Array<[WriteMethod, RegExp]> = [
23
+ ['POST', /^\/campaigns$/],
24
+ ['PUT', /^\/campaigns\/[\w-]+\/draft$/],
25
+ ['POST', /^\/campaigns\/[\w-]+\/(publish|status)$/],
26
+ ['POST', /^\/templates$/],
27
+ ['POST', /^\/templates\/[\w-]+\/versions$/],
28
+ ['POST', /^\/template-skills$/],
29
+ ['PUT', /^\/template-skills\/[\w-]+$/],
30
+ ['POST', /^\/books$/],
31
+ ['PATCH', /^\/books\/[\w-]+\/source$/],
32
+ ['PUT', /^\/books\/[\w-]+\/(field-map|identity-keys|display)$/],
33
+ ];
34
+
35
+ export type CampaignStatus = 'draft' | 'running' | 'paused' | 'archived';
36
+ /** A touchpoint in a campaign's draft, found by its key. */
37
+ export type Touchpoint = { key: string } & Record<string, unknown>;
38
+ type Draft = { audience?: unknown; touchpoints?: Touchpoint[]; escalation_policy?: unknown; channel_policy?: unknown };
39
+ type Envelope = { total?: number; items?: Array<Record<string, unknown>> };
40
+ // A template version is whole: a change starts from the latest version's content.
41
+ const TEMPLATE_CONTENT = ['subject', 'body', 'html', 'uses_variables', 'selection_tags', 'skills', 'batch_row'];
42
+ const given = <T extends object>(o: T) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as Partial<T>;
43
+ type Variable = { key: string; required?: boolean; example?: unknown };
44
+ // Templates declare the variables they read as {key, required, example}; given as keys, a key the template already
45
+ // declares keeps its entry.
46
+ const declare = (keys: string[] | undefined, current: Variable[] = []) => keys?.map((key) => current.find((v) => v.key === key) ?? { key });
47
+
16
48
  /**
17
- * Campaigns, READ-ONLY by design, through copilot's proxy to the campaigns
18
- * service. The CLI never publishes a campaign, sends a communication or
19
- * triggers a book sync; those act on real customers.
49
+ * Campaigns through copilot's proxy to the campaigns service: reads, and
50
+ * writes to configuration only (WRITABLE). The CLI never sends a
51
+ * communication, runs a book sync or deletes; those act on real customers.
20
52
  */
21
53
  export class CampaignsSDK {
22
54
  constructor(private client: IndemnClient) {}
@@ -26,6 +58,11 @@ export class CampaignsSDK {
26
58
  return this.client.copilotGet<T>(`/organization/:org_id/campaigns-service${path}`, query);
27
59
  }
28
60
 
61
+ private async write<T>(method: WriteMethod, path: string, body: unknown): Promise<T> {
62
+ if (!WRITABLE.some(([m, r]) => m === method && r.test(path))) throw new CliError(`Campaigns ${method} ${path} is not allowed from the CLI.`, EXIT.NEEDS_INPUT);
63
+ return this.client.copilotRequestRaw<T>(method, `/organization/:org_id/campaigns-service${path}`, body);
64
+ }
65
+
29
66
  list(filter: { status?: string; solutionId?: string; limit?: number; skip?: number } = {}): Promise<{ total?: number; items?: unknown[] }> {
30
67
  const q: Record<string, string> = { limit: String(Math.min(filter.limit ?? 50, 500)), skip: String(filter.skip ?? 0) };
31
68
  if (filter.status) q.status = filter.status;
@@ -57,4 +94,88 @@ export class CampaignsSDK {
57
94
  templateSkills(solutionId?: string): Promise<unknown> {
58
95
  return this.read('/template-skills', solutionId ? { solution_id: solutionId } : undefined);
59
96
  }
97
+
98
+ templateSkill(id: string): Promise<{ name?: string; instructions?: string }> {
99
+ return this.read(`/template-skills/${id}`);
100
+ }
101
+
102
+ books(filter: { solutionId?: string; limit?: number } = {}): Promise<Envelope> {
103
+ const q: Record<string, string> = { limit: String(Math.min(filter.limit ?? 50, 200)) }; // the service allows 200
104
+ if (filter.solutionId) q.solution_id = filter.solutionId;
105
+ return this.read('/books', q);
106
+ }
107
+
108
+ book(id: string): Promise<{ name?: string }> {
109
+ return this.read(`/books/${id}`);
110
+ }
111
+
112
+ createCampaign(input: { solution_id: string; book_id: string; name: string; campaign_type: string; entry?: string; closes?: string }): Promise<{ id: string }> {
113
+ return this.write('POST', '/campaigns', given(input));
114
+ }
115
+
116
+ /** Change parts of the draft; the rest of it (its touchpoints among it) stays as it is. */
117
+ async updateDraft(id: string, parts: Omit<Draft, 'touchpoints'>): Promise<Draft> {
118
+ const { draft } = await this.read<{ draft?: Draft }>(`/campaigns/${id}`);
119
+ return this.write('PUT', `/campaigns/${id}/draft`, { ...draft, ...given(parts) });
120
+ }
121
+
122
+ /** Add a touchpoint, or change the one with its key (only the fields given). A new one drafts unless told to
123
+ * send; a template given without a version is pinned to its latest. */
124
+ async setTouchpoint(id: string, touchpoint: Touchpoint): Promise<Draft> {
125
+ const { draft } = await this.read<{ draft?: Draft }>(`/campaigns/${id}`);
126
+ const current = draft?.touchpoints ?? [];
127
+ const existing = current.find((t) => t.key === touchpoint.key);
128
+ const tp: Touchpoint = { ...(existing ?? { terminal_action: 'draft' }), ...given(touchpoint), key: touchpoint.key };
129
+ if (typeof touchpoint.template_id === 'string' && touchpoint.template_version_n === undefined) {
130
+ tp.template_version_n = Number((await this.latestTemplateVersion(touchpoint.template_id))?.version_n ?? 1);
131
+ }
132
+ const touchpoints = existing ? current.map((t) => (t.key === tp.key ? tp : t)) : [...current, tp];
133
+ return this.write('PUT', `/campaigns/${id}/draft`, { ...draft, touchpoints });
134
+ }
135
+
136
+ publish(id: string, changeSummary?: string): Promise<unknown> {
137
+ return this.write('POST', `/campaigns/${id}/publish`, { change_summary: changeSummary ?? '' });
138
+ }
139
+
140
+ setStatus(id: string, status: CampaignStatus): Promise<unknown> {
141
+ return this.write('POST', `/campaigns/${id}/status`, { status });
142
+ }
143
+
144
+ createTemplate(input: Record<string, unknown> & { uses_variables?: string[] }): Promise<unknown> {
145
+ return this.write('POST', '/templates', given({ ...input, uses_variables: declare(input.uses_variables) }));
146
+ }
147
+
148
+ private async latestTemplateVersion(id: string): Promise<Record<string, unknown> | undefined> {
149
+ return (await this.read<Envelope>(`/templates/${id}/versions`)).items?.[0]; // newest first
150
+ }
151
+
152
+ /** A new version of the template: its latest one with the changes given. */
153
+ async updateTemplate(id: string, changes: Record<string, unknown> & { uses_variables?: string[] }): Promise<unknown> {
154
+ const latest = (await this.latestTemplateVersion(id)) ?? {};
155
+ const kept = Object.fromEntries(TEMPLATE_CONTENT.filter((k) => k in latest).map((k) => [k, latest[k]]));
156
+ return this.write('POST', `/templates/${id}/versions`, { ...kept, ...given({ ...changes, uses_variables: declare(changes.uses_variables, latest.uses_variables as Variable[]) }) });
157
+ }
158
+
159
+ createTemplateSkill(input: { solution_id?: string; key: string; name: string; instructions: string }): Promise<unknown> {
160
+ return this.write('POST', '/template-skills', input);
161
+ }
162
+
163
+ async updateTemplateSkill(id: string, changes: { name?: string; instructions?: string }): Promise<unknown> {
164
+ const skill = await this.templateSkill(id);
165
+ return this.write('PUT', `/template-skills/${id}`, { name: skill.name, instructions: skill.instructions, ...given(changes) });
166
+ }
167
+
168
+ createBook(input: { solution_id: string; name: string; schema_id?: string; source?: unknown }): Promise<{ id: string }> {
169
+ return this.write('POST', '/books', given(input));
170
+ }
171
+
172
+ /** Change the parts of a book that are given, each its own call in the service. Keys are the book's schema
173
+ * keys; identity keys change only while the book has no records. */
174
+ async updateBook(id: string, changes: { source?: unknown; field_map?: unknown[]; identity_keys?: string[]; display?: unknown }): Promise<unknown> {
175
+ if (changes.source !== undefined) await this.write('PATCH', `/books/${id}/source`, { source: changes.source });
176
+ if (changes.field_map !== undefined) await this.write('PUT', `/books/${id}/field-map`, { field_map: changes.field_map });
177
+ if (changes.identity_keys !== undefined) await this.write('PUT', `/books/${id}/identity-keys`, { identity_keys: changes.identity_keys });
178
+ if (changes.display !== undefined) await this.write('PUT', `/books/${id}/display`, { display: changes.display });
179
+ return this.book(id);
180
+ }
60
181
  }
package/src/sdk/client.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import Conf from 'conf';
2
2
  import { chmodSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
3
4
  import type { ApiResponse, Environment, EnvironmentHosts, IndemnConfig, OrgSummary } from './types.js';
4
5
  import { CliError, cliVersion, indemnHost, isDebug, SessionExpiredError } from '../core/runtime.js';
5
- import { copilotAuthHeader, darwinAuthHeader, resolveCredential, type Credential } from './credentials.js';
6
+ import { copilotAuthHeader, darwinAuthHeader, evaluationsAuthHeader, resolveCredential, type Credential } from './credentials.js';
6
7
  import { tokenExpiry } from './session.js';
7
8
  import { findLinkFile, resolveOrg, type LinkFile, type ResolvedOrg } from '../core/org-resolver.js';
8
9
  import { emitOrgResolved, getAskers, getOrgFlag, scopedOrg } from '../core/scope.js';
@@ -71,6 +72,8 @@ export class IndemnClient {
71
72
  private environment: Environment;
72
73
  private hosts: EnvironmentHosts;
73
74
  private resolvedOrg?: Promise<OrgContext>;
75
+ /** The org `resolvedOrg` settled on, readable without waiting or asking. */
76
+ private settledOrgId?: string;
74
77
  private linkCache?: LinkFile | null;
75
78
  private membershipCache?: OrgSummary[];
76
79
 
@@ -126,6 +129,7 @@ export class IndemnClient {
126
129
  orgs: store.get('orgs'),
127
130
  api_key: apiKey,
128
131
  hosts: store.get('hosts', DEFAULT_HOSTS),
132
+ access: store.get('access'),
129
133
  };
130
134
  } catch {
131
135
  return null;
@@ -138,9 +142,10 @@ export class IndemnClient {
138
142
  if (value === undefined) store.delete(key as keyof IndemnConfig);
139
143
  else store.set(key as keyof IndemnConfig, value);
140
144
  }
141
- // Restrict config file to owner-only (contains credentials)
145
+ // Restrict the config file and its folder to owner-only (they hold the session)
142
146
  try {
143
147
  chmodSync(store.path, 0o600);
148
+ chmodSync(dirname(store.path), 0o700);
144
149
  } catch {
145
150
  // Non-fatal — Windows doesn't support Unix permissions
146
151
  }
@@ -246,6 +251,7 @@ export class IndemnClient {
246
251
  if (explicit || opts.cache === false) return run();
247
252
  if (!this.resolvedOrg) {
248
253
  this.resolvedOrg = run().then((org) => {
254
+ this.settledOrgId = org.id;
249
255
  emitOrgResolved({ id: org.id, name: org.name, source: org.source, environment: this.environment });
250
256
  return org;
251
257
  });
@@ -322,6 +328,12 @@ export class IndemnClient {
322
328
  }
323
329
  if (backend === 'copilot' && opts.auth !== false) {
324
330
  headers['Authorization'] = await this.authHeader();
331
+ } else if (backend === 'evaluations' && opts.auth !== false && this.getCredential()) {
332
+ // Without any credential the request goes out bare, as before; the service then answers 401.
333
+ headers['Authorization'] = evaluationsAuthHeader(await this.freshCredential());
334
+ // Only an org already in effect: the service resolves the user's orgs itself, so never ask or look one up here.
335
+ const org = scopedOrg() ?? this.settledOrgId;
336
+ if (org) headers['X-Organization-Id'] = org;
325
337
  }
326
338
 
327
339
  const attempts = method === 'GET' ? RETRY_DELAYS_MS.length + 1 : 1;
@@ -429,7 +441,7 @@ export class IndemnClient {
429
441
  return res.json() as Promise<T>;
430
442
  }
431
443
 
432
- // Evaluations Service requests (no /api/ prefix, no org_id, no auth)
444
+ // Evaluations Service requests (no /api/ prefix, no org_id in the path; the credential goes as Bearer, see send())
433
445
  async evalsGet<T = unknown>(path: string, query?: Record<string, string>): Promise<T> {
434
446
  const res = await this.send('evaluations', 'GET', this.withQuery(this.buildEvalsUrl(path), query));
435
447
  return this.handleEvalsResponse<T>(res);
@@ -490,6 +502,16 @@ export class IndemnClient {
490
502
  }
491
503
 
492
504
  private async handleEvalsResponse<T>(res: Response): Promise<T> {
505
+ if (res.status === 401 || res.status === 403) {
506
+ // Keep the service's reason. 401: no valid credential (log in again). 403: signed in but not allowed.
507
+ const text = await res.text().catch(() => '');
508
+ let detail: unknown = text;
509
+ try { detail = (JSON.parse(text) as { detail?: unknown }).detail ?? text; } catch { /* plain text */ }
510
+ const reason = (typeof detail === 'string' ? detail : JSON.stringify(detail)).trim();
511
+ const shown = reason && !reason.startsWith('<') ? ` (${reason.slice(0, 200)})` : '';
512
+ if (res.status === 401) throw new SessionExpiredError(this.loginAgain(`The evaluations service refused the request${shown}.`));
513
+ throw new CliError(`The evaluations service denied access${shown}.`);
514
+ }
493
515
  if (!res.ok) {
494
516
  const text = await res.text();
495
517
  throw new Error(`Evaluations API error (${res.status}): ${text}`);
@@ -51,3 +51,8 @@ export function darwinAuthHeader(c: Credential): string {
51
51
  }
52
52
  return `Bearer ${c.token}`;
53
53
  }
54
+
55
+ /** The evaluations service takes the session or an API key, both as Bearer; it checks a key and pins it to the key's org. */
56
+ export function evaluationsAuthHeader(c: Credential): string {
57
+ return `Bearer ${c.kind === 'token' ? c.token : c.key}`;
58
+ }
package/src/sdk/eval.ts CHANGED
@@ -33,9 +33,9 @@ export class EvalSDK {
33
33
  }
34
34
 
35
35
  /**
36
- * The evaluations service checks nothing about orgs. Before acting on an
37
- * agent, confirm through copilot (which refuses other orgs' agents) that it
38
- * belongs to the org this command acts on.
36
+ * The evaluations service now enforces org scope itself; this check is defense
37
+ * in depth. Before acting on an agent, confirm through copilot (which refuses
38
+ * other orgs' agents) that it belongs to the org this command acts on.
39
39
  */
40
40
  async assertAgentInOrg(agentId: string): Promise<void> {
41
41
  const { AgentsSDK } = await import('./agents.js');