@lanes-sh/link 0.6.8 → 0.6.10
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/.gcloudignore +50 -0
- package/README.md +5 -0
- package/package.json +4 -3
- package/src/auth/oidc.ts +65 -12
- package/src/cli/brand.ts +16 -9
- package/src/cli/commands/operate/auth.ts +333 -0
- package/src/cli/commands/operate/desktop.ts +220 -0
- package/src/cli/commands/operate/findings.ts +9 -38
- package/src/cli/commands/operate/inspect.ts +59 -40
- package/src/cli/commands/operate/serve.ts +0 -3
- package/src/cli/commands/operate.ts +5 -3
- package/src/cli/main.ts +19 -4
- package/src/cli/selection.ts +15 -4
- package/src/cli/usage.ts +5 -1
- package/src/connectivity/auth/index.ts +1 -0
- package/src/connectivity/auth/oauth-authcode/provider.ts +17 -1
- package/src/connectivity/auth/oauth-authcode/refresh.ts +43 -11
- package/src/connectivity/auth/oauth-jwt/index.ts +12 -1
- package/src/connectivity/auth/reauth.ts +48 -0
- package/src/deployments/gcp/Dockerfile +27 -2
- package/src/deployments/gcp/bucket.ts +82 -9
- package/src/deployments/gcp/driver.ts +43 -5
- package/src/deployments/gcp/lifecycle.json +12 -0
- package/src/deployments/gcp/survey.ts +12 -1
- package/src/policy/limits.ts +76 -3
- package/src/profile/index.ts +1 -0
- package/src/profile/legacy.ts +8 -3
- package/src/profile/schema.ts +65 -0
- package/src/server/cors.ts +3 -3
- package/src/server/edge.ts +188 -1
- package/src/server/endpoint.ts +0 -17
- package/src/server/harness.ts +10 -7
- package/src/server/index.ts +62 -90
- package/src/server/mcp/index.ts +0 -1
- package/src/server/mcp/visibility.ts +0 -33
- package/src/server/oauth.ts +21 -2
- package/src/cli/commands/operate/dashboard.ts +0 -107
- package/src/cli/dashboard-page.ts +0 -293
- package/src/cli/dashboard-shell.ts +0 -125
- package/src/cli/provider-marks.ts +0 -45
- package/src/server/dashboard.ts +0 -212
package/.gcloudignore
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# What `gcloud builds submit` uploads, and what it must not.
|
|
2
|
+
#
|
|
3
|
+
# **This file exists because `.dockerignore` is not consulted for the upload.**
|
|
4
|
+
# The build context is packed and sent to a Cloud Build staging bucket before any
|
|
5
|
+
# Dockerfile is read, so the first block of `.dockerignore` — which its own
|
|
6
|
+
# comment calls "a security control, not an image-size optimisation" — governs
|
|
7
|
+
# what reaches the *image* and says nothing about what reaches Google.
|
|
8
|
+
#
|
|
9
|
+
# Absent this file, gcloud derives its exclusions from `.gitignore` when the
|
|
10
|
+
# context happens to be a git checkout, and from nothing at all when it is not.
|
|
11
|
+
# `lanes link deploy` sends `installRoot`, which for the documented install
|
|
12
|
+
# method is a directory under `~/.bun` with no `.git` in it — so the safe
|
|
13
|
+
# behaviour was being inherited from a coincidence.
|
|
14
|
+
#
|
|
15
|
+
# Keep the first block in step with `.dockerignore`. `data/` holds the encrypted
|
|
16
|
+
# credential store *and* the key that opens it.
|
|
17
|
+
data/
|
|
18
|
+
*.key
|
|
19
|
+
*.pem
|
|
20
|
+
*.enc
|
|
21
|
+
*.p12
|
|
22
|
+
.env
|
|
23
|
+
.env.*
|
|
24
|
+
|
|
25
|
+
# Not needed to build, and `.git` in particular carries every branch.
|
|
26
|
+
.git/
|
|
27
|
+
.gitignore
|
|
28
|
+
.worktrees/
|
|
29
|
+
node_modules/
|
|
30
|
+
**/node_modules/
|
|
31
|
+
coverage/
|
|
32
|
+
dist/
|
|
33
|
+
build/
|
|
34
|
+
*.tsbuildinfo
|
|
35
|
+
|
|
36
|
+
# Tests, docs and tooling: the image runs the endpoint and nothing else.
|
|
37
|
+
**/*.test.ts
|
|
38
|
+
**/*.test.json
|
|
39
|
+
docs/
|
|
40
|
+
instructions/
|
|
41
|
+
**/README.md
|
|
42
|
+
.lanes/
|
|
43
|
+
.claude/
|
|
44
|
+
.vscode/
|
|
45
|
+
.idea/
|
|
46
|
+
.DS_Store
|
|
47
|
+
.playwright-mcp/
|
|
48
|
+
|
|
49
|
+
# The compiled binary from `bun build --compile`, if one was made locally.
|
|
50
|
+
/lanes
|
package/README.md
CHANGED
|
@@ -81,6 +81,11 @@ with Claude Code or Codex.
|
|
|
81
81
|
It runs the commands above rather than reimplementing them, so consent and the token stay here where
|
|
82
82
|
they belong, and an endpoint set up in the app is the same one you get from a shell. Available from
|
|
83
83
|
Lanes v0.47.0, as a research preview.
|
|
84
|
+
|
|
85
|
+
```console
|
|
86
|
+
$ lanes link desktop # opens the app on that page, installing it if it is not there
|
|
87
|
+
```
|
|
88
|
+
|
|
84
89
|
**[How to use it →](https://lanes.sh/docs/desktop/lanes-link)**
|
|
85
90
|
|
|
86
91
|
## What your agent gets
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lanes-sh/link",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.10",
|
|
4
4
|
"description": "A self-hostable MCP gateway for all your connections, memory, tasks, files, and secrets",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://lanes.sh/link",
|
|
@@ -44,13 +44,14 @@
|
|
|
44
44
|
"README.md",
|
|
45
45
|
"LICENSE",
|
|
46
46
|
"bunfig.toml",
|
|
47
|
-
".dockerignore"
|
|
47
|
+
".dockerignore",
|
|
48
|
+
".gcloudignore"
|
|
48
49
|
],
|
|
49
50
|
"scripts": {
|
|
50
51
|
"test": "bun test",
|
|
51
52
|
"typecheck": "tsc --noEmit",
|
|
52
53
|
"lanes": "bun run ./src/cli/lanes.ts",
|
|
53
|
-
"audit": "bun
|
|
54
|
+
"audit": "bun audit",
|
|
54
55
|
"vendor:bunq": "bun run ./src/providers/bunq/specs/vendor.ts",
|
|
55
56
|
"vendor:discord": "bun run ./src/providers/discord/specs/vendor.ts",
|
|
56
57
|
"vendor:google": "bun run ./src/providers/google/specs/vendor.ts"
|
package/src/auth/oidc.ts
CHANGED
|
@@ -108,8 +108,27 @@ export class OidcVerifier {
|
|
|
108
108
|
return verified;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
|
|
112
|
-
|
|
111
|
+
/**
|
|
112
|
+
* Where to ask about a token, and whether the operator chose it.
|
|
113
|
+
*
|
|
114
|
+
* The second half decides one thing: whether the non-standard GET shape below
|
|
115
|
+
* is attempted at all. A discovered endpoint is one the issuer publishes as
|
|
116
|
+
* RFC 7662, and RFC 7662 is a form POST — so an issuer that answers discovery
|
|
117
|
+
* and then needs its token in a query string is not a case that exists. The
|
|
118
|
+
* shape is for issuers that ship an equivalent without advertising one, and
|
|
119
|
+
* those have to be named in config regardless.
|
|
120
|
+
*/
|
|
121
|
+
async #endpoint(): Promise<{ url: string; explicit: boolean } | null> {
|
|
122
|
+
const named = this.#options.introspectionEndpoint;
|
|
123
|
+
if (named) {
|
|
124
|
+
if (!isHttps(named)) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`auth.authorization.introspection_endpoint is ${named}, which is not https. ` +
|
|
127
|
+
'A token is sent to it on every call.',
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return { url: named, explicit: true };
|
|
131
|
+
}
|
|
113
132
|
|
|
114
133
|
this.#discovered ??= this.#fetch(
|
|
115
134
|
`${this.#options.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`,
|
|
@@ -120,16 +139,33 @@ export class OidcVerifier {
|
|
|
120
139
|
|
|
121
140
|
const metadata = await this.#discovered;
|
|
122
141
|
const endpoint = metadata['introspection_endpoint'];
|
|
123
|
-
|
|
142
|
+
if (typeof endpoint !== 'string') return null;
|
|
143
|
+
|
|
144
|
+
// **A discovery document decides where a credential is sent, so what it
|
|
145
|
+
// names is checked rather than followed.** The issuer is config and the
|
|
146
|
+
// operator chose it; the endpoint inside its metadata is a value fetched
|
|
147
|
+
// over the network, and until now anything there — any host, any scheme —
|
|
148
|
+
// received this endpoint's tokens. Same origin as the issuer is what a
|
|
149
|
+
// conforming document says anyway.
|
|
150
|
+
if (!isHttps(endpoint) || !sameOrigin(endpoint, this.#options.issuer)) return null;
|
|
151
|
+
|
|
152
|
+
return { url: endpoint, explicit: false };
|
|
124
153
|
}
|
|
125
154
|
|
|
126
155
|
/**
|
|
127
156
|
* Two request shapes, because two are in the wild.
|
|
128
157
|
*
|
|
129
158
|
* RFC 7662 is a form POST of `token`. The other common spelling is a GET with
|
|
130
|
-
* the token in the query string, which several issuers ship instead
|
|
131
|
-
*
|
|
132
|
-
*
|
|
159
|
+
* the token in the query string, which several issuers ship instead — Google's
|
|
160
|
+
* `tokeninfo` among them, which is why the second shape exists at all.
|
|
161
|
+
*
|
|
162
|
+
* **The GET is attempted only for an endpoint the operator named.** A token in
|
|
163
|
+
* a query string is a credential in the issuer's access logs and in every
|
|
164
|
+
* proxy between here and it, which is a cost worth paying for the issuer whose
|
|
165
|
+
* documented setup requires it and worth paying for no other. A discovered
|
|
166
|
+
* endpoint publishes itself as RFC 7662 and RFC 7662 is the POST, so trying
|
|
167
|
+
* the query-string shape against one could only ever put a credential in a URL
|
|
168
|
+
* for an issuer that did not ask for it.
|
|
133
169
|
*/
|
|
134
170
|
async #introspect(token: string): Promise<Introspection | null> {
|
|
135
171
|
const endpoint = await this.#endpoint();
|
|
@@ -138,20 +174,21 @@ export class OidcVerifier {
|
|
|
138
174
|
// audience would leave the confused-deputy hole open while looking like
|
|
139
175
|
// it verified something.
|
|
140
176
|
throw new Error(
|
|
141
|
-
`The issuer ${this.#options.issuer} publishes no introspection_endpoint
|
|
142
|
-
'
|
|
143
|
-
'
|
|
177
|
+
`The issuer ${this.#options.issuer} publishes no introspection_endpoint this endpoint ` +
|
|
178
|
+
'will use — it is absent, not https, or not on the issuer\'s own origin. Set ' +
|
|
179
|
+
'auth.authorization.introspection_endpoint to the URL that answers questions about a ' +
|
|
180
|
+
'token, or this endpoint cannot check who a token was issued to.',
|
|
144
181
|
);
|
|
145
182
|
}
|
|
146
183
|
|
|
147
|
-
const posted = await this.#ask(endpoint, {
|
|
184
|
+
const posted = await this.#ask(endpoint.url, {
|
|
148
185
|
method: 'POST',
|
|
149
186
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
150
187
|
body: new URLSearchParams({ token }).toString(),
|
|
151
188
|
});
|
|
152
|
-
if (posted) return posted;
|
|
189
|
+
if (posted || !endpoint.explicit) return posted;
|
|
153
190
|
|
|
154
|
-
const url = new URL(endpoint);
|
|
191
|
+
const url = new URL(endpoint.url);
|
|
155
192
|
url.searchParams.set('access_token', token);
|
|
156
193
|
return this.#ask(url.toString(), { method: 'GET' });
|
|
157
194
|
}
|
|
@@ -167,6 +204,22 @@ export class OidcVerifier {
|
|
|
167
204
|
}
|
|
168
205
|
}
|
|
169
206
|
|
|
207
|
+
function isHttps(candidate: string): boolean {
|
|
208
|
+
try {
|
|
209
|
+
return new URL(candidate).protocol === 'https:';
|
|
210
|
+
} catch {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function sameOrigin(candidate: string, issuer: string): boolean {
|
|
216
|
+
try {
|
|
217
|
+
return new URL(candidate).origin === new URL(issuer).origin;
|
|
218
|
+
} catch {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
170
223
|
/**
|
|
171
224
|
* One shape out of several spellings of the same three facts.
|
|
172
225
|
*
|
package/src/cli/brand.ts
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* The Lanes design tokens, and the pieces every page this repository serves is
|
|
3
3
|
* built from.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* connect flow lands on
|
|
7
|
-
* carried its own approximation of the brand.
|
|
5
|
+
* Two surfaces render HTML — the authorization consent screen and the page a
|
|
6
|
+
* connect flow lands on. There were three until ADR-053 retired the dashboard,
|
|
7
|
+
* and until this file existed each carried its own approximation of the brand.
|
|
8
|
+
* They had drifted: two greens and
|
|
8
9
|
* two ambers that are not tokens at all, nine different alphas standing in for
|
|
9
10
|
* one border colour, a destructive red with a dark variant the token does not
|
|
10
11
|
* have, and the system font stack where Geist belongs.
|
|
@@ -154,21 +155,27 @@ a { color: inherit; }
|
|
|
154
155
|
*
|
|
155
156
|
* The font stylesheet and the faces it names are the only two origins any of
|
|
156
157
|
* them reaches; `default-src 'none'` closes the rest. No `script-src`, because
|
|
157
|
-
*
|
|
158
|
-
* this rather than replacing it.
|
|
158
|
+
* a page here has no script by default — the consent screen, which has one
|
|
159
|
+
* listener for its submit spinner, extends this rather than replacing it.
|
|
159
160
|
*/
|
|
160
161
|
export const PAGE_CSP =
|
|
161
162
|
"frame-ancestors 'none'; default-src 'none'; " +
|
|
163
|
+
// `form-action` does **not** fall back to `default-src`, so `'none'` above
|
|
164
|
+
// says nothing about where a form may post. One page here posts the owner's
|
|
165
|
+
// endpoint token, and its `action` is built from the request's own `Host` —
|
|
166
|
+
// this is the second lock on that, so a form target that ever came from
|
|
167
|
+
// somewhere else is refused by the browser rather than followed.
|
|
168
|
+
"form-action 'self'; " +
|
|
162
169
|
"style-src 'unsafe-inline' https://fonts.googleapis.com; " +
|
|
163
170
|
'font-src https://fonts.gstatic.com';
|
|
164
171
|
|
|
165
172
|
/**
|
|
166
173
|
* Headers every page here answers with.
|
|
167
174
|
*
|
|
168
|
-
* `frame-ancestors` in both spellings: one of these pages
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
175
|
+
* `frame-ancestors` in both spellings: one of these pages is a consent screen
|
|
176
|
+
* with a submit button, and framing it is the cheap half of a UI-redress
|
|
177
|
+
* attack. The URLs carry a `client_id` and a `redirect_uri`, neither of which
|
|
178
|
+
* belongs in a Referer.
|
|
172
179
|
*/
|
|
173
180
|
export const PAGE_HEADERS: Readonly<Record<string, string>> = {
|
|
174
181
|
'content-type': 'text/html; charset=utf-8',
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { credentialResolver, ReauthRequired } from '#connectivity/auth/index.ts';
|
|
2
|
+
import type { ResolvedCredential } from '#connectivity/auth/credential.ts';
|
|
3
|
+
import { credentialRefFor } from '#registry';
|
|
4
|
+
import { announce, emit, fail, ok, print, warn } from '../../output.ts';
|
|
5
|
+
import { openRuntime, type GlobalFlags, type Runtime } from '../../runtime.ts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Whether each connection could still authenticate, asked rather than guessed.
|
|
9
|
+
*
|
|
10
|
+
* `doctor` used to answer a version of this from the *age* of a stored
|
|
11
|
+
* credential, which is wrong in both directions: it dates a credential from
|
|
12
|
+
* when its access token was last refreshed, so a healthy connection nobody has
|
|
13
|
+
* called in a fortnight reads as stale, and a grant revoked an hour ago reads
|
|
14
|
+
* as fresh because nothing has tried to use it since.
|
|
15
|
+
*
|
|
16
|
+
* So this attempts the renewal instead. That is the only thing that actually
|
|
17
|
+
* knows, and it is cheap in the case that matters: resolving an OAuth
|
|
18
|
+
* credential short-circuits on the stored `expires_at` (`oauth-authcode/provider.ts`),
|
|
19
|
+
* so a connection whose access token is still live costs no network at all. The
|
|
20
|
+
* cost is one token-endpoint round trip per connection that has genuinely
|
|
21
|
+
* lapsed — which is exactly the set worth asking about.
|
|
22
|
+
*
|
|
23
|
+
* **This command writes.** A successful refresh persists the new token, which on
|
|
24
|
+
* a deployed target is a secret-store version per refreshed connection. That is
|
|
25
|
+
* deliberate — it is the same write the serve path makes, and it warms the token
|
|
26
|
+
* for the next real call — but it is why the read-only wording `check`, `plan`
|
|
27
|
+
* and `doctor` carry does not appear here.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** What can be said about one connection's ability to authenticate. */
|
|
31
|
+
export type AuthVerdict =
|
|
32
|
+
/** Resolved. The vendor accepted it just now, or its access token is still live. */
|
|
33
|
+
| 'ok'
|
|
34
|
+
/** Stored, and cannot be renewed without a person. The signal this exists for. */
|
|
35
|
+
| 'reauth'
|
|
36
|
+
/** Nothing at the credential ref. */
|
|
37
|
+
| 'missing'
|
|
38
|
+
/** A static secret is present, and nothing here can exercise it. */
|
|
39
|
+
| 'stored'
|
|
40
|
+
/** `auth.kind: none` — the owner layer. Can never need signing in. */
|
|
41
|
+
| 'none'
|
|
42
|
+
/** The probe could not complete: a timeout, a network fault, an unexpected throw. */
|
|
43
|
+
| 'unknown';
|
|
44
|
+
|
|
45
|
+
export interface ConnectionAuth {
|
|
46
|
+
readonly key: string;
|
|
47
|
+
readonly provider: string;
|
|
48
|
+
readonly id: string;
|
|
49
|
+
/** The manifest's `auth.kind`, so a reader can tell why a verdict is what it is. */
|
|
50
|
+
readonly method: string;
|
|
51
|
+
readonly verdict: AuthVerdict;
|
|
52
|
+
/** Whether answering cost a token-endpoint round trip. */
|
|
53
|
+
readonly refreshed: boolean;
|
|
54
|
+
readonly detail?: string;
|
|
55
|
+
readonly fix?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* What the probe observed, before it means anything.
|
|
60
|
+
*
|
|
61
|
+
* Separated from the verdict so the classification can be tested without a
|
|
62
|
+
* network, a credential store, or a runtime — every interesting case here is a
|
|
63
|
+
* question about *mapping*, and the mapping is where the mistakes are.
|
|
64
|
+
*/
|
|
65
|
+
export type ProbeResult =
|
|
66
|
+
/** A credential came back. `staleAccessToken` is the silent-failure case below. */
|
|
67
|
+
| { readonly outcome: 'resolved'; readonly staleAccessToken: boolean }
|
|
68
|
+
/** The resolver returned `resolveNone()` — there was nothing to resolve. */
|
|
69
|
+
| { readonly outcome: 'none' }
|
|
70
|
+
| { readonly outcome: 'timeout' }
|
|
71
|
+
| { readonly outcome: 'threw'; readonly error: unknown };
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* One OAuth probe result, as a verdict.
|
|
75
|
+
*
|
|
76
|
+
* The two rules worth stating out loud, because both are ways this feature
|
|
77
|
+
* could lie:
|
|
78
|
+
*
|
|
79
|
+
* **An unexpected throw is `unknown`, never `reauth`.** Only `ReauthRequired`
|
|
80
|
+
* means a person is needed. Anything else — DNS, a 500, a bug in here — must
|
|
81
|
+
* not send someone through a consent screen, and a warning that is wrong once
|
|
82
|
+
* is a warning that gets scrolled past every time after.
|
|
83
|
+
*
|
|
84
|
+
* **A resolved token is not automatically `ok`.** `upstreamAccessToken` hands
|
|
85
|
+
* back the *stale* access token when there is no refresh token to renew with,
|
|
86
|
+
* and again when a refresh returns no `access_token`. Both are deliberate on
|
|
87
|
+
* the serve path, where letting the vendor's own 401 surface is the truthful
|
|
88
|
+
* instruction — but a health check that reported them as working would be
|
|
89
|
+
* saying the opposite of what the next real call will find. So the stored
|
|
90
|
+
* expiry is checked here rather than that behaviour being changed.
|
|
91
|
+
*/
|
|
92
|
+
export function classifyOAuth(result: ProbeResult): AuthVerdict {
|
|
93
|
+
switch (result.outcome) {
|
|
94
|
+
case 'resolved':
|
|
95
|
+
return result.staleAccessToken ? 'reauth' : 'ok';
|
|
96
|
+
case 'none':
|
|
97
|
+
return 'missing';
|
|
98
|
+
case 'timeout':
|
|
99
|
+
return 'unknown';
|
|
100
|
+
case 'threw':
|
|
101
|
+
return result.error instanceof ReauthRequired ? 'reauth' : 'unknown';
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* How many connections to probe at once.
|
|
107
|
+
*
|
|
108
|
+
* Small on purpose. The work is mostly waiting on token endpoints, so some
|
|
109
|
+
* concurrency is free, but a profile with twenty Google connections hitting one
|
|
110
|
+
* endpoint at once is a rate limit rather than a speed-up.
|
|
111
|
+
*/
|
|
112
|
+
const CONCURRENCY = 6;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* How long one connection may take before it is reported as `unknown`.
|
|
116
|
+
*
|
|
117
|
+
* `refreshDirectly` takes no `AbortSignal`, so this races rather than cancels —
|
|
118
|
+
* the request finishes into nothing. That is acceptable for a read-shaped
|
|
119
|
+
* command and avoids threading a signal through the refresh path for the
|
|
120
|
+
* benefit of one caller.
|
|
121
|
+
*/
|
|
122
|
+
const PER_CONNECTION_TIMEOUT_MS = 8_000;
|
|
123
|
+
|
|
124
|
+
/** `expires_at` from a stored OAuth blob, or null when it is not one. */
|
|
125
|
+
function storedExpiry(raw: string | null): number | null {
|
|
126
|
+
if (!raw) return null;
|
|
127
|
+
try {
|
|
128
|
+
const parsed = JSON.parse(raw) as { expires_at?: number };
|
|
129
|
+
return typeof parsed.expires_at === 'number' ? parsed.expires_at : null;
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface AuthFlags extends GlobalFlags {
|
|
136
|
+
readonly json?: boolean | undefined;
|
|
137
|
+
/** Narrow to one connection, by `provider.id`. A filter, not a second subject. */
|
|
138
|
+
readonly connection?: string | undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Every connection's verdict, probed concurrently.
|
|
143
|
+
*
|
|
144
|
+
* Exported because `doctor` asks the same question and must not answer it a
|
|
145
|
+
* second, differently-wrong way — that divergence is the bug this replaced.
|
|
146
|
+
*/
|
|
147
|
+
export async function probeConnections(
|
|
148
|
+
runtime: Runtime,
|
|
149
|
+
connections: readonly Runtime['config']['connections'][number][],
|
|
150
|
+
forSelection: (command: string) => string,
|
|
151
|
+
): Promise<ConnectionAuth[]> {
|
|
152
|
+
const resolve = credentialResolver(runtime.registry, runtime.credentials);
|
|
153
|
+
|
|
154
|
+
const probe = async (
|
|
155
|
+
connection: Runtime['config']['connections'][number],
|
|
156
|
+
): Promise<ConnectionAuth> => {
|
|
157
|
+
const key = `${connection.provider}.${connection.id}`;
|
|
158
|
+
const manifest = runtime.manifestFor(connection.provider);
|
|
159
|
+
const method = manifest?.auth.kind ?? 'unknown';
|
|
160
|
+
const base = { key, provider: connection.provider, id: connection.id, method };
|
|
161
|
+
|
|
162
|
+
// A provider holding nothing to authenticate with can never need signing
|
|
163
|
+
// in, and saying so is more useful than saying nothing: it is the whole
|
|
164
|
+
// owner layer, and "why is memory not checked" is a real question.
|
|
165
|
+
if (!manifest || manifest.auth.kind === 'none') {
|
|
166
|
+
return { ...base, method: 'none', verdict: 'none', refreshed: false };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const ref = credentialRefFor(connection, manifest);
|
|
170
|
+
if (!ref) return { ...base, verdict: 'none', refreshed: false };
|
|
171
|
+
|
|
172
|
+
const missing = (): ConnectionAuth => ({
|
|
173
|
+
...base,
|
|
174
|
+
verdict: 'missing',
|
|
175
|
+
refreshed: false,
|
|
176
|
+
detail: `Nothing stored at ${ref}.`,
|
|
177
|
+
fix: forSelection(`lanes link connect ${key}`),
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
if (!(await runtime.credentials.has(ref))) return missing();
|
|
181
|
+
|
|
182
|
+
// Everything that is not authcode OAuth is a secret sitting in the store.
|
|
183
|
+
// There is nothing to renew and no cheap way to exercise it, so presence is
|
|
184
|
+
// the whole of what can be said — and `strategy` in particular *must* take
|
|
185
|
+
// this path, because `credentialResolver` refuses it unconditionally (it
|
|
186
|
+
// signs its own requests) and routing it through would manufacture a
|
|
187
|
+
// failure that is not there.
|
|
188
|
+
if (manifest.auth.kind !== 'oauth') {
|
|
189
|
+
return { ...base, verdict: 'stored', refreshed: false };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const before = storedExpiry(await runtime.credentials.get(ref));
|
|
193
|
+
const lapsed = before !== null && before <= Date.now();
|
|
194
|
+
|
|
195
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
196
|
+
const result = await Promise.race([
|
|
197
|
+
resolve(connection.provider, connection.id).then(
|
|
198
|
+
(credential: ResolvedCredential): ProbeResult =>
|
|
199
|
+
credential.kind === 'none'
|
|
200
|
+
? { outcome: 'none' }
|
|
201
|
+
: { outcome: 'resolved', staleAccessToken: false },
|
|
202
|
+
(error: unknown): ProbeResult => ({ outcome: 'threw', error }),
|
|
203
|
+
),
|
|
204
|
+
new Promise<ProbeResult>((settle) => {
|
|
205
|
+
timer = setTimeout(() => settle({ outcome: 'timeout' }), PER_CONNECTION_TIMEOUT_MS);
|
|
206
|
+
}),
|
|
207
|
+
]);
|
|
208
|
+
if (timer) clearTimeout(timer);
|
|
209
|
+
|
|
210
|
+
// The silent-failure check. An expiry that is *still* in the past after
|
|
211
|
+
// resolving means the resolver went out and came back with nothing better,
|
|
212
|
+
// which is the case `classifyOAuth` documents.
|
|
213
|
+
const after = storedExpiry(await runtime.credentials.get(ref));
|
|
214
|
+
const stillLapsed = after !== null && after <= Date.now();
|
|
215
|
+
|
|
216
|
+
const settled: ProbeResult =
|
|
217
|
+
result.outcome === 'resolved'
|
|
218
|
+
? { outcome: 'resolved', staleAccessToken: stillLapsed }
|
|
219
|
+
: result;
|
|
220
|
+
|
|
221
|
+
const verdict = classifyOAuth(settled);
|
|
222
|
+
const detail =
|
|
223
|
+
result.outcome === 'threw' && result.error instanceof Error
|
|
224
|
+
? result.error.message
|
|
225
|
+
: result.outcome === 'timeout'
|
|
226
|
+
? `Timed out after ${PER_CONNECTION_TIMEOUT_MS / 1000}s.`
|
|
227
|
+
: settled.outcome === 'resolved' && settled.staleAccessToken
|
|
228
|
+
? 'The stored access token is expired and could not be renewed.'
|
|
229
|
+
: undefined;
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
...base,
|
|
233
|
+
verdict,
|
|
234
|
+
// Whether answering cost a round trip: a live token means the resolver
|
|
235
|
+
// short-circuited and never went out.
|
|
236
|
+
refreshed: lapsed,
|
|
237
|
+
...(detail ? { detail } : {}),
|
|
238
|
+
...(verdict === 'reauth' || verdict === 'missing'
|
|
239
|
+
? { fix: forSelection(`lanes link connect ${key}`) }
|
|
240
|
+
: {}),
|
|
241
|
+
};
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
return mapWithLimit(connections, CONCURRENCY, probe);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function auth(flags: AuthFlags): Promise<void> {
|
|
248
|
+
const runtime = await openRuntime(flags);
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
const { profile, target } = runtime.resolution;
|
|
252
|
+
const forSelection = (command: string) => `${command} --profile ${profile} --target ${target}`;
|
|
253
|
+
|
|
254
|
+
const wanted = flags.connection;
|
|
255
|
+
const connections = wanted
|
|
256
|
+
? runtime.config.connections.filter((c) => `${c.provider}.${c.id}` === wanted)
|
|
257
|
+
: runtime.config.connections;
|
|
258
|
+
|
|
259
|
+
if (wanted && connections.length === 0) {
|
|
260
|
+
throw new Error(
|
|
261
|
+
`No connection "${wanted}" in profile ${profile}. Run: ${forSelection('lanes link status')}`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const results = await probeConnections(runtime, connections, forSelection);
|
|
266
|
+
const needsSomeone = results.filter((r) => r.verdict === 'reauth' || r.verdict === 'missing');
|
|
267
|
+
|
|
268
|
+
// Always zero, and this is load-bearing rather than an oversight: the
|
|
269
|
+
// desktop app discards stdout when the CLI exits non-zero, which is exactly
|
|
270
|
+
// why it cannot read `doctor`. A connection needing a person is the answer
|
|
271
|
+
// this command was asked for, not a failure to produce one.
|
|
272
|
+
return emit(flags.json, { profile, target, ok: needsSomeone.length === 0, connections: results }, () => {
|
|
273
|
+
announce(runtime.resolution);
|
|
274
|
+
|
|
275
|
+
for (const result of results) {
|
|
276
|
+
const line = `${result.key} — ${describe(result.verdict)}`;
|
|
277
|
+
if (result.verdict === 'reauth') print(fail(`${line}\n ${result.fix}`));
|
|
278
|
+
else if (result.verdict === 'missing') print(warn(`${line}\n ${result.fix}`));
|
|
279
|
+
else print(ok(line));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (needsSomeone.length > 0) {
|
|
283
|
+
print();
|
|
284
|
+
print(fail(`${needsSomeone.length} connection(s) need you to sign in again`));
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
} finally {
|
|
288
|
+
await runtime.close();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function describe(verdict: AuthVerdict): string {
|
|
293
|
+
switch (verdict) {
|
|
294
|
+
case 'ok':
|
|
295
|
+
return 'authenticated';
|
|
296
|
+
case 'reauth':
|
|
297
|
+
return 'signed out, and cannot renew itself';
|
|
298
|
+
case 'missing':
|
|
299
|
+
return 'no credential stored';
|
|
300
|
+
case 'stored':
|
|
301
|
+
return 'credential stored, not exercised';
|
|
302
|
+
case 'none':
|
|
303
|
+
return 'needs no credential';
|
|
304
|
+
case 'unknown':
|
|
305
|
+
return 'could not be checked';
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* `Promise.all` with a ceiling, in the ten lines it takes.
|
|
311
|
+
*
|
|
312
|
+
* A pool rather than chunks: chunking would make every batch wait for its
|
|
313
|
+
* slowest member, which is the shape this command is trying to avoid.
|
|
314
|
+
*/
|
|
315
|
+
async function mapWithLimit<T, R>(
|
|
316
|
+
items: readonly T[],
|
|
317
|
+
limit: number,
|
|
318
|
+
run: (item: T) => Promise<R>,
|
|
319
|
+
): Promise<R[]> {
|
|
320
|
+
const results = new Array<R>(items.length);
|
|
321
|
+
let next = 0;
|
|
322
|
+
|
|
323
|
+
const worker = async (): Promise<void> => {
|
|
324
|
+
for (;;) {
|
|
325
|
+
const index = next++;
|
|
326
|
+
if (index >= items.length) return;
|
|
327
|
+
results[index] = await run(items[index]!);
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
332
|
+
return results;
|
|
333
|
+
}
|