@lanes-sh/link 0.2.2 → 0.3.1

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 (143) hide show
  1. package/README.md +22 -8
  2. package/instructions/skills/lanes-link/SKILL.md +42 -14
  3. package/package.json +1 -1
  4. package/src/cli/argv.ts +50 -0
  5. package/src/cli/brand.ts +178 -0
  6. package/src/cli/callback-page.ts +108 -128
  7. package/src/cli/commands/connect/accounts.ts +5 -0
  8. package/src/cli/commands/connect/assertion.ts +187 -0
  9. package/src/cli/commands/connect/authorise.ts +61 -17
  10. package/src/cli/commands/connect/client.ts +37 -9
  11. package/src/cli/commands/connect/discover.ts +94 -0
  12. package/src/cli/commands/connect/family.ts +72 -0
  13. package/src/cli/commands/connect/index.ts +113 -115
  14. package/src/cli/commands/connect/method.ts +237 -0
  15. package/src/cli/commands/connect/outcome.ts +42 -1
  16. package/src/cli/commands/connect/pasted-token.ts +66 -0
  17. package/src/cli/commands/connect/requirements.ts +60 -8
  18. package/src/cli/commands/connect/setup.ts +16 -5
  19. package/src/cli/commands/connect/target-note.ts +34 -0
  20. package/src/cli/commands/identity.ts +258 -0
  21. package/src/cli/commands/knowledge/index.ts +390 -0
  22. package/src/cli/commands/knowledge/migrate.ts +180 -0
  23. package/src/cli/commands/knowledge/setup.ts +144 -0
  24. package/src/cli/commands/knowledge.ts +10 -0
  25. package/src/cli/commands/mcp/harnesses.ts +16 -2
  26. package/src/cli/commands/mcp/register.ts +9 -1
  27. package/src/cli/commands/mcp/stdio.ts +21 -0
  28. package/src/cli/commands/operate/dashboard.ts +107 -0
  29. package/src/cli/commands/operate/findings.ts +151 -0
  30. package/src/cli/commands/operate/inspect.ts +63 -164
  31. package/src/cli/commands/operate/outputs.ts +38 -11
  32. package/src/cli/commands/operate/policy.ts +7 -7
  33. package/src/cli/commands/operate/serve.ts +3 -0
  34. package/src/cli/commands/operate/status.ts +108 -1
  35. package/src/cli/commands/operate/token.ts +1 -1
  36. package/src/cli/commands/operate.ts +2 -0
  37. package/src/cli/commands/profile/declare.ts +154 -0
  38. package/src/cli/commands/profile/removal.ts +17 -0
  39. package/src/cli/commands/profile/remove.ts +5 -5
  40. package/src/cli/commands/profile.ts +83 -35
  41. package/src/cli/commands/secrets.ts +6 -6
  42. package/src/cli/commands/setup.ts +22 -6
  43. package/src/cli/commands/sync.ts +262 -0
  44. package/src/cli/commands/target.ts +65 -83
  45. package/src/cli/config-edit.ts +53 -144
  46. package/src/cli/config-repair.ts +186 -0
  47. package/src/cli/dashboard-page.ts +284 -0
  48. package/src/cli/dashboard-shell.ts +125 -0
  49. package/src/cli/dispatch-owner.ts +93 -0
  50. package/src/cli/identity.ts +12 -1
  51. package/src/cli/main.ts +90 -61
  52. package/src/cli/nearest.ts +45 -0
  53. package/src/cli/oauth-callback.ts +187 -0
  54. package/src/cli/oauth-exchange.ts +57 -15
  55. package/src/cli/oauth.ts +67 -177
  56. package/src/cli/output.ts +21 -5
  57. package/src/cli/provider-marks.ts +45 -0
  58. package/src/cli/runtime/open.ts +74 -51
  59. package/src/cli/runtime/registry.ts +60 -2
  60. package/src/cli/runtime/select.ts +26 -13
  61. package/src/cli/runtime/vault.ts +61 -0
  62. package/src/cli/runtime.ts +2 -1
  63. package/src/cli/selection.ts +365 -0
  64. package/src/cli/usage.ts +42 -11
  65. package/src/connectivity/auth/README.md +7 -1
  66. package/src/connectivity/auth/basic/index.ts +1 -1
  67. package/src/connectivity/auth/index.ts +14 -0
  68. package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
  69. package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
  70. package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
  71. package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
  72. package/src/connectivity/auth/oauth-jwt/README.md +33 -0
  73. package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
  74. package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
  75. package/src/connectivity/auth/resolve.ts +1 -1
  76. package/src/connectivity/auth/token.ts +11 -0
  77. package/src/connectivity/index.ts +2 -0
  78. package/src/connectivity/manifest/auth.ts +99 -2
  79. package/src/connectivity/manifest/identity.ts +12 -0
  80. package/src/connectivity/manifest/index.ts +3 -1
  81. package/src/connectivity/manifest/provider.ts +37 -8
  82. package/src/connectivity/manifest/requirements.ts +109 -6
  83. package/src/deployments/adapters/filesystem.ts +10 -1
  84. package/src/deployments/adapters/github-api.ts +106 -0
  85. package/src/deployments/adapters/github-commit.ts +103 -0
  86. package/src/deployments/adapters/github-repo.ts +356 -0
  87. package/src/deployments/adapters/github-testing.ts +258 -0
  88. package/src/deployments/adapters/github.ts +125 -0
  89. package/src/deployments/deploy.ts +94 -114
  90. package/src/deployments/discover.ts +103 -0
  91. package/src/deployments/driver.ts +8 -1
  92. package/src/deployments/gcp/driver.ts +3 -1
  93. package/src/deployments/knowledge.ts +119 -0
  94. package/src/deployments/prepare.ts +12 -6
  95. package/src/deployments/report.ts +117 -0
  96. package/src/deployments/servable.ts +82 -0
  97. package/src/deployments/serving.ts +165 -0
  98. package/src/deployments/sync-apply.ts +276 -0
  99. package/src/deployments/sync.ts +136 -0
  100. package/src/deployments/target.ts +3 -2
  101. package/src/deployments/upload.ts +19 -12
  102. package/src/dispatch/dispatch.ts +1 -1
  103. package/src/profile/deployments.ts +80 -0
  104. package/src/profile/identity.ts +60 -0
  105. package/src/profile/index.ts +23 -5
  106. package/src/profile/knowledge.ts +124 -0
  107. package/src/profile/load.ts +17 -5
  108. package/src/profile/primitives.ts +24 -1
  109. package/src/profile/schema.ts +81 -3
  110. package/src/profile/targets.ts +122 -109
  111. package/src/profile/workspace.ts +139 -79
  112. package/src/providers/google/calendar/index.ts +2 -0
  113. package/src/providers/google/contacts/index.ts +2 -0
  114. package/src/providers/google/docs/index.ts +2 -0
  115. package/src/providers/google/drive/index.ts +2 -0
  116. package/src/providers/google/gmail/index.ts +2 -0
  117. package/src/providers/google/gmail-imap/index.ts +125 -0
  118. package/src/providers/google/index.ts +2 -1
  119. package/src/providers/google/shared/oauth.ts +18 -6
  120. package/src/providers/google/shared/service-account.ts +110 -0
  121. package/src/providers/google/shared/setup.ts +21 -3
  122. package/src/providers/google/sheets/index.ts +2 -0
  123. package/src/providers/google/tasks/index.ts +2 -0
  124. package/src/providers/identity/provider.ts +166 -0
  125. package/src/providers/index.ts +3 -0
  126. package/src/providers/owner.ts +10 -2
  127. package/src/providers/scopes.ts +2 -0
  128. package/src/providers/setup/plan.ts +31 -9
  129. package/src/providers/setup/provider.ts +23 -0
  130. package/src/providers/slack/index.ts +81 -33
  131. package/src/providers/slack/oauth.ts +103 -0
  132. package/src/providers/slack/scopes.ts +37 -0
  133. package/src/server/container.ts +18 -1
  134. package/src/server/cors.ts +252 -0
  135. package/src/server/dashboard.ts +208 -0
  136. package/src/server/endpoint.ts +45 -1
  137. package/src/server/generations.ts +11 -2
  138. package/src/server/harness.ts +7 -0
  139. package/src/server/index.ts +45 -6
  140. package/src/server/mcp/index.ts +1 -0
  141. package/src/server/mcp/instructions.ts +28 -1
  142. package/src/server/mcp/visibility.ts +33 -0
  143. package/src/stores/blobs/route.ts +123 -0
package/src/cli/main.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  auditVerify,
6
6
  check,
7
7
  configShow,
8
+ dashboard,
8
9
  doctor,
9
10
  outputs,
10
11
  plan,
@@ -18,28 +19,18 @@ import {
18
19
  } from './commands/operate.ts';
19
20
  import { profileAdd, profileDefault, profileList } from './commands/profile.ts';
20
21
  import { removeProfile as profileRemove } from './commands/profile/remove.ts';
22
+ import { syncTargets } from './commands/sync.ts';
21
23
  import { targetList, targetShow, targetUse } from './commands/target.ts';
24
+ import { identityAdd, identityList, identityRemove } from './commands/identity.ts';
22
25
  import { setupPlan } from './commands/setup.ts';
23
26
  import { mcpAdd, mcpList, mcpStdio, skillDocument } from './commands/mcp.ts';
24
27
  import { deploy } from '#deployments/deploy.ts';
25
28
  import { secretsList, secretsPush, secretsSet } from './commands/secrets.ts';
26
- import {
27
- memoryForget,
28
- memoryGet,
29
- memoryList,
30
- memoryWrite,
31
- skillsAdd,
32
- skillsList,
33
- skillsRemove,
34
- skillsShow,
35
- vaultGet,
36
- vaultKeyGenerate,
37
- vaultList,
38
- vaultRemove,
39
- vaultSet,
40
- } from './commands/owner.ts';
29
+ import { knowledgeShow, knowledgeUse } from './commands/knowledge.ts';
30
+ import { dispatchOwner } from './dispatch-owner.ts';
41
31
  import { update } from './commands/update.ts';
42
- import { globalFlags, ownerFlags, parseArgv, text } from './argv.ts';
32
+ import { all, globalFlags, knowledgeFlags, ownerFlags, parseArgv, text } from './argv.ts';
33
+ import { assertKnownFlags, requireSelection } from './selection.ts';
43
34
  import { PROGRAM, USAGE } from './usage.ts';
44
35
  import { version } from './version.ts';
45
36
  import { print } from './output.ts';
@@ -74,6 +65,14 @@ export async function run(argv: readonly string[]): Promise<void> {
74
65
  return;
75
66
  }
76
67
 
68
+ // Before the switch, so no command can be reached having been handed a flag it
69
+ // does not read or missing one it needs. One call site rather than a check per
70
+ // case: the reported bug was a single command building its own options literal
71
+ // and dropping `--target` into it, which is exactly what a per-case check
72
+ // leaves room for.
73
+ assertKnownFlags(first, second, flags);
74
+ await requireSelection(first, second, flags);
75
+
77
76
  switch (first) {
78
77
  case 'connect':
79
78
  if (!second) throw new Error(`Usage: ${PROGRAM} connect <provider>`);
@@ -85,6 +84,7 @@ export async function run(argv: readonly string[]): Promise<void> {
85
84
  nonInteractive: flags['non-interactive'] === true,
86
85
  acceptBroadScopes: flags['accept-broad-scopes'] === true,
87
86
  ownClient: flags['own-client'] === true,
87
+ auth: text(flags, 'auth'),
88
88
  json,
89
89
  });
90
90
 
@@ -97,8 +97,17 @@ export async function run(argv: readonly string[]): Promise<void> {
97
97
  case 'profile':
98
98
  switch (second) {
99
99
  case 'add':
100
- if (!rest[0]) throw new Error(`Usage: ${PROGRAM} profile add <name> [--default]`);
101
- return profileAdd(rest[0], { default: flags['default'] === true, json });
100
+ if (!rest[0]) {
101
+ throw new Error(`Usage: ${PROGRAM} profile add <name> --target <name> [--target <name>]`);
102
+ }
103
+ return profileAdd(rest[0], {
104
+ // Read from argv rather than from `flags`, because this is the one
105
+ // place a flag is a list: a profile declares every target it can run
106
+ // on, and the parser keeps only the last value of a repeated flag.
107
+ targets: all(argv, 'target'),
108
+ nonInteractive: flags['non-interactive'] === true,
109
+ json,
110
+ });
102
111
  case 'list':
103
112
  case undefined:
104
113
  return profileList({ json });
@@ -128,13 +137,35 @@ export async function run(argv: readonly string[]): Promise<void> {
128
137
  return targetList({ ...global, json, urls: flags['urls'] === true });
129
138
  case 'use':
130
139
  if (!rest[0]) throw new Error(`Usage: ${PROGRAM} target use <name>`);
131
- return targetUse(rest[0], global);
140
+ return targetUse(rest[0]);
132
141
  case 'show':
133
142
  return targetShow(rest[0], { ...global, json });
134
143
  default:
135
144
  throw new Error(`Unknown: ${PROGRAM} target ${second}`);
136
145
  }
137
146
 
147
+ case 'identity': {
148
+ // Both subcommands take the same two positionals, so the usage line is
149
+ // built once rather than written twice with one of them going stale.
150
+ const [kind, value] = rest;
151
+ const usage = (form: string): string =>
152
+ `Usage: ${PROGRAM} identity ${form}\n e.g. ${PROGRAM} identity add name "Your Name" --note "for open-source work"`;
153
+
154
+ switch (second) {
155
+ case 'add':
156
+ if (!kind || !value) throw new Error(usage('add <kind> <value> [--note text]'));
157
+ return identityAdd(kind, value, { ...global, note: text(flags, 'note'), json });
158
+ case 'list':
159
+ case undefined:
160
+ return identityList({ ...global, json });
161
+ case 'remove':
162
+ if (!kind || !value) throw new Error(usage('remove <kind> <value>'));
163
+ return identityRemove(kind, value, { ...global, json });
164
+ default:
165
+ throw new Error(`Unknown: ${PROGRAM} identity ${second}`);
166
+ }
167
+ }
168
+
138
169
  case 'policy':
139
170
  switch (second) {
140
171
  case 'list':
@@ -191,54 +222,26 @@ export async function run(argv: readonly string[]): Promise<void> {
191
222
  if (second !== 'show' && second !== undefined) throw new Error(`Unknown: ${PROGRAM} config ${second}`);
192
223
  return configShow(global);
193
224
 
225
+ // memory, skills and vault — one subject, dispatched together.
226
+ // `vault key generate` is synchronous, so this returns the result rather
227
+ // than testing it for truthiness.
194
228
  case 'memory':
195
- switch (second) {
196
- case 'list':
197
- case undefined:
198
- return memoryList(owner);
199
- case 'get':
200
- return memoryGet(rest[0], owner);
201
- case 'write':
202
- return memoryWrite(rest[0], owner);
203
- case 'forget':
204
- return memoryForget(rest[0], owner);
205
- default:
206
- throw new Error(`Unknown: ${PROGRAM} memory ${second}`);
207
- }
208
-
209
229
  case 'skills':
210
- switch (second) {
211
- case 'list':
212
- case undefined:
213
- return skillsList(owner);
214
- case 'show':
215
- return skillsShow(rest[0], owner);
216
- case 'add':
217
- return skillsAdd(rest[0], owner);
218
- case 'remove':
219
- return skillsRemove(rest[0], owner);
220
- default:
221
- throw new Error(`Unknown: ${PROGRAM} skills ${second}`);
222
- }
223
-
224
230
  case 'vault':
231
+ return dispatchOwner(first, second, rest, owner, PROGRAM);
232
+
233
+ // Beside `memory` and `skills` because it is the question they raise next:
234
+ // those two say what is stored, and this says where it is kept. Not one of
235
+ // them, though — it takes its own flags rather than the owner set.
236
+ case 'knowledge':
225
237
  switch (second) {
226
- case 'list':
238
+ case 'show':
227
239
  case undefined:
228
- return vaultList(owner);
229
- case 'get':
230
- return vaultGet(rest[0], owner);
231
- case 'set':
232
- return vaultSet(rest[0], owner);
233
- case 'remove':
234
- return vaultRemove(rest[0], owner);
235
- case 'key':
236
- if (rest[0] !== 'generate') {
237
- throw new Error(`Usage: ${PROGRAM} vault key generate`);
238
- }
239
- return vaultKeyGenerate(owner);
240
+ return knowledgeShow(knowledgeFlags(flags));
241
+ case 'use':
242
+ return knowledgeUse(rest[0], knowledgeFlags(flags));
240
243
  default:
241
- throw new Error(`Unknown: ${PROGRAM} vault ${second}`);
244
+ throw new Error(`Unknown: ${PROGRAM} knowledge ${second}`);
242
245
  }
243
246
 
244
247
  case 'check':
@@ -252,6 +255,13 @@ export async function run(argv: readonly string[]): Promise<void> {
252
255
  case 'outputs':
253
256
  return outputs({ ...global, show, json });
254
257
 
258
+ // Beside `outputs` for the same reason `tools` is: it answers the next
259
+ // question a person has rather than the next one an agent has. `outputs`
260
+ // hands a harness a URL and a token; this opens the one page a person can
261
+ // read, and only a local endpoint serves it.
262
+ case 'dashboard':
263
+ return dashboard({ ...global, print: flags['print'] === true });
264
+
255
265
  // Beside `outputs` because it answers the next question. `outputs` says
256
266
  // where the endpoint is; this says what it would hand a client that asked
257
267
  // right now — which is the only way to tell a stale client from a wrong
@@ -301,6 +311,10 @@ export async function run(argv: readonly string[]): Promise<void> {
301
311
  case 'deploy':
302
312
  return deploy({
303
313
  ...global,
314
+ // Repeatable, like `profile add --target`: a deploy sends every profile
315
+ // that declares the target, and this narrows that set rather than
316
+ // selecting from it. The first named is the primary (ADR-043).
317
+ profiles: all(argv, 'profile'),
304
318
  dryRun: flags['dry-run'] === true,
305
319
  // `--iam` was a boolean that meant "add the platform's own check on top".
306
320
  // It is now one of two values of a declared field, because the opposite
@@ -313,6 +327,21 @@ export async function run(argv: readonly string[]): Promise<void> {
313
327
  nonInteractive: flags['non-interactive'] === true,
314
328
  });
315
329
 
330
+ case 'sync':
331
+ switch (second) {
332
+ case undefined:
333
+ case 'targets':
334
+ return syncTargets({
335
+ ...global,
336
+ dryRun: flags['dry-run'] === true,
337
+ from: text(flags, 'from'),
338
+ discover: flags['discover'] === true,
339
+ prefer: text(flags, 'prefer'),
340
+ });
341
+ default:
342
+ throw new Error(`Unknown: ${PROGRAM} sync ${second}`);
343
+ }
344
+
316
345
  case 'secrets':
317
346
  switch (second) {
318
347
  case 'push':
@@ -0,0 +1,45 @@
1
+ /**
2
+ * "Did you mean --profile?" — one edit away, and no further.
3
+ *
4
+ * Pulled out of `selection.ts`, which is the table of what each command needs
5
+ * and the checks that enforce it. Guessing at a misspelling is neither: it is
6
+ * string distance, it has no idea what a flag is, and the reason it lives
7
+ * beside that file rather than inside it is that the file kept growing every
8
+ * time a command was added and this never did.
9
+ */
10
+
11
+ /**
12
+ * The closest accepted flag, when there is an obviously close one.
13
+ *
14
+ * One edit away, or one transposition — enough for `--porfile` and `--taget`,
15
+ * and short of guessing at something the operator did not mean. A wrong guess
16
+ * here costs more than no guess: it sends them to a flag that is not the answer.
17
+ */
18
+ export function nearest(given: string, allowed: ReadonlySet<string>): string | undefined {
19
+ for (const candidate of allowed) {
20
+ if (Math.abs(candidate.length - given.length) > 1) continue;
21
+ if (distance(given, candidate) <= 1) return candidate;
22
+ if (sorted(given) === sorted(candidate)) return candidate;
23
+ }
24
+ return undefined;
25
+ }
26
+
27
+ const sorted = (text: string): string => [...text].sort().join('');
28
+
29
+ function distance(a: string, b: string): number {
30
+ let row = Array.from({ length: b.length + 1 }, (_, index) => index);
31
+
32
+ for (let i = 1; i <= a.length; i++) {
33
+ const next = [i];
34
+ for (let j = 1; j <= b.length; j++) {
35
+ next[j] = Math.min(
36
+ row[j]! + 1,
37
+ next[j - 1]! + 1,
38
+ row[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
39
+ );
40
+ }
41
+ row = next;
42
+ }
43
+
44
+ return row[b.length]!;
45
+ }
@@ -0,0 +1,187 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
2
+ import { completionPage } from './callback-page.ts';
3
+ import { OAuthError } from './oauth-error.ts';
4
+
5
+ /**
6
+ * The loopback listener both OAuth paths come back to.
7
+ *
8
+ * Two callers want the same door and want it differently. `runOAuthFlow` drives
9
+ * the whole protocol and reads the code itself; `captureOAuthCallback` hands the
10
+ * protocol to the MCP SDK and supplies only somewhere for the browser to land.
11
+ * What they share is everything about the listener — that it binds loopback,
12
+ * serves exactly one callback, compares `state` in constant time, leaves a page
13
+ * a person can read, and drains before it closes — and holding that in one file
14
+ * is what stops the two drifting into disagreeing about it.
15
+ *
16
+ * Split out of `oauth.ts` when that file passed the size budget, along the seam
17
+ * the budget exists to find: the flow, and the door it knocks on.
18
+ */
19
+
20
+ const base64url = (input: Buffer): string => input.toString('base64url');
21
+
22
+ /** Compare the returned state in constant time — it is a CSRF defence. */
23
+ export function stateMatches(expected: string, received: string): boolean {
24
+ const a = createHash('sha256').update(expected).digest();
25
+ const b = createHash('sha256').update(received).digest();
26
+ return timingSafeEqual(a, b);
27
+ }
28
+
29
+ /**
30
+ * What the browser is left looking at, in the two shapes it comes in.
31
+ *
32
+ * The label is what the caller knows and this file cannot: which provider the
33
+ * grant was for. Given one, the page names it under "Connected" the way the
34
+ * invite page names the workspace; without one it says only that the flow
35
+ * finished, which is what every path said before.
36
+ */
37
+ export const connectedPage = (label?: string): Response =>
38
+ completionPage({
39
+ ...(label ? { label: 'Connected', heading: label } : { heading: 'Connected' }),
40
+ detail: 'You can close this tab and return to the terminal.',
41
+ ok: true,
42
+ });
43
+
44
+ export const failedPage = (detail: string): Response =>
45
+ completionPage({ heading: 'Authorization failed', detail, ok: false });
46
+
47
+ /**
48
+ * Close the listener without cutting off the response in flight.
49
+ *
50
+ * Forcing the socket shut the instant the code is read leaves the operator
51
+ * looking at a connection error on a flow that in fact succeeded. So: stop
52
+ * gracefully, wait for the in-flight callback to drain, and force only if
53
+ * graceful genuinely did not finish — a keep-alive connection must not be able
54
+ * to hold the CLI open forever either.
55
+ */
56
+ export async function shutdown(server: { stop(force?: boolean): Promise<void> }): Promise<void> {
57
+ let drained = false;
58
+ const graceful = server.stop().then(() => {
59
+ drained = true;
60
+ });
61
+
62
+ await withTimeout(graceful, 2_000).catch(() => {});
63
+ if (!drained) await server.stop(true);
64
+ }
65
+
66
+ /**
67
+ * Race a promise against a deadline, and **clear the timer either way**.
68
+ *
69
+ * A bare `Promise.race` with `setTimeout` leaks: when the real promise wins,
70
+ * the timer is still pending, and a pending timer keeps the event loop alive
71
+ * for its full duration. With a five-minute OAuth deadline that turns a
72
+ * finished `connect` into a terminal that prints "Next: lanes link start" and
73
+ * then sits there for five minutes — which is exactly what it did.
74
+ *
75
+ * Rejects with `OAuthError(message)` if given one, otherwise resolves to
76
+ * undefined at the deadline, which is what the shutdown path wants.
77
+ */
78
+ export async function withTimeout<T>(promise: Promise<T>, ms: number, message?: string): Promise<T> {
79
+ let timer: ReturnType<typeof setTimeout> | undefined;
80
+
81
+ try {
82
+ return await Promise.race([
83
+ promise,
84
+ new Promise<T>((resolve, reject) => {
85
+ timer = setTimeout(
86
+ () => (message ? reject(new OAuthError(message)) : resolve(undefined as T)),
87
+ ms,
88
+ );
89
+ }),
90
+ ]);
91
+ } finally {
92
+ if (timer) clearTimeout(timer);
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Capture exactly one OAuth callback on loopback.
98
+ *
99
+ * Split out from `runOAuthFlow` so the SDK can drive the protocol — discovery,
100
+ * registration, PKCE, exchange — while we supply only the redirect target. The
101
+ * listener exists for the duration of one consent and no longer.
102
+ */
103
+ export interface CallbackCapture {
104
+ readonly redirectUri: string;
105
+ /**
106
+ * The `state` this listener will accept, for the provider to put on the
107
+ * authorization URL. The SDK asks its provider for one and sends whatever it
108
+ * gets; nothing generates it for us.
109
+ */
110
+ readonly state: string;
111
+ wait(timeoutMs?: number): Promise<{ code: string; iss?: string }>;
112
+ close(): Promise<void>;
113
+ }
114
+
115
+ export function captureOAuthCallback(options: { label?: string } = {}): CallbackCapture {
116
+ const state = base64url(randomBytes(24));
117
+ let resolveCode: (value: { code: string; iss?: string }) => void;
118
+ let rejectCode: (error: Error) => void;
119
+
120
+ const received = new Promise<{ code: string; iss?: string }>((resolve, reject) => {
121
+ resolveCode = resolve;
122
+ rejectCode = reject;
123
+ });
124
+
125
+ const server = Bun.serve({
126
+ hostname: '127.0.0.1',
127
+ port: 0,
128
+ fetch(request) {
129
+ const url = new URL(request.url);
130
+ if (url.pathname !== '/callback') return new Response('Not found', { status: 404 });
131
+
132
+ const error = url.searchParams.get('error');
133
+ if (error) {
134
+ rejectCode(
135
+ new OAuthError(
136
+ error === 'access_denied'
137
+ ? 'Authorization was declined in the browser.'
138
+ : `Authorization failed: ${error}`,
139
+ ),
140
+ );
141
+ return failedPage('You can close this tab.');
142
+ }
143
+
144
+ const code = url.searchParams.get('code');
145
+ if (!code) {
146
+ rejectCode(new OAuthError('The callback carried no authorization code.'));
147
+ return failedPage('No code returned.');
148
+ }
149
+
150
+ // Checked here because nothing else checks it. The SDK's own docs say it
151
+ // does not validate `state`, and it only sends one if the provider hands
152
+ // it over — so this listener mints the value, `CredentialOAuthProvider`
153
+ // returns it from `state()`, and the two are compared on the way back.
154
+ //
155
+ // Without it this resolves on the first `/callback?code=` to reach the
156
+ // port, whoever sent it: any local process, or a page the operator has
157
+ // open, can sweep loopback during the five-minute window. PKCE usually
158
+ // turns that into a failed exchange, but a manifest is free to name an
159
+ // authorization server that does not enforce it, and there the code
160
+ // would be redeemed and the connection bound to someone else's account.
161
+ const returnedState = url.searchParams.get('state');
162
+ if (!returnedState || !stateMatches(state, returnedState)) {
163
+ rejectCode(new OAuthError('State mismatch — ignoring an unexpected callback.'));
164
+ return failedPage('Unexpected callback.');
165
+ }
166
+
167
+ const iss = url.searchParams.get('iss');
168
+ resolveCode({ code, ...(iss ? { iss } : {}) });
169
+ return connectedPage(options.label);
170
+ },
171
+ });
172
+
173
+ return {
174
+ redirectUri: `http://127.0.0.1:${server.port}/callback`,
175
+ state,
176
+
177
+ async wait(timeoutMs = 5 * 60_000) {
178
+ return withTimeout(
179
+ received,
180
+ timeoutMs,
181
+ `Timed out after ${Math.round(timeoutMs / 1000)}s waiting for the browser.`,
182
+ );
183
+ },
184
+
185
+ close: () => shutdown(server),
186
+ };
187
+ }
@@ -13,9 +13,18 @@ import { OAuthError } from './oauth-error.ts';
13
13
  */
14
14
 
15
15
  export interface OAuthTokens {
16
- readonly refreshToken: string;
16
+ /** Absent where the vendor issues a long-lived token and never renews it. */
17
+ readonly refreshToken?: string;
17
18
  readonly accessToken: string;
18
- readonly expiresIn: number;
19
+ /**
20
+ * Absent where the vendor states no lifetime.
21
+ *
22
+ * Not defaulted to an hour: with no refresh token there is nothing to renew
23
+ * with, so a made-up expiry would have `doctor` reporting a healthy
24
+ * connection as stale forever and would tell the operator to re-run a command
25
+ * that fixes nothing.
26
+ */
27
+ readonly expiresIn?: number;
19
28
  readonly scope: string;
20
29
  /**
21
30
  * An identity assertion, present when `openid` was granted.
@@ -38,11 +47,37 @@ export interface ExchangeInput {
38
47
 
39
48
  export type ExchangeCode = (input: ExchangeInput) => Promise<OAuthTokens>;
40
49
 
50
+ /**
51
+ * What a response without a refresh token means for this vendor.
52
+ *
53
+ * The two readings are opposite and neither is guessable from the response.
54
+ * Google omitting one means the account was already authorised and the
55
+ * connection will die in an hour — worth stopping for. Slack omitting one is
56
+ * the ordinary success: a user token is long-lived unless token rotation is
57
+ * switched on, so demanding one would refuse every connection that worked.
58
+ */
59
+ export interface RefreshTokenPolicy {
60
+ readonly required: boolean;
61
+ /** Named in the refusal, so it is not always the vendor who first needed it. */
62
+ readonly vendor: string;
63
+ /** Where the operator withdraws the existing grant, when the manifest says. */
64
+ readonly revokeUrl?: string | undefined;
65
+ }
66
+
41
67
  /** Straight to the vendor, signed with a client this machine holds. */
42
68
  export function directExchange(options: {
43
69
  readonly tokenUrl: string;
44
70
  readonly clientId: string;
45
- readonly clientSecret: string;
71
+ /**
72
+ * Absent for a public client, where PKCE is the whole of the protection.
73
+ *
74
+ * A client id shipped in a public repository has no secret to go with it, and
75
+ * inventing an empty one would be sent as `client_secret=` — which some
76
+ * authorization servers read as a malformed confidential client rather than
77
+ * as a public one, and refuse for a reason that names neither.
78
+ */
79
+ readonly clientSecret?: string | undefined;
80
+ readonly refreshToken: RefreshTokenPolicy;
46
81
  readonly fetch?: typeof globalThis.fetch | undefined;
47
82
  }): ExchangeCode {
48
83
  return async (input) => {
@@ -56,7 +91,7 @@ export function directExchange(options: {
56
91
  code: input.code,
57
92
  redirect_uri: input.redirectUri,
58
93
  client_id: options.clientId,
59
- client_secret: options.clientSecret,
94
+ ...(options.clientSecret ? { client_secret: options.clientSecret } : {}),
60
95
  code_verifier: input.codeVerifier,
61
96
  }),
62
97
  });
@@ -71,19 +106,23 @@ export function directExchange(options: {
71
106
  error_description?: string;
72
107
  };
73
108
 
109
+ // Not every vendor signals failure with a status. Slack answers a refused
110
+ // exchange with HTTP 200 and `{ok: false, error: ...}`, so the absent token
111
+ // is what has to be trusted here rather than the code.
74
112
  if (!response.ok || !body.access_token) {
75
113
  throw new OAuthError(
76
114
  `Token exchange failed: ${body.error ?? response.status} ${body.error_description ?? ''}`.trim(),
77
115
  );
78
116
  }
79
117
 
80
- return settle(body, input.scopes);
118
+ return settle(body, input.scopes, options.refreshToken);
81
119
  };
82
120
  }
83
121
 
84
122
  /** Through a broker, which holds the secret this machine does not have. */
85
123
  export function brokerExchangeVia(options: {
86
124
  readonly url: string;
125
+ readonly refreshToken: RefreshTokenPolicy;
87
126
  readonly fetch?: typeof globalThis.fetch | undefined;
88
127
  }): ExchangeCode {
89
128
  return async (input) => {
@@ -110,7 +149,7 @@ export function brokerExchangeVia(options: {
110
149
  throw new OAuthError('The token exchange returned no access token.');
111
150
  }
112
151
 
113
- return settle(tokens, input.scopes);
152
+ return settle(tokens, input.scopes, options.refreshToken);
114
153
  };
115
154
  }
116
155
 
@@ -123,23 +162,26 @@ function settle(
123
162
  scope?: string | undefined;
124
163
  },
125
164
  scopes: readonly string[],
165
+ refreshToken: RefreshTokenPolicy,
126
166
  ): OAuthTokens {
127
- if (!body.refresh_token) {
167
+ if (!body.refresh_token && refreshToken.required) {
128
168
  // Without one, the connection works until the access token expires and then
129
- // quietly stops. Better to fail now with the actual cause. Google is named
130
- // because Google is who reaches this path: every provider that redeems a
131
- // code here is a Google REST one, and the others hand the exchange to the
132
- // MCP SDK. Naming the page beats describing it.
169
+ // quietly stops. Better to fail now with the actual cause. The vendor is
170
+ // named rather than assumed: this used to say "Google" on the grounds that
171
+ // Google was the only provider redeeming a code here, and it is not any
172
+ // more.
133
173
  throw new OAuthError(
134
- 'Google returned no refresh token. This usually means the account was already authorised ' +
135
- 'for this app; revoke it at https://myaccount.google.com/permissions and try again.',
174
+ `${refreshToken.vendor} returned no refresh token. This usually means the account was ` +
175
+ 'already authorised for this app; revoke it' +
176
+ (refreshToken.revokeUrl ? ` at ${refreshToken.revokeUrl}` : '') +
177
+ ' and try again.',
136
178
  );
137
179
  }
138
180
 
139
181
  return {
140
- refreshToken: body.refresh_token,
182
+ ...(body.refresh_token ? { refreshToken: body.refresh_token } : {}),
141
183
  accessToken: body.access_token!,
142
- expiresIn: body.expires_in ?? 3600,
184
+ ...(body.expires_in !== undefined ? { expiresIn: body.expires_in } : {}),
143
185
  scope: body.scope ?? scopes.join(' '),
144
186
  ...(body.id_token ? { idToken: body.id_token } : {}),
145
187
  };