@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.
- package/README.md +21 -8
- package/instructions/skills/lanes-link/SKILL.md +42 -14
- package/package.json +1 -1
- package/src/cli/argv.ts +50 -0
- package/src/cli/brand.ts +178 -0
- package/src/cli/callback-page.ts +108 -128
- package/src/cli/commands/connect/accounts.ts +5 -0
- package/src/cli/commands/connect/assertion.ts +187 -0
- package/src/cli/commands/connect/authorise.ts +61 -17
- package/src/cli/commands/connect/client.ts +37 -9
- package/src/cli/commands/connect/discover.ts +94 -0
- package/src/cli/commands/connect/family.ts +72 -0
- package/src/cli/commands/connect/index.ts +113 -115
- package/src/cli/commands/connect/method.ts +237 -0
- package/src/cli/commands/connect/outcome.ts +42 -1
- package/src/cli/commands/connect/pasted-token.ts +66 -0
- package/src/cli/commands/connect/requirements.ts +60 -8
- package/src/cli/commands/connect/setup.ts +16 -5
- package/src/cli/commands/connect/target-note.ts +34 -0
- package/src/cli/commands/identity.ts +258 -0
- package/src/cli/commands/knowledge/index.ts +390 -0
- package/src/cli/commands/knowledge/migrate.ts +180 -0
- package/src/cli/commands/knowledge/setup.ts +144 -0
- package/src/cli/commands/knowledge.ts +10 -0
- package/src/cli/commands/mcp/harnesses.ts +16 -2
- package/src/cli/commands/mcp/register.ts +9 -1
- package/src/cli/commands/mcp/stdio.ts +21 -0
- package/src/cli/commands/operate/dashboard.ts +107 -0
- package/src/cli/commands/operate/findings.ts +151 -0
- package/src/cli/commands/operate/inspect.ts +56 -158
- package/src/cli/commands/operate/outputs.ts +38 -11
- package/src/cli/commands/operate/serve.ts +3 -0
- package/src/cli/commands/operate/token.ts +1 -1
- package/src/cli/commands/operate.ts +2 -0
- package/src/cli/commands/profile/declare.ts +154 -0
- package/src/cli/commands/profile/removal.ts +17 -0
- package/src/cli/commands/profile.ts +83 -35
- package/src/cli/commands/setup.ts +22 -6
- package/src/cli/commands/target.ts +65 -83
- package/src/cli/config-edit.ts +48 -144
- package/src/cli/config-repair.ts +186 -0
- package/src/cli/dashboard-page.ts +284 -0
- package/src/cli/dashboard-shell.ts +125 -0
- package/src/cli/identity.ts +12 -1
- package/src/cli/main.ts +68 -4
- package/src/cli/oauth-callback.ts +187 -0
- package/src/cli/oauth-exchange.ts +57 -15
- package/src/cli/oauth.ts +67 -177
- package/src/cli/output.ts +21 -5
- package/src/cli/provider-marks.ts +45 -0
- package/src/cli/runtime/open.ts +67 -49
- package/src/cli/runtime/registry.ts +60 -2
- package/src/cli/runtime/select.ts +26 -13
- package/src/cli/runtime/vault.ts +61 -0
- package/src/cli/runtime.ts +2 -1
- package/src/cli/selection.ts +357 -0
- package/src/cli/usage.ts +32 -9
- package/src/connectivity/auth/README.md +7 -1
- package/src/connectivity/auth/basic/index.ts +1 -1
- package/src/connectivity/auth/index.ts +14 -0
- package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
- package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
- package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
- package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
- package/src/connectivity/auth/oauth-jwt/README.md +33 -0
- package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
- package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
- package/src/connectivity/auth/resolve.ts +1 -1
- package/src/connectivity/auth/token.ts +11 -0
- package/src/connectivity/index.ts +2 -0
- package/src/connectivity/manifest/auth.ts +99 -2
- package/src/connectivity/manifest/identity.ts +12 -0
- package/src/connectivity/manifest/index.ts +3 -1
- package/src/connectivity/manifest/provider.ts +37 -8
- package/src/connectivity/manifest/requirements.ts +109 -6
- package/src/deployments/adapters/filesystem.ts +10 -1
- package/src/deployments/adapters/github-api.ts +106 -0
- package/src/deployments/adapters/github-commit.ts +103 -0
- package/src/deployments/adapters/github-repo.ts +356 -0
- package/src/deployments/adapters/github-testing.ts +258 -0
- package/src/deployments/adapters/github.ts +125 -0
- package/src/deployments/deploy.ts +48 -19
- package/src/deployments/driver.ts +8 -1
- package/src/deployments/gcp/driver.ts +3 -1
- package/src/deployments/knowledge.ts +119 -0
- package/src/deployments/prepare.ts +2 -2
- package/src/deployments/servable.ts +81 -0
- package/src/deployments/target.ts +3 -2
- package/src/deployments/upload.ts +2 -1
- package/src/dispatch/dispatch.ts +1 -1
- package/src/profile/identity.ts +60 -0
- package/src/profile/index.ts +15 -5
- package/src/profile/knowledge.ts +124 -0
- package/src/profile/load.ts +17 -5
- package/src/profile/primitives.ts +24 -1
- package/src/profile/schema.ts +46 -3
- package/src/profile/targets.ts +74 -114
- package/src/profile/workspace.ts +71 -84
- package/src/providers/google/calendar/index.ts +2 -0
- package/src/providers/google/contacts/index.ts +2 -0
- package/src/providers/google/docs/index.ts +2 -0
- package/src/providers/google/drive/index.ts +2 -0
- package/src/providers/google/gmail/index.ts +2 -0
- package/src/providers/google/gmail-imap/index.ts +125 -0
- package/src/providers/google/index.ts +2 -1
- package/src/providers/google/shared/oauth.ts +18 -6
- package/src/providers/google/shared/service-account.ts +110 -0
- package/src/providers/google/shared/setup.ts +5 -2
- package/src/providers/google/sheets/index.ts +2 -0
- package/src/providers/google/tasks/index.ts +2 -0
- package/src/providers/identity/provider.ts +166 -0
- package/src/providers/index.ts +3 -0
- package/src/providers/owner.ts +10 -2
- package/src/providers/scopes.ts +2 -0
- package/src/providers/setup/plan.ts +31 -9
- package/src/providers/setup/provider.ts +23 -0
- package/src/providers/slack/index.ts +81 -33
- package/src/providers/slack/oauth.ts +103 -0
- package/src/providers/slack/scopes.ts +37 -0
- package/src/server/container.ts +18 -1
- package/src/server/cors.ts +252 -0
- package/src/server/dashboard.ts +208 -0
- package/src/server/endpoint.ts +45 -1
- package/src/server/generations.ts +11 -2
- package/src/server/harness.ts +7 -0
- package/src/server/index.ts +45 -6
- package/src/server/mcp/index.ts +1 -0
- package/src/server/mcp/instructions.ts +28 -1
- package/src/server/mcp/visibility.ts +33 -0
- package/src/stores/blobs/route.ts +123 -0
package/src/cli/callback-page.ts
CHANGED
|
@@ -1,32 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The two pages a browser reaches on its way through an authorization.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
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
|
-
* - **
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* a
|
|
17
|
-
*
|
|
18
|
-
* - **
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
*
|
|
26
|
-
*
|
|
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
|
-
*
|
|
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, '<')
|
|
55
|
-
.replace(/>/g, '>')
|
|
56
|
-
.replace(/"/g, '"')
|
|
57
|
-
.replace(/'/g, ''');
|
|
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
|
-
|
|
68
|
-
|
|
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 {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
.
|
|
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
|
-
*
|
|
99
|
-
* the
|
|
100
|
-
*
|
|
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
|
-
*
|
|
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="
|
|
122
|
+
<p class="small"><code>lanes link outputs --show --target ${escapeHtml(page.target)}</code></p>`;
|
|
162
123
|
|
|
163
|
-
return
|
|
124
|
+
return shell(body, 'Authorise', page.retry ? 401 : 200, '', SUBMIT_SPINNER);
|
|
164
125
|
}
|
|
165
126
|
|
|
166
|
-
|
|
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
|
-
|
|
174
|
-
<link href="https://fonts.googleapis.com/css2?family=Lora:wght@400;500&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
|
-
|
|
194
|
-
|
|
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:
|
|
212
|
-
border-radius:
|
|
213
|
-
.field:focus { outline: none; border-color:
|
|
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:
|
|
216
|
-
border-radius:
|
|
217
|
-
.go:hover {
|
|
218
|
-
.small { margin-top:
|
|
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:
|
|
221
|
-
|
|
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 =
|
|
224
|
+
const title = label ?? heading;
|
|
230
225
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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,
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
/**
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 === '
|
|
242
|
-
...(client.kind === '
|
|
243
|
-
? {
|
|
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
|
-
|
|
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
|
-
|
|
289
|
-
|
|
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 } : {}),
|