@indemn/cli 1.7.6 → 1.7.8

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 (66) 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 +3 -1
  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 +39 -4
  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 +2 -1
  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 +19 -7
  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/client.d.ts +2 -0
  34. package/dist/sdk/client.d.ts.map +1 -1
  35. package/dist/sdk/client.js +31 -3
  36. package/dist/sdk/client.js.map +1 -1
  37. package/dist/sdk/credentials.d.ts +2 -0
  38. package/dist/sdk/credentials.d.ts.map +1 -1
  39. package/dist/sdk/credentials.js +4 -0
  40. package/dist/sdk/credentials.js.map +1 -1
  41. package/dist/sdk/eval.d.ts +3 -3
  42. package/dist/sdk/eval.js +3 -3
  43. package/dist/sdk/types.d.ts +3 -0
  44. package/dist/sdk/types.d.ts.map +1 -1
  45. package/dist/sdk/types.js +0 -1
  46. package/dist/sdk/types.js.map +1 -1
  47. package/package.json +1 -1
  48. package/src/cli/auth.ts +93 -63
  49. package/src/mcp/registry.ts +46 -5
  50. package/src/mcp/server.ts +20 -1
  51. package/src/mcp/tool-meta.ts +2 -1
  52. package/src/mcp/tools.ts +18 -7
  53. package/src/sdk/access.ts +143 -0
  54. package/src/sdk/auth.ts +23 -2
  55. package/src/sdk/browser-login.ts +193 -0
  56. package/src/sdk/client.ts +25 -3
  57. package/src/sdk/credentials.ts +5 -0
  58. package/src/sdk/eval.ts +3 -3
  59. package/src/sdk/types.ts +3 -0
  60. package/test/mcp/access.test.ts +158 -0
  61. package/test/mcp/registry.test.ts +14 -0
  62. package/test/mcp/tools-snapshot.test.ts +10 -0
  63. package/test/sdk/auth.test.ts +2 -1
  64. package/test/sdk/browser-login.test.ts +147 -0
  65. package/test/sdk/client.test.ts +134 -1
  66. package/test/sdk/credentials.test.ts +6 -1
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod/v3';
2
- import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import { ErrorCode, type CallToolResult } from '@modelcontextprotocol/sdk/types.js';
4
4
  import type { Environment } from '../sdk/types.js';
5
5
  import { NeedsInputError, PERSON_TIMEOUT_MS } from '../core/runtime.js';
@@ -118,7 +118,9 @@ export async function guardDestructive(
118
118
  message: `${formText} Proceed?`,
119
119
  requestedSchema: {
120
120
  type: 'object',
121
- properties: { confirm: { type: 'boolean', title: 'Proceed', description: 'Tick (Space), then submit, to make this change.' } },
121
+ // Ticked already, so approving takes Enter twice in Claude Code (to Accept, then submit), not Space then
122
+ // Enter. Still one boolean field: Darwin's card reads that as an approval (Approve / Decline).
123
+ properties: { confirm: { type: 'boolean', title: 'Proceed', description: 'Ticked already: press Enter twice to approve (Space unticks).', default: true } },
122
124
  required: ['confirm'],
123
125
  },
124
126
  }, { timeout: PERSON_TIMEOUT_MS });
@@ -167,10 +169,29 @@ export function createRegistry(
167
169
  * and the ids that name nothing (no card is shown for those).
168
170
  */
169
171
  displayNames?: (args: Args, orgId?: string) => Promise<{ names: Record<string, string>; missing?: string[] }>;
172
+ /**
173
+ * Role awareness (display only; the servers still decide): hide tools the person can't use
174
+ * anywhere, refuse early with the reason when the person's access says so, and name the
175
+ * person's role when a server refuses.
176
+ */
177
+ access?: {
178
+ visible: (name: string) => boolean;
179
+ deny: (name: string, orgId?: string) => Promise<string | null>;
180
+ explain: (orgId?: string) => Promise<string | null>;
181
+ };
170
182
  },
171
183
  ) {
172
184
  const metaTable = opts.meta ?? TOOL_META;
173
185
  const registered: string[] = [];
186
+ const handles = new Map<string, RegisteredTool>();
187
+
188
+ // A refusal from the servers names the person's role (handlers report errors as results).
189
+ const withRoleNote = async (result: CallToolResult, orgId?: string): Promise<CallToolResult> => {
190
+ const first = result.isError ? result.content?.[0] : undefined;
191
+ if (!opts.access || !first || first.type !== 'text' || !/^Error: (Not allowed|Access denied)/.test(first.text)) return result;
192
+ const role = await opts.access.explain(orgId).catch(() => null);
193
+ return role ? { ...result, content: [{ ...first, text: `${first.text} ${role}` }, ...result.content.slice(1)] } : result;
194
+ };
174
195
 
175
196
  return {
176
197
  tool(name: string, description: string, shape: Shape, handler: Handler, extra: { preview?: (args: Args) => Promise<string>; guardWhen?: (args: Args) => Promise<boolean> } = {}): void {
@@ -200,12 +221,18 @@ export function createRegistry(
200
221
  .describe('Set true only after the user has agreed to the dry run. Ignored when the host can ask the user itself.');
201
222
  }
202
223
 
203
- server.registerTool(
224
+ const handle = server.registerTool(
204
225
  name,
205
226
  { title: meta.title, description, inputSchema, annotations: meta.annotations },
206
227
  (async (args: Args) => {
228
+ let orgId: string | undefined;
207
229
  try {
208
230
  const org = orgScoped ? await opts.resolveOrg!(typeof args.org_id === 'string' ? args.org_id : undefined) : undefined;
231
+ orgId = org?.id;
232
+ const refusal = opts.access ? await opts.access.deny(name, org?.id) : null;
233
+ if (refusal) {
234
+ return { content: [{ type: 'text', text: `Error: Not allowed: ${refusal} Nothing was changed.` }], isError: true };
235
+ }
209
236
  return await runWithOrg(org?.id, async () => {
210
237
  let confirmedByPerson = false;
211
238
  // A guarded tool asks every time, or only when guardWhen says so (e.g. the agent may be live).
@@ -231,7 +258,7 @@ export function createRegistry(
231
258
  if (!gate.proceed) return gate.result;
232
259
  confirmedByPerson = gate.byPerson;
233
260
  }
234
- const result = await handler(args, { confirmedByPerson });
261
+ const result = await withRoleNote(await handler(args, { confirmedByPerson }), orgId);
235
262
  return withContext(result, { ...(await opts.context()), ...(confirmedByPerson ? { approved_by_person: true } : {}) }, paginate ? { limit: Number(args.limit ?? 50), page: Number(args.page ?? 1) } : undefined);
236
263
  });
237
264
  } catch (err) {
@@ -240,16 +267,30 @@ export function createRegistry(
240
267
  const message = err.message.replace('Choose one with --org <id>', 'Call again with org_id set to one of the ids below');
241
268
  return text({ needs_input: true, message, choices: err.choices, to_proceed: 'Ask the user which organization to use, then call again with org_id set to its id.' });
242
269
  }
243
- const message = err instanceof Error ? err.message : String(err);
270
+ let message = err instanceof Error ? err.message : String(err);
271
+ if (opts.access && /^(Not allowed|Access denied)/.test(message)) {
272
+ const role = await opts.access.explain(orgId).catch(() => null);
273
+ if (role) message = `${message} ${role}`;
274
+ }
244
275
  return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
245
276
  }
246
277
  }) as never,
247
278
  );
248
279
  registered.push(name);
280
+ handles.set(name, handle);
249
281
  },
250
282
  names(): string[] {
251
283
  return [...registered];
252
284
  },
285
+ /** Show only the tools the person can use somewhere; the host is told when the list changes. */
286
+ applyVisibility(): void {
287
+ if (!opts.access) return;
288
+ for (const [name, handle] of handles) {
289
+ const show = opts.access.visible(name);
290
+ if (show && !handle.enabled) handle.enable();
291
+ else if (!show && handle.enabled) handle.disable();
292
+ }
293
+ },
253
294
  };
254
295
  }
255
296
 
package/src/mcp/server.ts CHANGED
@@ -2,6 +2,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { IndemnClient } from '../sdk/client.js';
4
4
  import { registerTools } from './tools.js';
5
+ import { refreshAccess } from '../sdk/access.js';
5
6
  import { setAskers, type Askers } from '../core/scope.js';
6
7
  import { cliVersion, PERSON_TIMEOUT_MS } from '../core/runtime.js';
7
8
 
@@ -75,8 +76,26 @@ export async function startServer(): Promise<void> {
75
76
  }, { instructions: SERVER_INSTRUCTIONS });
76
77
 
77
78
  setAskers(mcpAskers(server));
78
- registerTools(server, client);
79
+ const registry = registerTools(server, client);
80
+
81
+ // A host session (INDEMN_TOKEN) may be a different identity than any saved login,
82
+ // so never hide tools for it — deny() still refuses with the reason when a call
83
+ // isn't allowed. A saved login hides from what it already knows, before the host
84
+ // lists tools, then keeps it fresh.
85
+ const ownLogin = client.getCredential()?.source === 'config';
86
+ if (ownLogin) registry.applyVisibility();
79
87
 
80
88
  const transport = new StdioServerTransport();
81
89
  await server.connect(transport);
90
+
91
+ const refresh = () =>
92
+ refreshAccess(client).then(
93
+ () => { if (ownLogin) registry.applyVisibility(); },
94
+ () => undefined,
95
+ );
96
+ void refresh();
97
+ setInterval(() => void refresh(), ACCESS_REFRESH_MS).unref();
82
98
  }
99
+
100
+ /** How often a running MCP server asks again what the person may do. */
101
+ const ACCESS_REFRESH_MS = 15 * 60_000;
@@ -114,7 +114,8 @@ export const TOOL_META: Record<string, ToolMeta> = {
114
114
  delete_knowledge_base: { ...destroy('Delete knowledge base'), guard: true, describe: (a) => `This will delete knowledge base ${a.kb_id} with all its sources and vectors.` },
115
115
  list_kb_data: read('List knowledge base entries'),
116
116
  add_kb_data: { ...write('Add knowledge base entry'), guard: true, describe: (a) => `This will add an entry to knowledge base ${a.kb_id}; agents using it answer from it at once.` },
117
- scrape_url: { ...write('Scrape a website into a knowledge base', { openWorldHint: true }), guard: true, describe: (a) => `This will crawl ${a.url} into knowledge base ${a.kb_id}; agents using it answer from it once it is imported.` },
117
+ // The approval says which crawl it is: a whole-site crawl stops at kb-service's default of 100 pages.
118
+ scrape_url: { ...write('Scrape a website into a knowledge base', { openWorldHint: true }), guard: true, describe: (a) => `${a.crawl_mode === 'selected' ? `This will import only the page ${a.url}` : `This will crawl the whole site from ${a.url} (up to 100 pages)`} into knowledge base ${a.kb_id}; agents using it answer from it once it is imported.` },
118
119
  upload_kb_file: { ...write('Upload file to knowledge base'), guard: true, describe: (a) => `This will upload ${a.file_path} into knowledge base ${a.kb_id}; agents using it answer from it once it is imported.` },
119
120
  update_kb_data: {
120
121
  ...write('Update knowledge base entry', { idempotentHint: true }),
package/src/mcp/tools.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod/v3';
2
+ import { currentAccess, orgDenial, refreshAccess, roleIn, usableSomewhere } from '../sdk/access.js';
2
3
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
4
  import type { IndemnClient } from '../sdk/client.js';
4
5
  import { AgentInsightsSDK, AgentsSDK } from '../sdk/agents.js';
@@ -168,8 +169,22 @@ const skillDefinition = z.preprocess(parseJsonText, z.object({
168
169
  }).passthrough());
169
170
 
170
171
  export function registerTools(server: McpServer, client: IndemnClient): Registry {
172
+ // Role awareness: the folder's linked org (or INDEMN_ORG_ID) narrows what counts; otherwise any org.
173
+ const orgInView = () => process.env.INDEMN_ORG_ID || client.getLink()?.org_id || undefined;
171
174
  const reg = createRegistry(server, {
172
175
  context: () => client.getResultContext(),
176
+ access: {
177
+ visible: (name) => usableSomewhere(name, currentAccess(client), orgInView()),
178
+ // Refuse early only when the saved access still says no after asking again (it may be stale).
179
+ deny: async (name, orgId) => {
180
+ if (!orgDenial(name, currentAccess(client), orgId)) return null;
181
+ return orgDenial(name, await refreshAccess(client), orgId);
182
+ },
183
+ explain: async (orgId) => {
184
+ const role = roleIn((await refreshAccess(client)) ?? currentAccess(client), orgId);
185
+ return role ? `Your role: ${role}.` : null;
186
+ },
187
+ },
173
188
  // Each call decides its own org; an answer given once is not reused for the next call.
174
189
  resolveOrg: (explicit) => client.resolveOrg(explicit, { cache: false }),
175
190
  // The person approves a change by names: look up the ids the call names.
@@ -1070,16 +1085,12 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
1070
1085
  kb_id: z.string().describe('The knowledge base ID'),
1071
1086
  question: z.string().optional().describe('FAQ question'),
1072
1087
  answer: z.string().optional().describe('FAQ answer'),
1073
- source_url: z.string().optional().describe('Source URL (initiates scraping via import endpoint)'),
1074
1088
  content: z.string().optional().describe('Raw content'),
1075
1089
  },
1076
1090
  async (args) => {
1077
1091
  try {
1092
+ // A website goes through scrape_url, where the person chooses the whole site or one page.
1078
1093
  const { kb_id, ...input } = args;
1079
- if (input.source_url) {
1080
- const result = await kb.scrapeUrl(kb_id, input.source_url as string);
1081
- return success(result);
1082
- }
1083
1094
  const result = await kb.addDataSource(kb_id, input);
1084
1095
  return success(result);
1085
1096
  } catch (err) { return error(err); }
@@ -1088,11 +1099,11 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
1088
1099
 
1089
1100
  reg.tool(
1090
1101
  'scrape_url',
1091
- 'Scrape a website URL and add it as a data source to a knowledge base. The crawl runs in the background; its pages appear in the knowledge base only when it ends.',
1102
+ 'Scrape a website URL and add it as a data source to a knowledge base. The crawl runs in the background; its pages appear in the knowledge base only when it ends. Unless the person already said, ask them first whether to crawl the whole site (up to 100 pages) or import only this page.',
1092
1103
  {
1093
1104
  kb_id: z.string().describe('The knowledge base ID'),
1094
1105
  url: z.string().describe('URL to scrape'),
1095
- crawl_mode: z.string().optional().describe('Crawl mode: "full" (entire site) or "selected"'),
1106
+ crawl_mode: z.enum(['full', 'selected']).describe('What the person chose: "full" crawls the whole site from url (up to 100 pages); "selected" imports only this page (one call per page).'),
1096
1107
  },
1097
1108
  async (args) => {
1098
1109
  try {
@@ -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
+ }