@lanes-sh/link 0.2.2 → 0.3.0

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 (130) hide show
  1. package/README.md +21 -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 +56 -158
  31. package/src/cli/commands/operate/outputs.ts +38 -11
  32. package/src/cli/commands/operate/serve.ts +3 -0
  33. package/src/cli/commands/operate/token.ts +1 -1
  34. package/src/cli/commands/operate.ts +2 -0
  35. package/src/cli/commands/profile/declare.ts +154 -0
  36. package/src/cli/commands/profile/removal.ts +17 -0
  37. package/src/cli/commands/profile.ts +83 -35
  38. package/src/cli/commands/setup.ts +22 -6
  39. package/src/cli/commands/target.ts +65 -83
  40. package/src/cli/config-edit.ts +48 -144
  41. package/src/cli/config-repair.ts +186 -0
  42. package/src/cli/dashboard-page.ts +284 -0
  43. package/src/cli/dashboard-shell.ts +125 -0
  44. package/src/cli/identity.ts +12 -1
  45. package/src/cli/main.ts +68 -4
  46. package/src/cli/oauth-callback.ts +187 -0
  47. package/src/cli/oauth-exchange.ts +57 -15
  48. package/src/cli/oauth.ts +67 -177
  49. package/src/cli/output.ts +21 -5
  50. package/src/cli/provider-marks.ts +45 -0
  51. package/src/cli/runtime/open.ts +67 -49
  52. package/src/cli/runtime/registry.ts +60 -2
  53. package/src/cli/runtime/select.ts +26 -13
  54. package/src/cli/runtime/vault.ts +61 -0
  55. package/src/cli/runtime.ts +2 -1
  56. package/src/cli/selection.ts +357 -0
  57. package/src/cli/usage.ts +32 -9
  58. package/src/connectivity/auth/README.md +7 -1
  59. package/src/connectivity/auth/basic/index.ts +1 -1
  60. package/src/connectivity/auth/index.ts +14 -0
  61. package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
  62. package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
  63. package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
  64. package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
  65. package/src/connectivity/auth/oauth-jwt/README.md +33 -0
  66. package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
  67. package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
  68. package/src/connectivity/auth/resolve.ts +1 -1
  69. package/src/connectivity/auth/token.ts +11 -0
  70. package/src/connectivity/index.ts +2 -0
  71. package/src/connectivity/manifest/auth.ts +99 -2
  72. package/src/connectivity/manifest/identity.ts +12 -0
  73. package/src/connectivity/manifest/index.ts +3 -1
  74. package/src/connectivity/manifest/provider.ts +37 -8
  75. package/src/connectivity/manifest/requirements.ts +109 -6
  76. package/src/deployments/adapters/filesystem.ts +10 -1
  77. package/src/deployments/adapters/github-api.ts +106 -0
  78. package/src/deployments/adapters/github-commit.ts +103 -0
  79. package/src/deployments/adapters/github-repo.ts +356 -0
  80. package/src/deployments/adapters/github-testing.ts +258 -0
  81. package/src/deployments/adapters/github.ts +125 -0
  82. package/src/deployments/deploy.ts +48 -19
  83. package/src/deployments/driver.ts +8 -1
  84. package/src/deployments/gcp/driver.ts +3 -1
  85. package/src/deployments/knowledge.ts +119 -0
  86. package/src/deployments/prepare.ts +2 -2
  87. package/src/deployments/servable.ts +81 -0
  88. package/src/deployments/target.ts +3 -2
  89. package/src/deployments/upload.ts +2 -1
  90. package/src/dispatch/dispatch.ts +1 -1
  91. package/src/profile/identity.ts +60 -0
  92. package/src/profile/index.ts +15 -5
  93. package/src/profile/knowledge.ts +124 -0
  94. package/src/profile/load.ts +17 -5
  95. package/src/profile/primitives.ts +24 -1
  96. package/src/profile/schema.ts +46 -3
  97. package/src/profile/targets.ts +74 -114
  98. package/src/profile/workspace.ts +71 -84
  99. package/src/providers/google/calendar/index.ts +2 -0
  100. package/src/providers/google/contacts/index.ts +2 -0
  101. package/src/providers/google/docs/index.ts +2 -0
  102. package/src/providers/google/drive/index.ts +2 -0
  103. package/src/providers/google/gmail/index.ts +2 -0
  104. package/src/providers/google/gmail-imap/index.ts +125 -0
  105. package/src/providers/google/index.ts +2 -1
  106. package/src/providers/google/shared/oauth.ts +18 -6
  107. package/src/providers/google/shared/service-account.ts +110 -0
  108. package/src/providers/google/shared/setup.ts +5 -2
  109. package/src/providers/google/sheets/index.ts +2 -0
  110. package/src/providers/google/tasks/index.ts +2 -0
  111. package/src/providers/identity/provider.ts +166 -0
  112. package/src/providers/index.ts +3 -0
  113. package/src/providers/owner.ts +10 -2
  114. package/src/providers/scopes.ts +2 -0
  115. package/src/providers/setup/plan.ts +31 -9
  116. package/src/providers/setup/provider.ts +23 -0
  117. package/src/providers/slack/index.ts +81 -33
  118. package/src/providers/slack/oauth.ts +103 -0
  119. package/src/providers/slack/scopes.ts +37 -0
  120. package/src/server/container.ts +18 -1
  121. package/src/server/cors.ts +252 -0
  122. package/src/server/dashboard.ts +208 -0
  123. package/src/server/endpoint.ts +45 -1
  124. package/src/server/generations.ts +11 -2
  125. package/src/server/harness.ts +7 -0
  126. package/src/server/index.ts +45 -6
  127. package/src/server/mcp/index.ts +1 -0
  128. package/src/server/mcp/instructions.ts +28 -1
  129. package/src/server/mcp/visibility.ts +33 -0
  130. package/src/stores/blobs/route.ts +123 -0
@@ -1,32 +1,33 @@
1
1
  /**
2
- * The page the browser lands on when a connect flow ends.
2
+ * The two pages a browser reaches on its way through an authorization.
3
3
  *
4
- * It is the only HTML this repository serves to a human, and for a few seconds
5
- * it is the whole product so it is the product's own page rather than a
6
- * bespoke one. The design is lifted from the pages the Lanes API serves after
7
- * someone authorises something (the workspace invite and the email
8
- * verification): an outline-only card, a Lora heading, native text softened
9
- * with opacity, and the shared one-row footer.
4
+ * `approvalPage` is the consent screen a remote MCP client stops at;
5
+ * `completionPage` is where a provider connect flow lands. For a few seconds
6
+ * each of them is the whole product, so both are the product's own page: the
7
+ * dashed card, the Lora heading, and the shared one-row footer that the Lanes
8
+ * API serves after a workspace invite or an email verification.
10
9
  *
11
- * Two decisions are worth keeping:
10
+ * Everything visual now comes from `brand.ts`. Two decisions this file used to
11
+ * record have been settled the other way by the design system, and are worth
12
+ * naming because both look like regressions otherwise:
12
13
  *
13
- * - **Nothing is painted.** `color-scheme: light dark` plus a transparent
14
- * background means the browser supplies its own canvas, so the page is
15
- * correct in either mode without a theme switch, a media query per colour, or
16
- * a preference to read. It replaced a hardcoded `#0d1117`, which was a black
17
- * rectangle on a light machine.
18
- * - **Colour is reserved for status.** Not for emphasis, and not for the
19
- * heading a green *word* said nothing the word "Connected" did not, and
20
- * that is why it went. A check is different: it is the glyph the Lanes app
21
- * already puts against a connected integration, so a reader arriving from the
22
- * app meets the mark they left it with. Both outcomes carry one now, the
23
- * check on success and the red heading on failure, and nothing else does.
14
+ * - **The canvas is painted.** This used to be transparent under
15
+ * `color-scheme: light dark`, so the browser supplied its own a fix for a
16
+ * hardcoded `#0d1117` that read as a black rectangle on a light machine. The
17
+ * system names a background per mode, so the fix is now to paint the right
18
+ * one. `color-scheme` stays, for form controls and scrollbars.
19
+ * - **The success mark is gold, not green.** It was lucide's `check` at
20
+ * `#059669`, chosen to match the tick the Lanes app puts against a connected
21
+ * integration. The design system is explicit that gold is the only accent and
22
+ * "gold for positive, neutral tokens otherwise", so the green was the odd one
23
+ * out rather than the match it was meant to be.
24
24
  *
25
- * Lora is fetched from Google Fonts, as the API pages do; the fallback stack
26
- * carries the page on a machine with no network, which is the state a failed
27
- * authorisation is sometimes in.
25
+ * What has not changed is that colour is reserved for status and spent nowhere
26
+ * else not on emphasis, and not on the heading.
28
27
  */
29
28
 
29
+ import { escapeHtml, FONTS, FOOTER, PAGE_CSP, PAGE_HEADERS, TOKENS } from './brand.ts';
30
+
30
31
  export interface CallbackPage {
31
32
  /** The focal line, set in Lora. A provider name, or the outcome itself. */
32
33
  readonly heading: string;
@@ -38,69 +39,29 @@ export interface CallbackPage {
38
39
  }
39
40
 
40
41
  /**
41
- * Escape text for an HTML body.
42
- *
43
- * `escapeXml` in `connectivity/transports/dav/xml.ts` would do, and `cli` is
44
- * allowed to import `connectivity` — but a page renderer reaching into a DAV
45
- * transport for a string function is a dependency nobody would defend. Five
46
- * lines is cheaper than the coupling.
47
- *
48
- * This is load-bearing: `heading` carries a provider's `manifest.name`, and a
49
- * custom provider supplies that from a YAML file of its own.
50
- */
51
- function escapeHtml(value: string): string {
52
- return value
53
- .replace(/&/g, '&')
54
- .replace(/</g, '&lt;')
55
- .replace(/>/g, '&gt;')
56
- .replace(/"/g, '&quot;')
57
- .replace(/'/g, '&#39;');
58
- }
59
-
60
- /**
61
- * Harmonised with the Lanes API's public pages, which are themselves harmonised
62
- * with the transactional emails — same dashed card, same Lora, same footer, so
63
- * the end of a connect flow reads as the same product as the invite that
64
- * preceded it.
42
+ * The card, centred. Everything else is `brand.ts`.
65
43
  */
66
44
  const STYLE = `
67
- :root { color-scheme: light dark; }
68
- * { box-sizing: border-box; }
69
- body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
70
- display: flex; align-items: center; justify-content: center;
71
- min-height: 100vh; margin: 0; background: transparent; padding: 24px; }
45
+ body { display: flex; align-items: center; justify-content: center;
46
+ min-height: 100vh; padding: 24px; }
72
47
  .wrap { width: 100%; max-width: 460px; text-align: center; }
73
- .card { background: transparent; border: 1px dashed rgba(120,113,108,0.35); border-radius: 10px;
74
- padding: 48px 36px; }
75
- .label { margin: 0 0 6px; font-size: 15px; font-weight: 500; letter-spacing: 0.01em; opacity: 0.6; }
76
- h1 { font-family: "Lora", Georgia, "Times New Roman", serif; font-size: 28px; font-weight: 500;
77
- letter-spacing: -0.01em; line-height: 1.3; margin: 0 0 18px; }
78
- .detail { margin: 0; font-size: 15px; line-height: 1.6; opacity: 0.75; }
79
- .icon { display: block; margin: 0 auto 18px; color: #059669; }
80
- .footer { margin: 24px 0 0; }
81
- .footer p { margin: 0; font-size: 12px; line-height: 1.5; opacity: 0.5; }
82
- .footer a { color: inherit; text-decoration: underline; }
83
- .err h1 { color: #A06060; }
84
- @media (prefers-color-scheme: dark) {
85
- .err h1 { color: #C08080; }
86
- .icon { color: #34D399; }
87
- }
48
+ .card { padding: 48px 36px; }
49
+ .label { margin: 0 0 6px; font-size: 15px; font-weight: 500; color: var(--muted-foreground); }
50
+ h1 { margin: 0 0 18px; }
51
+ .detail { margin: 0; font-size: 15px; line-height: 1.6; color: var(--muted-foreground); }
52
+ .icon { display: block; margin: 0 auto 18px; color: var(--accent-gold); }
53
+ .err h1 { color: var(--destructive); }
88
54
  `.trim();
89
55
 
90
- /** The one-row footer below the card, identical to the one the API pages carry. */
91
- const FOOTER =
92
- '<div class="footer"><p>Your unfair advantage in parallel AI coding. ' +
93
- '<a href="https://lanes.sh/">lanes.sh</a></p></div>';
94
-
95
56
  /**
96
57
  * The success mark, above the label.
97
58
  *
98
- * The glyph and the emerald are the Lanes app's, so a connected provider looks
99
- * the same wherever it is reported lucide's `check` at #059669, lightened to
100
- * #34D399 on a dark canvas because the darker green does not survive it.
59
+ * lucide's `check`, in `--accent-gold` which is what "gold for positive"
60
+ * means when the positive thing is a connection that now works. It carries no
61
+ * colour of its own; `.icon` sets it, so it follows the token if the token moves.
101
62
  *
102
- * `aria-hidden` because the word underneath is the announcement; a screen
103
- * reader hearing "Connected" does not also need to hear about a tick.
63
+ * `aria-hidden` because the word underneath is the announcement; a screen reader
64
+ * hearing "Connected" does not also need to hear about a tick.
104
65
  */
105
66
  const ICON =
106
67
  '<svg class="icon" xmlns="http://www.w3.org/2000/svg" width="28" height="28" ' +
@@ -158,49 +119,70 @@ ${hidden}
158
119
  <input class="field" type="password" name="token" placeholder="Endpoint token" autocomplete="off" autofocus required>
159
120
  <button class="go" type="submit">Approve</button>
160
121
  </form>
161
- <p class="detail small">Printed by <code>lanes link outputs --show --target ${escapeHtml(page.target)}</code>.</p>`;
122
+ <p class="small"><code>lanes link outputs --show --target ${escapeHtml(page.target)}</code></p>`;
162
123
 
163
- return page.retry ? shell(body, 'Authorise', 401) : shell(body, 'Authorise', 200);
124
+ return shell(body, 'Authorise', page.retry ? 401 : 200, '', SUBMIT_SPINNER);
164
125
  }
165
126
 
166
- function shell(inner: string, title: string, status: number): Response {
127
+ /**
128
+ * What runs while the approval is in flight.
129
+ *
130
+ * Approving is a round trip to an authorization server, and until it returns the
131
+ * page looks exactly as it did before the click — so the honest reading is that
132
+ * nothing happened, and the second click is the one that produces a duplicated
133
+ * request. The button disables itself, which is the part that matters; the
134
+ * spinner is what says why.
135
+ *
136
+ * The cost is real and worth naming: this is the one page that asks for the
137
+ * endpoint token, and an inline listener means its policy admits inline script.
138
+ * It is the minimum that does the job — one listener, no interpolation, nothing
139
+ * read from the page — and the alternative, a static file, is an asset pipeline
140
+ * this repository does not have.
141
+ */
142
+ const SUBMIT_SPINNER = `
143
+ document.querySelector('form').addEventListener('submit', function (event) {
144
+ var button = event.currentTarget.querySelector('.go');
145
+ button.classList.add('busy');
146
+ button.disabled = true;
147
+ });
148
+ `.trim();
149
+
150
+ function shell(
151
+ inner: string,
152
+ title: string,
153
+ status: number,
154
+ cardClass = '',
155
+ script = '',
156
+ ): Response {
167
157
  return new Response(
168
158
  `<!doctype html>
169
159
  <html lang="en">
170
160
  <head>
171
161
  <meta charset="utf-8">
172
162
  <meta name="viewport" content="width=device-width, initial-scale=1">
173
- <link rel="preconnect" href="https://fonts.googleapis.com">
174
- <link href="https://fonts.googleapis.com/css2?family=Lora:wght@400;500&amp;display=swap" rel="stylesheet">
163
+ ${FONTS}
175
164
  <title>${escapeHtml(title)}</title>
176
165
  <style>
166
+ ${TOKENS}
177
167
  ${STYLE}
178
168
  ${FORM_STYLE}
179
169
  </style>
180
170
  </head>
181
171
  <body>
182
172
  <div class="wrap">
183
- <div class="card">
173
+ <div class="card surface${cardClass}">
184
174
  ${inner}
185
175
  </div>
186
176
  ${FOOTER}
187
177
  </div>
178
+ ${script ? `<script>\n${script}\n</script>` : ''}
188
179
  </body>
189
180
  </html>`,
190
181
  {
191
182
  status,
192
183
  headers: {
193
- 'content-type': 'text/html; charset=utf-8',
194
- // One of these pages asks for the endpoint token in a password field.
195
- // Framing it is the cheap half of a UI-redress attack, and nothing here
196
- // is ever meant to be embedded — so refuse it in both spellings, since
197
- // `frame-ancestors` is the one that is actually specified and
198
- // `X-Frame-Options` is the one older browsers obey.
199
- 'content-security-policy': "frame-ancestors 'none'",
200
- 'x-frame-options': 'DENY',
201
- // The URL carries a `client_id` and a `redirect_uri`; neither belongs in
202
- // a Referer sent to whatever the page links out to.
203
- 'referrer-policy': 'no-referrer',
184
+ ...PAGE_HEADERS,
185
+ ...(script ? { 'content-security-policy': `${PAGE_CSP}; script-src 'unsafe-inline'` } : {}),
204
186
  },
205
187
  },
206
188
  );
@@ -208,17 +190,30 @@ ${FOOTER}
208
190
 
209
191
  const FORM_STYLE = `
210
192
  .field { width: 100%; margin: 20px 0 12px; padding: 11px 13px; font: inherit; font-size: 15px;
211
- color: inherit; background: transparent; border: 1px solid rgba(120,113,108,0.45);
212
- border-radius: 7px; }
213
- .field:focus { outline: none; border-color: rgba(120,113,108,0.9); }
193
+ color: inherit; background: var(--background); border: 1px solid var(--border);
194
+ border-radius: 6px; }
195
+ .field:focus { outline: none; border-color: var(--accent-gold); }
214
196
  .go { width: 100%; padding: 11px 13px; font: inherit; font-size: 15px; font-weight: 500;
215
- color: inherit; background: transparent; border: 1px solid rgba(120,113,108,0.55);
216
- border-radius: 7px; cursor: pointer; }
217
- .go:hover { border-color: rgba(120,113,108,0.95); }
218
- .small { margin-top: 14px; font-size: 12px; opacity: 0.55; }
197
+ color: var(--foreground); background: transparent; border: 1px solid var(--border);
198
+ border-radius: 6px; cursor: pointer; }
199
+ .go:hover { background: var(--muted); }
200
+ .small { margin-top: 16px; font-size: 12px; color: var(--muted-foreground); opacity: 0.8; }
219
201
  .small code { font-size: 12px; }
220
- .err-text { color: #A06060; opacity: 1; }
221
- @media (prefers-color-scheme: dark) { .err-text { color: #C08080; } }
202
+ .err-text { color: var(--destructive); }
203
+
204
+ /* The button, mid-flight. The label goes transparent rather than away, so the
205
+ button keeps the width it had and the card does not reflow under the cursor. */
206
+ .go.busy { color: transparent; position: relative; pointer-events: none; }
207
+ .go.busy::after { content: ''; position: absolute; inset: 0; margin: auto;
208
+ width: 15px; height: 15px; border-radius: 50%;
209
+ border: 2px solid var(--border); border-top-color: var(--foreground);
210
+ animation: spin 0.6s linear infinite; }
211
+ @keyframes spin { to { transform: rotate(360deg); } }
212
+ /* Monochrome deliberately: gold says a thing turned out well, and a request in
213
+ flight has not turned out yet. */
214
+ @media (prefers-reduced-motion: reduce) {
215
+ .go.busy::after { animation-duration: 2.4s; }
216
+ }
222
217
  `.trim();
223
218
 
224
219
  export function completionPage(page: CallbackPage): Response {
@@ -226,31 +221,16 @@ export function completionPage(page: CallbackPage): Response {
226
221
 
227
222
  // The tab strip truncates to a few characters, and the word that has to
228
223
  // survive that is the outcome — not the name of what was connected.
229
- const title = escapeHtml(label ?? heading);
224
+ const title = label ?? heading;
230
225
 
231
- return new Response(
232
- `<!doctype html>
233
- <html lang="en">
234
- <head>
235
- <meta charset="utf-8">
236
- <meta name="viewport" content="width=device-width, initial-scale=1">
237
- <link rel="preconnect" href="https://fonts.googleapis.com">
238
- <link href="https://fonts.googleapis.com/css2?family=Lora:wght@400;500&amp;display=swap" rel="stylesheet">
239
- <title>${title}</title>
240
- <style>
241
- ${STYLE}
242
- </style>
243
- </head>
244
- <body>
245
- <div class="wrap">
246
- <div class="card${ok ? '' : ' err'}">
247
- ${ok ? `${ICON}\n` : ''}${label ? `<p class="label">${escapeHtml(label)}</p>\n` : ''}<h1>${escapeHtml(heading)}</h1>
248
- <p class="detail">${escapeHtml(detail)}</p>
249
- </div>
250
- ${FOOTER}
251
- </div>
252
- </body>
253
- </html>`,
254
- { status: ok ? 200 : 400, headers: { 'content-type': 'text/html; charset=utf-8' } },
255
- );
226
+ const body =
227
+ `${ok ? `${ICON}\n` : ''}` +
228
+ `${label ? `<p class="label">${escapeHtml(label)}</p>\n` : ''}` +
229
+ `<h1>${escapeHtml(heading)}</h1>` +
230
+ `<p class="detail">${escapeHtml(detail)}</p>`;
231
+
232
+ // Through the same shell as the consent screen, rather than a second copy of
233
+ // the document. They were already meant to be one page; only the error class
234
+ // on the card differs.
235
+ return shell(body, title, ok ? 200 : 400, ok ? '' : ' err');
256
236
  }
@@ -92,3 +92,8 @@ export async function moveCredential(
92
92
  await credentials.set(to, value);
93
93
  await credentials.delete(from);
94
94
  }
95
+
96
+ /** What a spec that named an account rather than a provider turned out to mean. */
97
+ export function familyNote(providerId: string, family: readonly string[]): string {
98
+ return `${providerId} is ${family.length} services on one account: ${family.join(', ')}`;
99
+ }
@@ -0,0 +1,187 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { isAbsolute, resolve } from 'node:path';
4
+ import { ASSERTION_GRANT, parseAssertionKey } from '#connectivity/auth/index.ts';
5
+ import { credentialRefForConnection, type AuthAssertion, type ProviderManifest } from '#connectivity';
6
+ import type { SecretStore } from '#secrets';
7
+ import { ok, progress, style } from '../../output.ts';
8
+ import { terminalPrompter, type Prompter } from '../../prompt.ts';
9
+ import { askForSetup, printSetup } from './setup.ts';
10
+
11
+ /**
12
+ * The counterpart to `authorise.ts`, for a provider authenticated by a key.
13
+ *
14
+ * The same conversation, minus the browser: show the console work, take what it
15
+ * produced, put it in the store. What differs is that there are two halves and
16
+ * they have different lifetimes — the key is one file covering every provider
17
+ * of a vendor, and the account it acts as is per connection. So the key is
18
+ * asked for once per profile and the subject once per account, and re-running
19
+ * `connect` for a second provider asks for neither.
20
+ */
21
+
22
+ /**
23
+ * Take a path, or the file's contents.
24
+ *
25
+ * A key arrives as a downloaded file, and a downloaded file is a path — asking
26
+ * someone to open it and paste several hundred characters of PEM into a
27
+ * terminal is asking for a truncated key and an error two steps later. Pasting
28
+ * still works for anyone who would rather, which is why this looks at the shape
29
+ * of the answer rather than at a flag.
30
+ *
31
+ * What is *stored* is always the contents. A path is a fact about one machine
32
+ * and this credential outlives it: the same profile is read by a deployed
33
+ * revision that has no such file, and a store holding a path would fail there
34
+ * with an error about the filesystem rather than about the credential.
35
+ */
36
+ async function contentsOf(answer: string): Promise<string> {
37
+ if (answer.startsWith('{')) return answer;
38
+
39
+ const expanded = answer.startsWith('~/') ? resolve(homedir(), answer.slice(2)) : answer;
40
+ const path = isAbsolute(expanded) ? expanded : resolve(process.cwd(), expanded);
41
+
42
+ try {
43
+ return await readFile(path, 'utf8');
44
+ } catch {
45
+ throw new Error(
46
+ `No file at ${path}. Give the path to the key the console downloaded, or paste its contents.`,
47
+ );
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Ensure the profile holds the key, and this connection holds a pointer to it.
53
+ *
54
+ * Idempotent on the key for the same reason `ensureStaticCredential` is: seven
55
+ * providers share one file, and the second `connect` must find it already there
56
+ * and say so rather than asking again. The subject is not idempotent in the
57
+ * same way — it is per connection, and every connection is a first one.
58
+ */
59
+ export async function authoriseWithKey(input: {
60
+ readonly manifest: ProviderManifest;
61
+ readonly assertion: AuthAssertion;
62
+ readonly connectionId: string;
63
+ readonly credentials: SecretStore;
64
+ readonly changes: string[];
65
+ /** The operator asked to be asked again — a rotated key, or the wrong account. */
66
+ readonly replace: boolean;
67
+ readonly prompter?: Prompter;
68
+ }): Promise<void> {
69
+ const { manifest, assertion, connectionId, credentials, changes, replace } = input;
70
+ const prompter = input.prompter ?? terminalPrompter;
71
+
72
+ const keyPrompt = assertion.setup.prompts.find((prompt) => prompt.scope === 'shared');
73
+ if (!keyPrompt) {
74
+ throw new Error(
75
+ `Provider "${manifest.id}" declares auth.assertion with no shared prompt, so there is no key to ask for.`,
76
+ );
77
+ }
78
+
79
+ const stored = await credentials.has(assertion.key_ref);
80
+
81
+ // `stored && !replace`, and deliberately not `reuseStoredCredential` — which
82
+ // also asks whether the connection id is still provisional. That question is
83
+ // right for a per-connection credential, where a provisional id means an
84
+ // earlier connect stored something no server ever accepted. It is wrong here:
85
+ // this key is shared across the whole profile, so a *first* connect of a
86
+ // second provider is provisional by definition while the key it finds was
87
+ // stored deliberately and may already be in use. Asking again there made a
88
+ // stored key unusable — the preflight said it had everything it needed, and
89
+ // the run that followed immediately asked for it.
90
+ if (stored && !replace) {
91
+ // The walkthrough still prints. The key is shared and already held, but the
92
+ // *sharing* is per resource and per product: whoever connects Sheets after
93
+ // Drive has a key that works and a spreadsheet nobody has shared with it
94
+ // yet, and that failure looks exactly like a broken credential.
95
+ printSetup(
96
+ manifest,
97
+ `The key is already stored at ${assertion.key_ref}, so it is not asked for again — but what ` +
98
+ 'it can reach is granted per resource, and this is the first connection of this one.',
99
+ assertion.setup,
100
+ );
101
+ progress(ok(`key already stored (${assertion.key_ref})`));
102
+ // Named because this is the only way past a key that is stored and wrong,
103
+ // and a well-formed key for the wrong project is refused by the token
104
+ // endpoint rather than here.
105
+ progress(style.dim(` To replace it: lanes link connect ${manifest.id} --replace`));
106
+ } else {
107
+ if (stored) {
108
+ progress(
109
+ style.dim(
110
+ `Replacing ${assertion.key_ref} — what is stored is overwritten only once you have entered a new one.`,
111
+ ),
112
+ );
113
+ }
114
+
115
+ const answers = await askForSetup(
116
+ manifest,
117
+ [keyPrompt],
118
+ `Stored at ${assertion.key_ref}, in the credential store — never in config.`,
119
+ prompter,
120
+ assertion.setup,
121
+ );
122
+
123
+ const contents = await contentsOf(answers.get(keyPrompt.key)!);
124
+
125
+ // Parsed before it is written, so the wrong file is caught here rather than
126
+ // at the token endpoint. The two candidates live on adjacent pages of the
127
+ // same console and `parseAssertionKey` knows how to tell them apart.
128
+ const key = parseAssertionKey(contents, assertion.key_ref);
129
+
130
+ await credentials.set(assertion.key_ref, contents);
131
+ changes.push(`${assertion.key_ref} stored`);
132
+ progress(ok(`key stored — it acts as ${key.client_email}`));
133
+ }
134
+
135
+ const subject = await askForSubject(assertion, prompter);
136
+
137
+ await credentials.set(
138
+ credentialRefForConnection(manifest, connectionId)!,
139
+ JSON.stringify({
140
+ grant: ASSERTION_GRANT,
141
+ key_ref: assertion.key_ref,
142
+ ...(subject ? { subject } : {}),
143
+ }),
144
+ );
145
+
146
+ progress(ok(subject ? `authenticated as ${subject}` : 'authenticated'));
147
+ }
148
+
149
+ /**
150
+ * Who the key acts as, where it acts as anyone.
151
+ *
152
+ * Blank is a real answer when delegation is `optional` — the key is then an
153
+ * identity in its own right. It is refused when delegation is `required`,
154
+ * because the alternative is a credential that authenticates perfectly and
155
+ * finds nothing: there is no mailbox or contact list belonging to a key, so
156
+ * every call would return an empty result rather than an error, which is the
157
+ * worst way for this to be wrong.
158
+ */
159
+ async function askForSubject(
160
+ assertion: AuthAssertion,
161
+ prompter: Prompter,
162
+ ): Promise<string | undefined> {
163
+ const optional = assertion.delegation === 'optional';
164
+
165
+ if (!prompter.interactive) {
166
+ if (optional) return undefined;
167
+ throw new Error(
168
+ `This provider can only reach an account by acting as someone, and this run is ` +
169
+ `non-interactive so there is nobody to ask who. Re-run in a terminal.`,
170
+ );
171
+ }
172
+
173
+ progress();
174
+ const answer = await prompter.ask(
175
+ ` ${assertion.subject_label}${optional ? style.dim(' [none]') : ''}`,
176
+ );
177
+
178
+ if (answer.length > 0) return answer;
179
+
180
+ if (optional) return undefined;
181
+
182
+ throw new Error(
183
+ `${assertion.subject_label} is required here: this provider has nothing that belongs to a ` +
184
+ 'key, so a connection that acts as nobody would authenticate and then find every ' +
185
+ 'mailbox, list and calendar empty.',
186
+ );
187
+ }
@@ -16,9 +16,14 @@ import { ensureOAuthApp } from './setup.ts';
16
16
  /**
17
17
  * Getting a token, and saying what it will be able to do first.
18
18
  *
19
- * Two paths, because two kinds of upstream: an MCP server publishes metadata
20
- * worth discovering and the SDK drives it, while a plain REST API announces
21
- * nothing and the manifest has to name its endpoints.
19
+ * Two paths, and what chooses between them is not the kind of upstream but
20
+ * whether the manifest names its own endpoints. Without them there is nothing
21
+ * to authorise against but what the server advertises, so the SDK discovers it
22
+ * and drives the flow. With them there is nothing left to discover, and the
23
+ * flow is ours — which is the only arrangement in which a client somebody else
24
+ * holds can redeem the code. A REST API never announces an authorization
25
+ * server, so it is always on the second path; an MCP server may be on either.
26
+ * See ADR-040.
22
27
  */
23
28
 
24
29
  /**
@@ -65,8 +70,8 @@ export async function authorise(input: {
65
70
  /** How the operator spelled the target, so a refusal names a command they typed. */
66
71
  target?: string;
67
72
  profile: string;
68
- /** `--own-client`: register a client rather than using the one a broker runs. */
69
- ownClient?: boolean;
73
+ /** Which OAuth client, when the operator chose one. `undefined` keeps today's precedence. */
74
+ client?: 'own' | 'hosted' | undefined;
70
75
  prompter?: Prompter;
71
76
  /** The operator has already said yes to scopes broader than the provider needs. */
72
77
  acceptBroadScopes?: boolean;
@@ -86,13 +91,24 @@ export async function authorise(input: {
86
91
  // announces. So the manifest names the endpoints and we run the flow
87
92
  // directly — the same loopback listener, PKCE, and exchange, minus the
88
93
  // discovery the SDK would otherwise do for us.
89
- if (manifest.connector.kind !== 'mcp') {
94
+ //
95
+ // An MCP connector takes that path too when it names both endpoints, and that
96
+ // is the *only* thing declaring them means. The SDK's flow ends by posting to
97
+ // the token endpoint with whatever `clientInformation()` returned, which is
98
+ // fine for a client the operator holds and impossible for one held by a
99
+ // broker — so a provider whose client lives somewhere else opts out here
100
+ // rather than discovering it after consent. Nothing is lost by opting out:
101
+ // discovery is all the SDK was doing that this does not, and a manifest that
102
+ // names its endpoints has nothing left to discover. Notion, Linear, and
103
+ // Google's two MCP servers name neither and are untouched. See ADR-040.
104
+ if (
105
+ manifest.connector.kind !== 'mcp' ||
106
+ (manifest.auth.authorize_url !== undefined && manifest.auth.token_url !== undefined)
107
+ ) {
90
108
  await authoriseDirect(input);
91
109
  return;
92
110
  }
93
111
 
94
- // An MCP provider is always bring-your-own: `defineProvider` refuses a broker
95
- // on one, because the SDK owns the exchange and there is no seam to route it.
96
112
  if (manifest.auth.registration === 'manual') {
97
113
  await ensureOAuthApp(input);
98
114
  }
@@ -187,7 +203,7 @@ async function authoriseDirect(input: {
187
203
  firstForProvider: boolean;
188
204
  target?: string;
189
205
  profile: string;
190
- ownClient?: boolean;
206
+ client?: 'own' | 'hosted' | undefined;
191
207
  prompter?: Prompter;
192
208
  acceptBroadScopes?: boolean;
193
209
  fetch?: typeof globalThis.fetch;
@@ -210,7 +226,7 @@ async function authoriseDirect(input: {
210
226
  document: input.document,
211
227
  changes: input.changes,
212
228
  firstForProvider: input.firstForProvider,
213
- ownClient: input.ownClient === true,
229
+ client: input.client,
214
230
  target: input.target ?? manifest.id,
215
231
  profile: input.profile,
216
232
  ...(input.prompter ? { prompter: input.prompter } : {}),
@@ -234,19 +250,39 @@ async function authoriseDirect(input: {
234
250
  throw new Error('Cancelled — nothing was authorised.');
235
251
  }
236
252
 
253
+ // What a response carrying no refresh token means, which only the manifest
254
+ // knows: Google omitting one is a failure worth stopping for, Slack omitting
255
+ // one is the ordinary success. See `RefreshTokenPolicy`.
256
+ const refreshToken = {
257
+ required: manifest.auth.refresh_token === 'required',
258
+ vendor: manifest.name,
259
+ ...(manifest.auth.revoke_url ? { revokeUrl: manifest.auth.revoke_url } : {}),
260
+ };
261
+
237
262
  let tokens;
238
263
  try {
239
264
  tokens = await runOAuthFlow({
240
265
  authorizeUrl,
241
- clientId: client.kind === 'own' ? client.clientId : client.config.clientId,
242
- ...(client.kind === 'own'
243
- ? { tokenUrl, clientSecret: client.clientSecret }
244
- : {
266
+ clientId: client.kind === 'brokered' ? client.config.clientId : client.clientId,
267
+ ...(client.kind === 'brokered'
268
+ ? {
245
269
  exchange: brokerExchangeVia({
246
270
  url: client.url,
271
+ refreshToken,
247
272
  ...(input.fetch ? { fetch: input.fetch } : {}),
248
273
  }),
274
+ }
275
+ : {
276
+ tokenUrl,
277
+ clientSecret: client.clientSecret,
249
278
  }),
279
+ refreshToken,
280
+ // Only where the broker published one, which means only where the vendor
281
+ // refuses a loopback redirect. The broker owns the URL because the
282
+ // correct value depends on which deployment answered `/config`.
283
+ ...(client.kind === 'brokered' && client.config.redirectUri
284
+ ? { relayRedirect: client.config.redirectUri }
285
+ : {}),
250
286
  scopes,
251
287
  connectionLabel: manifest.name,
252
288
  ...(manifest.auth.authorize_params
@@ -283,10 +319,18 @@ async function authoriseDirect(input: {
283
319
  `${manifest.id}/${connectionId}`,
284
320
  JSON.stringify({
285
321
  access_token: tokens.accessToken,
286
- refresh_token: tokens.refreshToken,
322
+ // Both omitted rather than defaulted where the vendor issues neither.
323
+ //
324
+ // A long-lived token has no refresh token and states no lifetime, and
325
+ // inventing an hour for it would have `doctor` reporting a healthy
326
+ // connection as stale forever while telling the operator to re-run a
327
+ // command that changes nothing. Absent is what `upstreamAccessToken`
328
+ // already reads as "hand back what is stored", which is correct here.
329
+ ...(tokens.refreshToken ? { refresh_token: tokens.refreshToken } : {}),
287
330
  token_type: 'Bearer',
288
- expires_in: tokens.expiresIn,
289
- expires_at: Date.now() + tokens.expiresIn * 1000,
331
+ ...(tokens.expiresIn !== undefined
332
+ ? { expires_in: tokens.expiresIn, expires_at: Date.now() + tokens.expiresIn * 1000 }
333
+ : {}),
290
334
  scope: tokens.scope,
291
335
  issuer: new URL(authorizeUrl).origin,
292
336
  ...(tokens.idToken ? { id_token: tokens.idToken } : {}),