@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
package/src/sdk/types.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { Access } from './access.js';
1
2
  // Environment and configuration types
2
3
 
3
4
  export type Environment = 'dev' | 'production';
@@ -53,6 +54,8 @@ export interface IndemnConfig {
53
54
  org_id?: string;
54
55
  org_name?: string;
55
56
  hosts: Record<Environment, EnvironmentHosts>;
57
+ /** What the person may do (GET /auth/me/access), saved with the login; display only. */
58
+ access?: Access;
56
59
  }
57
60
 
58
61
  // API response types
@@ -111,10 +111,15 @@ exports[`MCP tool list > gives every tool a title and annotations 1`] = `
111
111
  "platform_list",
112
112
  "platform_save",
113
113
  "promote_prod_call",
114
+ "publish_campaign",
114
115
  "reimport_kb_source",
115
116
  "remove_holiday",
116
117
  "run_evaluation",
117
118
  "run_simulation",
119
+ "save_book",
120
+ "save_campaign",
121
+ "save_campaign_template",
122
+ "save_template_skill",
118
123
  "save_ticket_rule",
119
124
  "scrape_url",
120
125
  "search_phone_numbers",
@@ -122,10 +127,12 @@ exports[`MCP tool list > gives every tool a title and annotations 1`] = `
122
127
  "set_agent_transcriber",
123
128
  "set_agent_voice",
124
129
  "set_business_hours",
130
+ "set_campaign_status",
125
131
  "set_config",
126
132
  "set_org_admin_settings",
127
133
  "set_org_timezone",
128
134
  "set_prompt",
135
+ "set_touchpoint",
129
136
  "simulation_status",
130
137
  "test_function",
131
138
  "transfer_agent_owner",
@@ -0,0 +1,158 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js';
3
+ import { on, recordFetch } from '../helpers/fetch-recorder.js';
4
+ import { cleanEnv, useTempHome } from '../helpers/config-home.js';
5
+ import { jwtExpiringIn } from '../helpers/jwt.js';
6
+ import { connectPair } from '../helpers/mcp-pair.js';
7
+ import { IndemnClient } from '../../src/sdk/client.js';
8
+ import { registerTools } from '../../src/mcp/tools.js';
9
+ import { refreshAccess, resetAccess, usableSomewhere, type Access } from '../../src/sdk/access.js';
10
+ import type { Registry } from '../../src/mcp/registry.js';
11
+
12
+ const A = 'aaaaaaaaaaaaaaaaaaaaaaaa';
13
+ const B = 'bbbbbbbbbbbbbbbbbbbbbbbb';
14
+
15
+ function accessWith(bTickets = false): Access {
16
+ return {
17
+ user: { id: 'u1', email: 'pat@example.test', name: 'Pat' },
18
+ is_indemn_staff: false,
19
+ capabilities: [],
20
+ orgs: [
21
+ { org_id: A, org_name: 'QA A', role: 'admin', is_org_admin: true, capabilities: ['member', 'tickets'],
22
+ projects: [{ project_id: 'p1', name: 'A main', role: 'admin', capabilities: ['conversations:messages', 'conversations:read'] }] },
23
+ { org_id: B, org_name: 'QA B', role: 'guest', is_org_admin: false, capabilities: bTickets ? ['member', 'tickets'] : ['member'],
24
+ projects: [{ project_id: 'p2', name: 'B main', role: 'guest', capabilities: ['conversations:messages'] }] },
25
+ ],
26
+ };
27
+ }
28
+
29
+ describe('role-aware MCP tools', () => {
30
+ let home: ReturnType<typeof useTempHome>;
31
+ let registry: Registry;
32
+
33
+ beforeEach(() => {
34
+ cleanEnv();
35
+ home = useTempHome();
36
+ resetAccess();
37
+ vi.spyOn(process, 'cwd').mockReturnValue(home.dir);
38
+ IndemnClient.saveConfig({
39
+ environment: 'dev', user_id: 'u1', session: { token: jwtExpiringIn(5 * 3_600_000) },
40
+ orgs: [{ id: A, name: 'QA A' }, { id: B, name: 'QA B' }],
41
+ access: accessWith(),
42
+ });
43
+ });
44
+ afterEach(() => { resetAccess(); home.cleanup(); });
45
+
46
+ function setup(server: Parameters<typeof registerTools>[0]) {
47
+ registry = registerTools(server, new IndemnClient());
48
+ registry.applyVisibility();
49
+ }
50
+
51
+ async function toolNames(pair: Awaited<ReturnType<typeof connectPair>>) {
52
+ return (await pair.client.listTools()).tools.map((t) => t.name);
53
+ }
54
+
55
+ it('hides tools the person can use nowhere, keeps the rest', async () => {
56
+ recordFetch();
57
+ const pair = await connectPair(setup);
58
+ const names = await toolNames(pair);
59
+ expect(names).toContain('list_tickets'); // tickets on in QA A
60
+ expect(names).toContain('get_conversation'); // admin in A's project
61
+ expect(names).toContain('list_agents'); // membership only
62
+ expect(names).not.toContain('set_org_admin_settings'); // staff only
63
+ expect(names).not.toContain('create_sandbox_org'); // support account only
64
+ expect(names).not.toContain('mark_conversation_test'); // staff + agent
65
+ await pair.close();
66
+ });
67
+
68
+ it('a folder linked to one org counts only that org', async () => {
69
+ vi.stubEnv('INDEMN_ORG_ID', B);
70
+ recordFetch();
71
+ const pair = await connectPair(setup);
72
+ const names = await toolNames(pair);
73
+ expect(names).not.toContain('list_tickets');
74
+ expect(names).not.toContain('get_conversation');
75
+ expect(names).toContain('list_agents');
76
+ await pair.close();
77
+ });
78
+
79
+ it('refuses early, with the reason, where the org has no access (after asking again)', async () => {
80
+ const { calls } = recordFetch([on('GET', '/auth/me/access', { json: accessWith() })]);
81
+ const pair = await connectPair(setup);
82
+ const res = await pair.call('list_tickets', { org_id: B });
83
+ expect(res.isError).toBe(true);
84
+ expect(res.body).toContain('Tickets are not turned on for QA B');
85
+ expect(calls.some((c) => c.url.includes('/auth/me/access'))).toBe(true); // stale access is re-checked first
86
+ expect(calls.some((c) => c.url.includes('/tickets'))).toBe(false);
87
+ await pair.close();
88
+ });
89
+
90
+ it('a refresh that grants access lets the call through', async () => {
91
+ const { calls } = recordFetch([on('GET', '/auth/me/access', { json: accessWith(true) })]);
92
+ const pair = await connectPair(setup);
93
+ const res = await pair.call('list_tickets', { org_id: B });
94
+ expect(res.isError).toBe(false);
95
+ expect(calls.some((c) => c.url.includes(`/organization/${B}/tickets`))).toBe(true);
96
+ await pair.close();
97
+ });
98
+
99
+ it('concurrent calls for different orgs each get their own answer', async () => {
100
+ const { calls } = recordFetch([on('GET', '/auth/me/access', { json: accessWith() })]);
101
+ const pair = await connectPair(setup);
102
+ const [inA, inB, inA2] = await Promise.all([
103
+ pair.call('list_tickets', { org_id: A }),
104
+ pair.call('list_tickets', { org_id: B }),
105
+ pair.call('list_tickets', { org_id: A }),
106
+ ]);
107
+ expect(inA.isError).toBe(false);
108
+ expect(inA2.isError).toBe(false);
109
+ expect(inB.isError).toBe(true);
110
+ expect(inA.body.context.org_id).toBe(A);
111
+ const ticketCalls = calls.filter((c) => c.url.includes('/tickets'));
112
+ expect(ticketCalls).toHaveLength(2);
113
+ expect(ticketCalls.every((c) => c.url.includes(`/organization/${A}/tickets`))).toBe(true);
114
+ await pair.close();
115
+ });
116
+
117
+ it('a server refusal names the person\'s role', async () => {
118
+ recordFetch([
119
+ on('GET', '/auth/me/access', { json: accessWith() }),
120
+ on('GET', '/ai-studio/v2/bots', { status: 403, json: { success: false, msg: 'Forbidden' } }),
121
+ ]);
122
+ const pair = await connectPair(setup);
123
+ const res = await pair.call('list_agents', { org_id: B });
124
+ expect(res.isError).toBe(true);
125
+ expect(res.body).toContain('Not allowed');
126
+ expect(res.body).toContain('Your role: guest in QA B.');
127
+ await pair.close();
128
+ });
129
+
130
+ it('tells the host when the visible tools change', async () => {
131
+ vi.stubEnv('INDEMN_ORG_ID', B);
132
+ recordFetch([on('GET', '/auth/me/access', { json: accessWith(true) })]);
133
+ const pair = await connectPair(setup);
134
+ let changed = 0;
135
+ pair.client.setNotificationHandler(ToolListChangedNotificationSchema, async () => { changed += 1; });
136
+ expect(await toolNames(pair)).not.toContain('list_tickets');
137
+ await refreshAccess(new IndemnClient());
138
+ registry.applyVisibility();
139
+ await vi.waitFor(() => expect(changed).toBeGreaterThan(0));
140
+ expect(await toolNames(pair)).toContain('list_tickets');
141
+ await pair.close();
142
+ });
143
+
144
+ it('unknown access hides nothing', () => {
145
+ expect(usableSomewhere('set_org_admin_settings', null)).toBe(true);
146
+ expect(usableSomewhere('list_tickets', null)).toBe(true);
147
+ });
148
+
149
+ it('a host session (INDEMN_TOKEN) never writes access to this machine', async () => {
150
+ IndemnClient.clearConfig();
151
+ vi.stubEnv('INDEMN_TOKEN', jwtExpiringIn(3_600_000));
152
+ vi.stubEnv('INDEMN_ENV', 'dev');
153
+ recordFetch([on('GET', '/auth/me/access', { json: accessWith() })]);
154
+ const got = await refreshAccess(new IndemnClient());
155
+ expect(got?.orgs).toHaveLength(2);
156
+ expect(IndemnClient.loadConfig()).toBeNull();
157
+ });
158
+ });
@@ -0,0 +1,165 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import { on, recordFetch, type RecordedCall, type Responder } from '../helpers/fetch-recorder.js';
3
+ import { cleanEnv } from '../helpers/config-home.js';
4
+ import { connectPair } from '../helpers/mcp-pair.js';
5
+ import { IndemnClient } from '../../src/sdk/client.js';
6
+ import { registerTools } from '../../src/mcp/tools.js';
7
+ import { TOOL_META } from '../../src/mcp/tool-meta.js';
8
+
9
+ const ORG = 'aaaaaaaaaaaaaaaaaaaaaaaa';
10
+ const solutionsOn = on('GET', `/organization/${ORG}/configurations`, { json: { enableSolutions: true } });
11
+ const approve = () => ({ action: 'accept' as const, content: { confirm: true } });
12
+ async function pair(elicit: () => { action: 'accept' | 'decline'; content?: Record<string, unknown> } = approve) {
13
+ cleanEnv();
14
+ const client = new IndemnClient({ apiKey: `ind_dev_${'a'.repeat(64)}`, orgId: ORG, environment: 'dev' });
15
+ return connectPair((server) => { registerTools(server, client); }, { elicit });
16
+ }
17
+ const fetches = (responders: Responder[]) => recordFetch([solutionsOn, ...responders]);
18
+ const writes = (calls: RecordedCall[]) => calls.filter((c) => c.method !== 'GET').map((c) => `${c.method} ${c.url.split('campaigns-service')[1]}`);
19
+ const draft = { audience: null, touchpoints: [], escalation_policy: {}, channel_policy: {} };
20
+ const campaign = { id: 'c1', name: 'GL renewals', status: 'draft', current_version_n: null, draft };
21
+
22
+ describe('campaign tools', () => {
23
+ let p: Awaited<ReturnType<typeof pair>> | undefined;
24
+ afterEach(async () => { await p?.close(); p = undefined; });
25
+
26
+ it('with Solutions off, reads and changes are refused before any card or call, saying how to turn it on', async () => {
27
+ const { calls } = recordFetch([on('GET', '/configurations', { json: { enableSolutions: false } })]);
28
+ p = await pair();
29
+ for (const [name, args] of [['get_campaigns', { what: 'list' }], ['save_campaign', { solution_id: 's1', book_id: 'b1', name: 'X', campaign_type: 'renewal' }]] as const) {
30
+ const res = await p.call(name, args);
31
+ expect(res.isError).toBe(true);
32
+ expect(String(res.body)).toMatch(/Solutions is not turned on.*set_org_admin_settings.*Nothing was changed/);
33
+ }
34
+ expect(p.questions).toHaveLength(0);
35
+ expect(calls.filter((c) => c.url.includes('campaigns-service'))).toHaveLength(0);
36
+ });
37
+
38
+ it('save_campaign without an id creates, asking first by name; confirm and empty fields never reach the service', async () => {
39
+ const { calls } = fetches([on('GET', '/campaigns-service/books/b1', { json: { id: 'b1', name: 'GL book' } }), on('POST', '/campaigns-service/campaigns', { json: campaign })]);
40
+ p = await pair();
41
+ const res = await p.call('save_campaign', { solution_id: 's1', book_id: 'b1', name: 'GL renewals', campaign_type: 'renewal', closes: '', confirm: true });
42
+ expect(res.isError).toBe(false);
43
+ expect(p.questions[0].message).toContain('create campaign "GL renewals" as a draft; it sends nothing until it is published and started');
44
+ expect(writes(calls)).toEqual(['POST /campaigns']);
45
+ expect(calls.find((c) => c.method === 'POST')!.body).toEqual({ solution_id: 's1', book_id: 'b1', name: 'GL renewals', campaign_type: 'renewal' });
46
+ });
47
+
48
+ it('a create missing what it needs is answered at once: no card, no call', async () => {
49
+ const { calls } = fetches([]);
50
+ p = await pair();
51
+ const res = await p.call('save_campaign', { name: 'GL renewals' });
52
+ expect(res.isError).toBe(true);
53
+ expect(String(res.body)).toContain('To create a campaign also give solution_id, book_id, campaign_type');
54
+ expect(p.questions).toHaveLength(0);
55
+ expect(writes(calls)).toEqual([]);
56
+ });
57
+
58
+ it('save_campaign with an id changes only the draft parts given, by name on the card', async () => {
59
+ const touchpoints = [{ key: 't1', channel: 'email' }];
60
+ const { calls } = fetches([
61
+ on('GET', '/campaigns-service/campaigns/c1', { json: { ...campaign, draft: { ...draft, touchpoints } } }),
62
+ on('PUT', '/campaigns-service/campaigns/c1/draft', (c) => ({ json: c.body })),
63
+ ]);
64
+ p = await pair();
65
+ const res = await p.call('save_campaign', { campaign_id: 'c1', audience: '{"field":"status","op":"eq","value":"active"}' });
66
+ expect(res.isError).toBe(false);
67
+ expect(p.questions[0].message).toContain('change the audience in the draft of campaign "GL renewals"');
68
+ expect(calls.find((c) => c.method === 'PUT')!.body).toEqual({ ...draft, touchpoints, audience: { field: 'status', op: 'eq', value: 'active' } });
69
+ });
70
+
71
+ it('set_touchpoint takes flat fields and rules as JSON text, and drafts unless told to send', async () => {
72
+ const { calls } = fetches([
73
+ on('GET', '/campaigns-service/campaigns/c1', { json: campaign }),
74
+ on('PUT', '/campaigns-service/campaigns/c1/draft', (c) => ({ json: c.body })),
75
+ ]);
76
+ p = await pair();
77
+ const res = await p.call('set_touchpoint', { campaign_id: 'c1', key: 't1_60', channel: 'email', when: '{"field":"derived.days_out","op":"lte","value":60}' });
78
+ expect(res.isError).toBe(false);
79
+ expect((calls.find((c) => c.method === 'PUT')!.body as typeof draft).touchpoints).toEqual([{ key: 't1_60', channel: 'email', terminal_action: 'draft', when: { field: 'derived.days_out', op: 'lte', value: 60 } }]);
80
+ expect(p.questions[0].message).toContain('touchpoint "t1_60" in the draft of campaign "GL renewals" (email)');
81
+ });
82
+
83
+ it('starting a campaign is its own approval and says it reaches customers; a decline changes nothing', async () => {
84
+ const { calls } = fetches([on('GET', '/campaigns-service/campaigns/c1', { json: campaign }), on('POST', '/status', { json: { ...campaign, status: 'running' } })]);
85
+ p = await pair(() => ({ action: 'decline' }));
86
+ await p.call('set_campaign_status', { campaign_id: 'c1', status: 'running' });
87
+ expect(p.questions[0].message).toMatch(/START campaign "GL renewals".*reach real customers/);
88
+ expect(writes(calls)).toEqual([]);
89
+ });
90
+
91
+ it("the service's refusal reaches the person as it is", async () => {
92
+ fetches([
93
+ on('GET', '/campaigns-service/campaigns/c1', { json: campaign }),
94
+ on('POST', '/status', { status: 409, json: { detail: 'cannot start a campaign that has never published a version' } }),
95
+ ]);
96
+ p = await pair();
97
+ const res = await p.call('set_campaign_status', { campaign_id: 'c1', status: 'running' });
98
+ expect(res.isError).toBe(true);
99
+ expect(String(res.body)).toContain('never published a version');
100
+ });
101
+
102
+ it('an id that names nothing gets no card', async () => {
103
+ const { calls } = fetches([on('GET', '/campaigns-service/campaigns/nope', { status: 404, json: { detail: 'not found' } })]);
104
+ p = await pair();
105
+ const res = await p.call('publish_campaign', { campaign_id: 'nope' });
106
+ expect(String(res.body)).toContain('Not found: campaign nope');
107
+ expect(p.questions).toHaveLength(0);
108
+ expect(writes(calls)).toEqual([]);
109
+ });
110
+
111
+ it('save_campaign_template with an id saves a new version from the latest one, named on the card', async () => {
112
+ const { calls } = fetches([
113
+ on('GET', '/campaigns-service/templates/tpl1/versions', { json: { items: [{ version_n: 1, subject: 'Renewal', body: 'Old', uses_variables: [] }] } }),
114
+ on('GET', '/campaigns-service/templates/tpl1', { json: { id: 'tpl1', name: 'GL reminder' } }),
115
+ on('POST', '/campaigns-service/templates/tpl1/versions', (c) => ({ json: { version_n: 2, ...(c.body as object) } })),
116
+ ]);
117
+ p = await pair();
118
+ const res = await p.call('save_campaign_template', { template_id: 'tpl1', body: 'New', subject: '' });
119
+ expect(res.isError).toBe(false);
120
+ expect(calls.find((c) => c.method === 'POST')!.body).toEqual({ subject: 'Renewal', body: 'New', uses_variables: [] }); // "" kept the subject
121
+ expect(p.questions[0].message).toContain('new version of template "GL reminder" (body)');
122
+ });
123
+
124
+ it('save_template_skill creates and changes, by name on the card', async () => {
125
+ const { calls } = fetches([
126
+ on('GET', '/campaigns-service/template-skills/sk1', { json: { id: 'sk1', name: 'Tone', instructions: 'Brief.' } }),
127
+ (c) => (c.method === 'GET' ? undefined : { json: { id: 'sk1' } }),
128
+ ]);
129
+ p = await pair();
130
+ await p.call('save_template_skill', { solution_id: 's1', key: 'tone', name: 'Tone', instructions: 'Brief.' });
131
+ await p.call('save_template_skill', { template_skill_id: 'sk1', instructions: 'Warm.' });
132
+ expect(writes(calls)).toEqual(['POST /template-skills', 'PUT /template-skills/sk1']);
133
+ expect(calls.filter((c) => c.method !== 'GET').map((c) => c.body)).toEqual([{ solution_id: 's1', key: 'tone', name: 'Tone', instructions: 'Brief.' }, { name: 'Tone', instructions: 'Warm.' }]);
134
+ expect(p.questions[1].message).toContain('change template skill "Tone" (instructions)');
135
+ });
136
+
137
+ it('save_book changes only the parts given; a create whose change is refused says the book exists', async () => {
138
+ const { calls } = fetches([
139
+ on('GET', '/campaigns-service/books/b1', { json: { id: 'b1', name: 'GL book' } }),
140
+ on('POST', '/campaigns-service/books', { json: { id: 'b2', name: 'New book' } }),
141
+ on('PUT', '/books/b2/display', { status: 400, json: { detail: 'display names keys absent from the schema: no.such' } }),
142
+ (c) => (c.method === 'GET' ? undefined : { json: { id: 'b1' } }),
143
+ ]);
144
+ p = await pair();
145
+ const res = await p.call('save_book', { book_id: 'b1', identity_keys: ['policy.number'], display: '{"primary":["policy.number"],"secondary":["insured.name"]}' });
146
+ expect(res.isError).toBe(false);
147
+ expect(p.questions[0].message).toContain('change book "GL book": identity keys to policy.number; display to primary policy.number; secondary insured.name.');
148
+ const made = await p.call('save_book', { solution_id: 's1', name: 'New book', display: '{"primary":["no.such"]}' });
149
+ expect(String(made.body)).toMatch(/The book was created \(id b2\), but .*no\.such.*do not create it again/);
150
+ expect(writes(calls)).toEqual(['PUT /books/b1/identity-keys', 'PUT /books/b1/display', 'POST /books', 'PUT /books/b2/display']);
151
+ });
152
+
153
+ it('a book change card shows the new values', () => {
154
+ expect(TOOL_META.save_book.describe!({ book_id: 'b1', source: { kind: 'upload' }, field_map: [{ source_path: 'Policy #', schema_key: 'policy.number' }, { source_path: 'Insured', schema_key: 'insured.name' }] }))
155
+ .toBe('This will change book b1: source to upload; field map to Policy # → policy.number, Insured → insured.name.');
156
+ });
157
+
158
+ it('get_campaigns reads books', async () => {
159
+ const { calls } = fetches([on('GET', '/campaigns-service/books', { json: { total: 1, items: [{ id: 'b1', name: 'GL book' }] } })]);
160
+ p = await pair();
161
+ const res = await p.call('get_campaigns', { what: 'books', solution_id: 's1' });
162
+ expect(res.isError).toBe(false);
163
+ expect(calls.some((c) => c.method === 'GET' && c.url.includes('/campaigns-service/books?') && c.url.includes('solution_id=s1'))).toBe(true);
164
+ });
165
+ });
@@ -17,7 +17,7 @@ describe('MCP tool list', () => {
17
17
  expect(typeof tool.annotations?.readOnlyHint, tool.name).toBe('boolean');
18
18
  }
19
19
  expect(tools.map((t) => t.name).sort()).toMatchSnapshot();
20
- expect(tools).toHaveLength(141); // +numbers // +set_agent_transcriber // +set_org_admin_settings // +remove_holiday // +create_sandbox_org
20
+ expect(tools).toHaveLength(148); // +7 campaign tools // +numbers // +set_agent_transcriber // +set_org_admin_settings // +remove_holiday // +create_sandbox_org
21
21
  const props = (name: string) => Object.keys((tools.find((t) => t.name === name)!.inputSchema as { properties?: object }).properties ?? {});
22
22
  for (const name of ['list_agents', 'delete_agent', 'set_prompt', 'list_org_members', 'list_channels', 'chat_with_agent']) {
23
23
  expect(props(name), name).toContain('org_id');
@@ -26,7 +26,7 @@ describe('MCP tool list', () => {
26
26
  expect(props(name), name).not.toContain('org_id');
27
27
  }
28
28
  // Writes that change what customers get or reach outside ask first on every host (a dry run, then confirm).
29
- for (const name of ['delete_agent', 'create_function', 'test_function', 'add_kb_data', 'scrape_url', 'upload_kb_file']) {
29
+ for (const name of ['delete_agent', 'create_function', 'test_function', 'add_kb_data', 'scrape_url', 'upload_kb_file', 'save_campaign', 'set_touchpoint', 'publish_campaign', 'set_campaign_status', 'save_campaign_template', 'save_template_skill', 'save_book']) {
30
30
  expect(props(name), name).toContain('confirm');
31
31
  }
32
32
  // The person chooses how much of a website is crawled: only scrape_url crawls, and it needs their choice.
@@ -24,7 +24,8 @@ describe('token-first login', () => {
24
24
  ]);
25
25
  const result = await login('qa@indemn.ai', 'pw', 'dev', { firebaseSignIn: fakeFirebase });
26
26
 
27
- expect(calls.map((c) => `${c.method} ${new URL(c.url).pathname}`)).toEqual(['POST /api/auth/firebase/signin', 'GET /api/organization']);
27
+ // Then what the person may do, kept with the login (display only).
28
+ expect(calls.map((c) => `${c.method} ${new URL(c.url).pathname}`)).toEqual(['POST /api/auth/firebase/signin', 'GET /api/organization', 'GET /api/auth/me/access']);
28
29
  expect(calls.some((c) => c.url.includes('/auth/api-keys'))).toBe(false);
29
30
  expect(calls[1].headers.authorization).toBe(`JWT ${token}`);
30
31
  expect(result.orgs).toEqual([{ id: A._id, name: 'QA A' }, { id: B._id, name: 'QA B' }]);
@@ -0,0 +1,147 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { createHash } from 'node:crypto';
3
+ import { readFileSync, statSync } from 'node:fs';
4
+ import { request } from 'node:http';
5
+ import { join } from 'node:path';
6
+ import { on, recordFetch } from '../helpers/fetch-recorder.js';
7
+ import { cleanEnv, useTempHome } from '../helpers/config-home.js';
8
+ import { jwtExpiringIn } from '../helpers/jwt.js';
9
+ import { approveUrl, createPkce, dashboardUrl, startLoopback } from '../../src/sdk/browser-login.js';
10
+ import { loginWithBrowser } from '../../src/sdk/auth.js';
11
+
12
+ /** GET a loopback URL with node:http (global fetch is recorded in these tests). */
13
+ function hit(url: string): Promise<{ status: number; body: string }> {
14
+ return new Promise((resolve, reject) => {
15
+ request(url, (res) => {
16
+ let body = '';
17
+ res.on('data', (c) => (body += c));
18
+ res.on('end', () => resolve({ status: res.statusCode ?? 0, body }));
19
+ }).on('error', reject).end();
20
+ });
21
+ }
22
+
23
+ describe('PKCE and URLs', () => {
24
+ beforeEach(() => cleanEnv());
25
+
26
+ it('challenge is the base64url SHA-256 of the verifier; values are fresh each time', () => {
27
+ const a = createPkce();
28
+ const b = createPkce();
29
+ expect(a.challenge).toBe(createHash('sha256').update(a.verifier).digest('base64url'));
30
+ expect(a.challenge).toMatch(/^[A-Za-z0-9_-]{43}$/);
31
+ expect(a.verifier).toMatch(/^[A-Za-z0-9_-]{43}$/);
32
+ expect(a.state).toMatch(/^[A-Za-z0-9_-]{43}$/);
33
+ expect(a.verifier).not.toBe(b.verifier);
34
+ expect(a.state).not.toBe(b.state);
35
+ });
36
+
37
+ it('dashboard is the copilot host without /api, or INDEMN_DASHBOARD_URL (Indemn or localhost only)', () => {
38
+ expect(dashboardUrl('https://devcopilot.indemn.ai/api')).toBe('https://devcopilot.indemn.ai');
39
+ vi.stubEnv('INDEMN_DASHBOARD_URL', 'http://localhost:5174/');
40
+ expect(dashboardUrl('https://devcopilot.indemn.ai/api')).toBe('http://localhost:5174');
41
+ vi.stubEnv('INDEMN_DASHBOARD_URL', 'https://evil.example');
42
+ expect(() => dashboardUrl('https://devcopilot.indemn.ai/api')).toThrow(/indemn\.ai/);
43
+ });
44
+
45
+ it('approve URL carries port, state and challenge but never the verifier', () => {
46
+ const p = createPkce();
47
+ const url = approveUrl('https://copilot.indemn.ai', 50123, p);
48
+ expect(url.startsWith('https://copilot.indemn.ai/#/cli-login?')).toBe(true);
49
+ const q = new URLSearchParams(url.split('?')[1]);
50
+ expect(q.get('port')).toBe('50123');
51
+ expect(q.get('state')).toBe(p.state);
52
+ expect(q.get('challenge')).toBe(p.challenge);
53
+ expect(url).not.toContain(p.verifier);
54
+ });
55
+ });
56
+
57
+ describe('loopback listener', () => {
58
+ it('binds 127.0.0.1, ignores a wrong state, takes the first matching callback, then closes', async () => {
59
+ const loop = await startLoopback('the-state', 10_000);
60
+ const base = `http://127.0.0.1:${loop.port}`;
61
+ expect((await hit(`${base}/other`)).status).toBe(404);
62
+ expect((await hit(`${base}/callback?code=x&state=wrong`)).status).toBe(400);
63
+ const ok = await hit(`${base}/callback?code=the-code&state=the-state`);
64
+ expect(ok.status).toBe(200);
65
+ expect(ok.body).toContain('close this tab');
66
+ expect(ok.body).not.toContain('the-code');
67
+ await expect(loop.result).resolves.toEqual({ code: 'the-code' });
68
+ await expect(hit(`${base}/callback?code=again&state=the-state`)).rejects.toThrow();
69
+ });
70
+
71
+ it('reports a cancel from the browser', async () => {
72
+ const loop = await startLoopback('s', 10_000);
73
+ await hit(`http://127.0.0.1:${loop.port}/callback?error=access_denied&state=s`);
74
+ await expect(loop.result).resolves.toEqual({ error: 'access_denied' });
75
+ });
76
+
77
+ it('times out', async () => {
78
+ const loop = await startLoopback('s', 50);
79
+ await expect(loop.result).rejects.toThrow(/Timed out/);
80
+ });
81
+ });
82
+
83
+ describe('loginWithBrowser', () => {
84
+ let home: ReturnType<typeof useTempHome>;
85
+ beforeEach(() => { cleanEnv(); home = useTempHome(); });
86
+ afterEach(() => home.cleanup());
87
+
88
+ /** A fake browser: reads the approve URL and calls back like the dashboard would. */
89
+ function approvingBrowser(seen: { challenge?: string }, outcome: 'approve' | 'cancel' = 'approve') {
90
+ return async (url: string) => {
91
+ const q = new URLSearchParams(url.split('?')[1]);
92
+ seen.challenge = q.get('challenge') ?? undefined;
93
+ const back = new URLSearchParams(outcome === 'approve' ? { code: 'one-time-code', state: q.get('state')! } : { error: 'access_denied', state: q.get('state')! });
94
+ setTimeout(() => void hit(`http://127.0.0.1:${q.get('port')}/callback?${back}`), 10);
95
+ return true;
96
+ };
97
+ }
98
+
99
+ it('exchanges the code with the verifier and saves the session like a normal login', async () => {
100
+ const token = jwtExpiringIn(24 * 3_600_000, { cli: true });
101
+ const { calls } = recordFetch([
102
+ on('POST', '/auth/cli/token', { json: { token: `JWT ${token}`, expires_at: 'x', user: { _id: 'u1', email: 'pat@example.test' } } }),
103
+ on('GET', '/organization', { json: { success: true, userOrganizations: [{ _id: 'aaaaaaaaaaaaaaaaaaaaaaaa', name: 'Acme' }] } }),
104
+ ]);
105
+ const seen: { challenge?: string } = {};
106
+ const result = await loginWithBrowser('dev', { open: approvingBrowser(seen), timeoutMs: 10_000 });
107
+
108
+ const exchange = calls.find((c) => c.url.endsWith('/auth/cli/token'))!;
109
+ expect(new URL(exchange.url).origin).toBe('https://devcopilot.indemn.ai');
110
+ expect(exchange.headers.authorization).toBeUndefined();
111
+ const body = exchange.body as { code: string; code_verifier: string };
112
+ expect(body.code).toBe('one-time-code');
113
+ expect(createHash('sha256').update(body.code_verifier).digest('base64url')).toBe(seen.challenge);
114
+
115
+ expect(result.email).toBe('pat@example.test');
116
+ expect(result.orgs).toEqual([{ id: 'aaaaaaaaaaaaaaaaaaaaaaaa', name: 'Acme' }]);
117
+ const saved = JSON.parse(readFileSync(join(home.dir, '.indemn', 'config.json'), 'utf8'));
118
+ expect(saved.session.token).toBe(token);
119
+ expect(saved.email).toBe('pat@example.test');
120
+ expect(saved).not.toHaveProperty('api_key');
121
+ if (process.platform !== 'win32') {
122
+ expect(statSync(join(home.dir, '.indemn', 'config.json')).mode & 0o777).toBe(0o600);
123
+ expect(statSync(join(home.dir, '.indemn')).mode & 0o777).toBe(0o700);
124
+ }
125
+ });
126
+
127
+ it('cancel in the browser saves nothing', async () => {
128
+ recordFetch([]);
129
+ await expect(loginWithBrowser('dev', { open: approvingBrowser({}, 'cancel'), timeoutMs: 10_000 })).rejects.toThrow(/cancelled/);
130
+ expect(() => readFileSync(join(home.dir, '.indemn', 'config.json'))).toThrow();
131
+ });
132
+
133
+ it('a refused code says to log in again', async () => {
134
+ recordFetch([on('POST', '/auth/cli/token', { status: 400, json: { success: false, error: 'invalid_grant' } })]);
135
+ await expect(loginWithBrowser('dev', { open: approvingBrowser({}), timeoutMs: 10_000 })).rejects.toThrow(/not accepted/);
136
+ });
137
+
138
+ it('shows the URL even when the browser cannot be opened', async () => {
139
+ recordFetch([]);
140
+ const shown: Array<[string, boolean]> = [];
141
+ const pending = loginWithBrowser('dev', { open: async () => false, showUrl: (u, o) => shown.push([u, o]), timeoutMs: 100 });
142
+ await expect(pending).rejects.toThrow(/Timed out/);
143
+ expect(shown).toHaveLength(1);
144
+ expect(shown[0][1]).toBe(false);
145
+ expect(shown[0][0]).toContain('/#/cli-login?');
146
+ });
147
+ });