@lanes-sh/link 0.6.8 → 0.6.9
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 +5 -0
- package/package.json +1 -1
- package/src/cli/brand.ts +10 -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/server/cors.ts +3 -3
- package/src/server/endpoint.ts +0 -17
- package/src/server/harness.ts +0 -7
- package/src/server/index.ts +3 -33
- package/src/server/mcp/index.ts +0 -1
- package/src/server/mcp/visibility.ts +0 -33
- 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
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { formatPlan, planIsNoop, planReconcile } from '#registry';
|
|
2
2
|
import { DEFAULT_SURFACES } from '../../config-repair.ts';
|
|
3
3
|
import { announce, announceProfile, emit, fail, ok, print, warn } from '../../output.ts';
|
|
4
4
|
import { staleNudge } from '../../release.ts';
|
|
5
5
|
import { openRuntime, resolveProfileOnly, type GlobalFlags, type Runtime } from '../../runtime.ts';
|
|
6
6
|
import type { FetchLike } from '#deployments/knowledge.ts';
|
|
7
7
|
import { unboundRotatableRefs } from '#deployments/bind.ts';
|
|
8
|
-
import {
|
|
8
|
+
import { reportCapabilityDrift } from './findings.ts';
|
|
9
|
+
import { probeConnections } from './auth.ts';
|
|
9
10
|
import { migratedContract, migratedRenamedProviders } from './migrate.ts';
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -65,7 +66,16 @@ export interface DoctorFinding {
|
|
|
65
66
|
readonly fix?: string;
|
|
66
67
|
}
|
|
67
68
|
|
|
68
|
-
/**
|
|
69
|
+
/**
|
|
70
|
+
* External checks: credentials still authenticate, stores reachable.
|
|
71
|
+
*
|
|
72
|
+
* Not read-only, and that changed when the credential check stopped guessing
|
|
73
|
+
* from a stored date and started attempting the renewal. A refresh that
|
|
74
|
+
* succeeds persists the new token, which on a deployed target is a secret-store
|
|
75
|
+
* write. It is the same write serving a request makes, and it warms the token
|
|
76
|
+
* for the next real call — but `check` and `plan` above are still the two that
|
|
77
|
+
* touch nothing.
|
|
78
|
+
*/
|
|
69
79
|
export async function doctor(flags: DoctorFlags): Promise<void> {
|
|
70
80
|
// The one check that cannot use a runtime, because it answers for the profiles
|
|
71
81
|
// that cannot open one. A provider rename left in the config refuses at load,
|
|
@@ -108,47 +118,56 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
|
|
|
108
118
|
});
|
|
109
119
|
}
|
|
110
120
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
121
|
+
// Whether each credential still works, asked rather than dated.
|
|
122
|
+
//
|
|
123
|
+
// This used to warn from the *age* of a stored credential, on the theory
|
|
124
|
+
// that a Google app left in "Testing" expires refresh tokens at seven days.
|
|
125
|
+
// The heuristic was wrong in both directions — it dated a credential from
|
|
126
|
+
// its last refresh, so an untouched healthy connection read as stale and a
|
|
127
|
+
// grant revoked an hour ago read as fresh — and its own guard made it worse:
|
|
128
|
+
// it skipped brokered credentials because "the hosted client is in
|
|
129
|
+
// production", which `providers/google/shared/oauth.ts` now says outright is
|
|
130
|
+
// not so. The hosted client is under review and carries the same weekly
|
|
131
|
+
// expiry, so the warning was silenced for exactly the population that has
|
|
132
|
+
// the problem.
|
|
133
|
+
//
|
|
134
|
+
// `probeConnections` answers it by attempting the renewal, which is the only
|
|
135
|
+
// thing that actually knows. Same classifier as `lanes link auth`, so the two
|
|
136
|
+
// cannot drift apart again.
|
|
137
|
+
const probed = await probeConnections(runtime, runtime.config.connections, forSelection);
|
|
120
138
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
// fails rather than after.
|
|
125
|
-
//
|
|
126
|
-
// Only for a client the operator registered. The hosted one is in
|
|
127
|
-
// production and does not expire refresh tokens weekly, so this warning
|
|
128
|
-
// would simply be false there — and a warning that is wrong once is a
|
|
129
|
-
// warning that gets scrolled past every time after.
|
|
130
|
-
if (staleness !== null && !staleness.brokered && staleness.days >= 7) {
|
|
139
|
+
for (const result of probed) {
|
|
140
|
+
switch (result.verdict) {
|
|
141
|
+
case 'reauth':
|
|
131
142
|
warnings.push({
|
|
132
|
-
kind: '
|
|
133
|
-
key,
|
|
143
|
+
kind: 'needs_reauth',
|
|
144
|
+
key: result.key,
|
|
134
145
|
message:
|
|
135
|
-
`${key}
|
|
136
|
-
|
|
137
|
-
fix: forSelection(`lanes link connect ${key}`),
|
|
146
|
+
`${result.key} is signed out and cannot renew itself — run: lanes link connect ${result.key}` +
|
|
147
|
+
(result.detail ? `\n ${result.detail}` : ''),
|
|
148
|
+
fix: forSelection(`lanes link connect ${result.key}`),
|
|
138
149
|
});
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
: ''
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
150
|
+
break;
|
|
151
|
+
case 'missing':
|
|
152
|
+
problems.push({
|
|
153
|
+
kind: 'missing_credential',
|
|
154
|
+
key: result.key,
|
|
155
|
+
message: `${result.key} has no stored credential — run: lanes link connect ${result.key}`,
|
|
156
|
+
fix: forSelection(`lanes link connect ${result.key}`),
|
|
157
|
+
});
|
|
158
|
+
break;
|
|
159
|
+
case 'none':
|
|
160
|
+
checks.push(`${result.key} needs no credential`);
|
|
161
|
+
break;
|
|
162
|
+
case 'unknown':
|
|
163
|
+
warnings.push({
|
|
164
|
+
kind: 'auth_uncheckable',
|
|
165
|
+
key: result.key,
|
|
166
|
+
message: `${result.key} could not be checked${result.detail ? `: ${result.detail}` : ''}`,
|
|
167
|
+
});
|
|
168
|
+
break;
|
|
169
|
+
default:
|
|
170
|
+
checks.push(`${result.key} credential resolves`);
|
|
152
171
|
}
|
|
153
172
|
}
|
|
154
173
|
|
|
@@ -45,9 +45,6 @@ export async function start(
|
|
|
45
45
|
port: flags.port,
|
|
46
46
|
only: flags.only,
|
|
47
47
|
mintToken: true,
|
|
48
|
-
// Local, so there is a browser and a person at it. `container.ts` does not
|
|
49
|
-
// pass this — see `#server/dashboard.ts`.
|
|
50
|
-
dashboard: true,
|
|
51
48
|
// Stderr, not stdout: `--json` and `--raw` callers parse the other stream.
|
|
52
49
|
// A refused credential is the event worth seeing while this runs in the
|
|
53
50
|
// foreground, and until now nothing printed it.
|
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
* Running an instance and looking at it — everything that is neither
|
|
3
3
|
* `connect` nor the owner layer.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* Eight files, one per verb group, because every private helper here served
|
|
6
6
|
* exactly one command and nothing crossed between them:
|
|
7
7
|
*
|
|
8
8
|
* inspect.ts check, plan, doctor — the gate order, cheapest failure first
|
|
9
|
+
* auth.ts whether each connection can still authenticate, by asking
|
|
9
10
|
* status.ts connections, reachable capabilities, endpoint
|
|
10
11
|
* outputs.ts what an agent harness needs, and proving the short form works
|
|
11
|
-
*
|
|
12
|
+
* desktop.ts opening the Lanes app, on the page that drives this CLI
|
|
12
13
|
* serve.ts start
|
|
13
14
|
* audit.ts audit tail and verify, and the Markdown rendering of tail
|
|
14
15
|
* token.ts token show, token rotate
|
|
@@ -19,11 +20,12 @@
|
|
|
19
20
|
*/
|
|
20
21
|
|
|
21
22
|
export { check, doctor, plan } from './operate/inspect.ts';
|
|
23
|
+
export { auth, classifyOAuth, type AuthFlags, type AuthVerdict, type ConnectionAuth } from './operate/auth.ts';
|
|
22
24
|
export { status } from './operate/status.ts';
|
|
23
25
|
export { outputs, type OutputsFlags } from './operate/outputs.ts';
|
|
24
26
|
export { tools, type ToolsFlags } from './operate/tools.ts';
|
|
25
27
|
export { start } from './operate/serve.ts';
|
|
26
|
-
export {
|
|
28
|
+
export { desktop, settingsUrl, type DesktopFlags } from './operate/desktop.ts';
|
|
27
29
|
export { auditTail, auditVerify, markdownCell } from './operate/audit.ts';
|
|
28
30
|
export { attachFile } from './operate/attach.ts';
|
|
29
31
|
export { tokenRotate, tokenShow } from './operate/token.ts';
|
package/src/cli/main.ts
CHANGED
|
@@ -5,9 +5,10 @@ import {
|
|
|
5
5
|
attachFile,
|
|
6
6
|
auditTail,
|
|
7
7
|
auditVerify,
|
|
8
|
+
auth,
|
|
8
9
|
check,
|
|
9
10
|
configShow,
|
|
10
|
-
|
|
11
|
+
desktop,
|
|
11
12
|
doctor,
|
|
12
13
|
outputs,
|
|
13
14
|
plan,
|
|
@@ -282,6 +283,11 @@ export async function run(argv: readonly string[]): Promise<void> {
|
|
|
282
283
|
return plan(global);
|
|
283
284
|
case 'doctor':
|
|
284
285
|
return doctor({ ...global, json, fix: flags['fix'] === true });
|
|
286
|
+
|
|
287
|
+
// Beside `doctor` because it answers half of what `doctor` used to guess at,
|
|
288
|
+
// and answers it by asking rather than by dating a credential.
|
|
289
|
+
case 'auth':
|
|
290
|
+
return auth({ ...global, json, connection: text(flags, 'connection') });
|
|
285
291
|
case 'status':
|
|
286
292
|
return status({ ...global, json });
|
|
287
293
|
case 'outputs':
|
|
@@ -289,10 +295,19 @@ export async function run(argv: readonly string[]): Promise<void> {
|
|
|
289
295
|
|
|
290
296
|
// Beside `outputs` for the same reason `tools` is: it answers the next
|
|
291
297
|
// question a person has rather than the next one an agent has. `outputs`
|
|
292
|
-
// hands a harness a URL and a token; this opens the
|
|
293
|
-
//
|
|
298
|
+
// hands a harness a URL and a token; this opens the app a person drives all
|
|
299
|
+
// of this from.
|
|
300
|
+
//
|
|
301
|
+
// Two spellings, one behaviour, as `skill` is for `mcp skill`. `dashboard`
|
|
302
|
+
// is what this was called when it opened a page the endpoint served, and
|
|
303
|
+
// that name is in a year of notes; `desktop` is what it does now (ADR-053).
|
|
304
|
+
// Both are in `USAGE`, unlike the `skill` alias, because nobody has learned
|
|
305
|
+
// the new one yet.
|
|
306
|
+
//
|
|
307
|
+
// No `...global`: this resolves nothing, so there is nothing to select.
|
|
294
308
|
case 'dashboard':
|
|
295
|
-
|
|
309
|
+
case 'desktop':
|
|
310
|
+
return desktop({ print: flags['print'] === true, yes: flags['yes'] === true });
|
|
296
311
|
|
|
297
312
|
// Beside `outputs` because it answers the next question. `outputs` says
|
|
298
313
|
// where the endpoint is; this says what it would hand a client that asked
|
package/src/cli/selection.ts
CHANGED
|
@@ -129,13 +129,18 @@ export const SELECTION: Record<string, Requires> = {
|
|
|
129
129
|
secrets: 'profile+target',
|
|
130
130
|
plan: 'profile+target',
|
|
131
131
|
doctor: 'profile+target',
|
|
132
|
+
auth: 'profile+target',
|
|
132
133
|
// Target-scoped: see the note above. `--profile` narrows each to one profile.
|
|
133
134
|
status: 'target',
|
|
134
135
|
outputs: 'profile+target',
|
|
135
136
|
tools: 'profile+target',
|
|
136
|
-
// It
|
|
137
|
-
//
|
|
138
|
-
|
|
137
|
+
// It resolves nothing and opens nothing — it hands macOS a URL (ADR-053).
|
|
138
|
+
// `target list` is the precedent for a `'none'` command that still takes a
|
|
139
|
+
// flag of its own. Both spellings need a row: `selection.test.ts` reads
|
|
140
|
+
// `main.ts` for `case` labels, and a label with no row here falls through to
|
|
141
|
+
// the `profile+target` default.
|
|
142
|
+
dashboard: 'none',
|
|
143
|
+
desktop: 'none',
|
|
139
144
|
attach: 'profile+target',
|
|
140
145
|
start: 'profile+target',
|
|
141
146
|
deploy: 'target',
|
|
@@ -267,6 +272,9 @@ const ACCEPTS: Record<string, readonly string[]> = {
|
|
|
267
272
|
// it undoes a provider rename this project shipped, and every other finding
|
|
268
273
|
// there is something only the operator can decide.
|
|
269
274
|
doctor: ['fix'],
|
|
275
|
+
// A filter, not a second subject: it narrows the answer to one connection so
|
|
276
|
+
// a caller can re-ask about the row it just repaired. Same shape as `attach`.
|
|
277
|
+
auth: ['connection'],
|
|
270
278
|
relabel: [],
|
|
271
279
|
'target list': ['urls', 'target'],
|
|
272
280
|
'target show': ['target'],
|
|
@@ -281,7 +289,10 @@ const ACCEPTS: Record<string, readonly string[]> = {
|
|
|
281
289
|
'mcp add': ['name', 'scope', 'token-env', 'dry-run', 'force', 'no-skill'],
|
|
282
290
|
'mcp skill': ['print', 'force'],
|
|
283
291
|
'mcp list': ['name', 'scope'],
|
|
284
|
-
|
|
292
|
+
// `--yes` because it installs the app when nothing answers the scheme, and
|
|
293
|
+
// that is the one prompt in this CLI that puts an application on the machine.
|
|
294
|
+
dashboard: ['print', 'yes'],
|
|
295
|
+
desktop: ['print', 'yes'],
|
|
285
296
|
skill: ['print', 'force'],
|
|
286
297
|
deploy: ['dry-run', 'iam', 'access', 'service-account', 'tag', 'yes', 'non-interactive'],
|
|
287
298
|
'secrets push': ['from', 'to', 'overwrite', 'dry-run'],
|
package/src/cli/usage.ts
CHANGED
|
@@ -37,7 +37,9 @@ ${style.bold('Everyday')}
|
|
|
37
37
|
from the credential store, or say what is missing
|
|
38
38
|
${PROGRAM} start [--only] reconcile and serve every profile on one endpoint
|
|
39
39
|
${PROGRAM} outputs [--show] [--json] the endpoint an agent needs
|
|
40
|
-
${PROGRAM}
|
|
40
|
+
${PROGRAM} desktop [--print] [--yes] open the Lanes app on its Lanes Link page,
|
|
41
|
+
installing it first if it is not there
|
|
42
|
+
${PROGRAM} dashboard the older spelling of the line above
|
|
41
43
|
${PROGRAM} mcp add [claude|codex] register this endpoint, and install the agent skill
|
|
42
44
|
${PROGRAM} mcp add --no-skill register only, leaving the agent's own files alone
|
|
43
45
|
${PROGRAM} mcp list where it is registered, and whether the skill is current
|
|
@@ -129,6 +131,8 @@ ${style.bold('Inspection')}
|
|
|
129
131
|
${PROGRAM} doctor [--json] credentials resolve, stores reachable
|
|
130
132
|
${PROGRAM} doctor --fix apply a repair it can make itself, such as
|
|
131
133
|
a provider this project renamed under you
|
|
134
|
+
${PROGRAM} auth [--json] whether each connection can still sign in
|
|
135
|
+
${PROGRAM} auth --connection <key> just this one
|
|
132
136
|
${PROGRAM} tools [--json] what the endpoint advertises to a client
|
|
133
137
|
${PROGRAM} plan what reconcile would change
|
|
134
138
|
${PROGRAM} audit tail [--limit N] [--denied-only] [--format md]
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
export { credentialResolver, type ResolvedCredential } from './resolve.ts';
|
|
21
21
|
export { requestAuthorizer } from './authorize.ts';
|
|
22
|
+
export { ReauthRequired, statusMeansGrantIsDead } from './reauth.ts';
|
|
22
23
|
export { basicCredential } from './basic/index.ts';
|
|
23
24
|
export { bearerToken, bearerTokenAsStored } from './token.ts';
|
|
24
25
|
export {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { SecretStore } from '#secrets';
|
|
2
2
|
import type { ProviderManifest } from '#connectivity';
|
|
3
|
+
import { ReauthRequired } from '../reauth.ts';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* The SDK's `OAuthClientProvider`, backed by our `SecretStore`.
|
|
@@ -69,6 +70,20 @@ export class CredentialOAuthProvider {
|
|
|
69
70
|
return `${this.#options.manifest.id}/${this.#options.connectionId}`;
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Which connection this provider speaks for, as `provider.id`.
|
|
75
|
+
*
|
|
76
|
+
* Public because a refusal has to name it: `refresh.ts` builds the same key
|
|
77
|
+
* for a `ReauthRequired`, and a caller holding several connections needs to
|
|
78
|
+
* know which one to send someone to rather than parsing it back out of a
|
|
79
|
+
* sentence. Note the separator differs from `#tokensRef` on purpose — that
|
|
80
|
+
* one is a credential ref (`gmail/main`), this one is the addressing form
|
|
81
|
+
* (`gmail.main`).
|
|
82
|
+
*/
|
|
83
|
+
get connectionId(): string {
|
|
84
|
+
return this.#options.connectionId;
|
|
85
|
+
}
|
|
86
|
+
|
|
72
87
|
// --- OAuthClientProvider ----------------------------------------------
|
|
73
88
|
|
|
74
89
|
get redirectUrl(): string | undefined {
|
|
@@ -131,7 +146,8 @@ export class CredentialOAuthProvider {
|
|
|
131
146
|
|
|
132
147
|
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
|
|
133
148
|
if (!this.#options.openBrowser) {
|
|
134
|
-
throw new
|
|
149
|
+
throw new ReauthRequired(
|
|
150
|
+
`${this.#options.manifest.id}.${this.#options.connectionId}`,
|
|
135
151
|
`Connection ${this.#options.manifest.id}.${this.#options.connectionId} needs re-authorisation, ` +
|
|
136
152
|
`which requires a browser. Connect ${this.#options.manifest.id}.${this.#options.connectionId} again for this profile and target.`,
|
|
137
153
|
);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ProviderManifest } from '#connectivity';
|
|
2
2
|
import type { SecretStore } from '#secrets';
|
|
3
3
|
import { BROKERED, BrokerError, brokerRefresh } from './broker.ts';
|
|
4
|
+
import { ReauthRequired, statusMeansGrantIsDead } from '../reauth.ts';
|
|
4
5
|
import type { CredentialOAuthProvider } from './provider.ts';
|
|
5
6
|
|
|
6
7
|
/** What a stored OAuth credential carries beyond the tokens themselves. */
|
|
@@ -23,7 +24,10 @@ export async function refreshDirectly(
|
|
|
23
24
|
const refreshToken = existing?.refresh_token;
|
|
24
25
|
|
|
25
26
|
if (!refreshToken) {
|
|
26
|
-
|
|
27
|
+
// Nothing to renew with, so this can only be settled by signing in again —
|
|
28
|
+
// the same remedy as a dead grant, and reported as the same thing.
|
|
29
|
+
throw new ReauthRequired(
|
|
30
|
+
`${manifest.id}.${provider.connectionId}`,
|
|
27
31
|
`No refresh token stored for ${manifest.id}. Connecting it again for this profile and target would store one.`,
|
|
28
32
|
);
|
|
29
33
|
}
|
|
@@ -38,9 +42,19 @@ export async function refreshDirectly(
|
|
|
38
42
|
const broker = auth?.broker;
|
|
39
43
|
const brokered = broker !== undefined && existing?.authorized_via === BROKERED;
|
|
40
44
|
|
|
45
|
+
const connectionKey = `${manifest.id}.${provider.connectionId}`;
|
|
46
|
+
|
|
41
47
|
const refreshed = brokered
|
|
42
|
-
? await viaBroker(manifest, broker.url, refreshToken, existing, fetchImpl)
|
|
43
|
-
: await viaStoredClient(
|
|
48
|
+
? await viaBroker(manifest, connectionKey, broker.url, refreshToken, existing, fetchImpl)
|
|
49
|
+
: await viaStoredClient(
|
|
50
|
+
manifest,
|
|
51
|
+
connectionKey,
|
|
52
|
+
auth?.app,
|
|
53
|
+
tokenUrl,
|
|
54
|
+
refreshToken,
|
|
55
|
+
credentials,
|
|
56
|
+
fetchImpl,
|
|
57
|
+
);
|
|
44
58
|
|
|
45
59
|
// `existing` first, so what the response does not mention survives it. Neither
|
|
46
60
|
// the vendor nor the broker echoes `refresh_token`, `id_token`, or
|
|
@@ -53,6 +67,7 @@ export async function refreshDirectly(
|
|
|
53
67
|
|
|
54
68
|
async function viaBroker(
|
|
55
69
|
manifest: ProviderManifest,
|
|
70
|
+
connectionKey: string,
|
|
56
71
|
url: string,
|
|
57
72
|
refreshToken: string,
|
|
58
73
|
existing: StoredTokens,
|
|
@@ -70,17 +85,27 @@ async function viaBroker(
|
|
|
70
85
|
// next step. Same shape as the stored-client message below, deliberately:
|
|
71
86
|
// where the credential came from is not the reader's problem here.
|
|
72
87
|
const notice = cause instanceof BrokerError && cause.notice ? `\n${cause.notice}` : '';
|
|
73
|
-
|
|
88
|
+
const message =
|
|
74
89
|
`The credential for ${manifest.id} could not be refreshed. ` +
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
90
|
+
`Re-authorise ${manifest.id} for this profile and target.\n${String(
|
|
91
|
+
cause instanceof Error ? cause.message : cause,
|
|
92
|
+
).slice(0, 200)}${notice}`;
|
|
93
|
+
|
|
94
|
+
// Only the broker refusing *this* credential means a person is needed. A
|
|
95
|
+
// broker that is down, or rate-limiting, says nothing about the grant, and
|
|
96
|
+
// reporting it as "sign in again" would send someone through a consent
|
|
97
|
+
// screen to fix an outage. A `cause` that is not a `BrokerError` never
|
|
98
|
+
// reached the broker at all, so it is the same case.
|
|
99
|
+
if (cause instanceof BrokerError && statusMeansGrantIsDead(cause.status)) {
|
|
100
|
+
throw new ReauthRequired(connectionKey, message);
|
|
101
|
+
}
|
|
102
|
+
throw new Error(message);
|
|
79
103
|
}
|
|
80
104
|
}
|
|
81
105
|
|
|
82
106
|
async function viaStoredClient(
|
|
83
107
|
manifest: ProviderManifest,
|
|
108
|
+
connectionKey: string,
|
|
84
109
|
app: string | undefined,
|
|
85
110
|
tokenUrl: string,
|
|
86
111
|
refreshToken: string,
|
|
@@ -108,10 +133,17 @@ async function viaStoredClient(
|
|
|
108
133
|
if (!response.ok) {
|
|
109
134
|
// A revoked or expired refresh token is the common case here, and the fix
|
|
110
135
|
// is always the same, so say it rather than surfacing the raw grant error.
|
|
111
|
-
|
|
136
|
+
const message =
|
|
112
137
|
`The credential for ${manifest.id} could not be refreshed (${response.status}). ` +
|
|
113
|
-
|
|
114
|
-
|
|
138
|
+
`Re-authorise ${manifest.id} for this profile and target.\n${text.slice(0, 200)}`;
|
|
139
|
+
|
|
140
|
+
// 4xx is the authorization server rejecting this credential; 5xx is it
|
|
141
|
+
// being unwell. Only the first is something a person can fix, and only the
|
|
142
|
+
// first may be reported as such.
|
|
143
|
+
if (statusMeansGrantIsDead(response.status)) {
|
|
144
|
+
throw new ReauthRequired(connectionKey, message);
|
|
145
|
+
}
|
|
146
|
+
throw new Error(message);
|
|
115
147
|
}
|
|
116
148
|
|
|
117
149
|
return JSON.parse(text) as Record<string, unknown>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ProviderManifest } from '#connectivity';
|
|
2
|
+
import { ReauthRequired, statusMeansGrantIsDead } from '../reauth.ts';
|
|
2
3
|
import type { SecretStore } from '#secrets';
|
|
3
4
|
import { credentialRefForConnection } from '../../manifest/credential-ref.ts';
|
|
4
5
|
import { parseAssertionKey, signAssertion } from './key.ts';
|
|
@@ -170,7 +171,17 @@ export async function resolveAssertionToken(input: {
|
|
|
170
171
|
const body = (await response.json().catch(() => ({}))) as TokenResponse;
|
|
171
172
|
|
|
172
173
|
if (!response.ok || !body.access_token) {
|
|
173
|
-
|
|
174
|
+
const message = refusalMessage(manifest, stored, body, response.status);
|
|
175
|
+
|
|
176
|
+
// A key is refused for the same two reasons a refresh token is: the grant
|
|
177
|
+
// behind it is gone, or the token endpoint is unwell. The remedy differs
|
|
178
|
+
// from a consent screen — it is an admin grant in a console, or `connect
|
|
179
|
+
// --replace` — but "the owner has to re-authorise this connection" is the
|
|
180
|
+
// same claim, and `refusalMessage` already writes the specific sentence.
|
|
181
|
+
if (statusMeansGrantIsDead(response.status)) {
|
|
182
|
+
throw new ReauthRequired(`${manifest.id}.${connectionId}`, message);
|
|
183
|
+
}
|
|
184
|
+
throw new Error(message);
|
|
174
185
|
}
|
|
175
186
|
|
|
176
187
|
minted.set(cacheKey, {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one failure a person has to fix, told apart from every other one.
|
|
3
|
+
*
|
|
4
|
+
* A stored credential stops working for two very different reasons, and until
|
|
5
|
+
* this existed they arrived as the same `Error`:
|
|
6
|
+
*
|
|
7
|
+
* - the grant is gone — revoked, or expired because the client's publishing
|
|
8
|
+
* status expires refresh tokens on a timer. Nothing retries its way out of
|
|
9
|
+
* this; somebody has to sign in again.
|
|
10
|
+
* - the token endpoint had a bad afternoon — a 502, a reset connection, DNS.
|
|
11
|
+
* Retrying is exactly right, and telling the owner to re-authorise would be
|
|
12
|
+
* a lie that costs them a consent screen.
|
|
13
|
+
*
|
|
14
|
+
* Both used to read as "could not be refreshed", so anything trying to *report*
|
|
15
|
+
* connection health had to match on the message text. That is why this is a
|
|
16
|
+
* class and not a string: `auth.ts` classifies on `instanceof`, and the messages
|
|
17
|
+
* stay free to be rewritten for whoever is reading them.
|
|
18
|
+
*
|
|
19
|
+
* The message is deliberately unchanged from what each throw site said before —
|
|
20
|
+
* this is a widening, not a rewrite. What is new is that the type now carries
|
|
21
|
+
* *which* connection, so a caller holding several can say which one to fix
|
|
22
|
+
* without parsing the sentence.
|
|
23
|
+
*/
|
|
24
|
+
export class ReauthRequired extends Error {
|
|
25
|
+
/** `provider.id`, e.g. `gmail.main`. The addressing form used everywhere. */
|
|
26
|
+
readonly connectionKey: string;
|
|
27
|
+
|
|
28
|
+
constructor(connectionKey: string, message: string) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = 'ReauthRequired';
|
|
31
|
+
this.connectionKey = connectionKey;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether an HTTP status from a token endpoint means the grant itself is dead.
|
|
37
|
+
*
|
|
38
|
+
* 4xx is the authorization server saying no to *this credential* — `invalid_grant`
|
|
39
|
+
* for a revoked or expired refresh token, `invalid_client` for a client that no
|
|
40
|
+
* longer exists. Signing in again is the fix.
|
|
41
|
+
*
|
|
42
|
+
* 5xx is the server saying no to *everyone*, and 429 is it saying "not now".
|
|
43
|
+
* Neither is a statement about the credential, so neither may be reported as
|
|
44
|
+
* needing a human. 429 sits in the 4xx range and is excluded for that reason.
|
|
45
|
+
*/
|
|
46
|
+
export function statusMeansGrantIsDead(status: number): boolean {
|
|
47
|
+
return status >= 400 && status < 500 && status !== 429;
|
|
48
|
+
}
|
package/src/server/cors.ts
CHANGED
|
@@ -217,9 +217,9 @@ function withCors(response: Response, headers: Record<string, string>): Response
|
|
|
217
217
|
*
|
|
218
218
|
* Wrapped at `serve()` rather than inside the router, which is also where the
|
|
219
219
|
* policy is decided: cross-origin access is a property of the address this is
|
|
220
|
-
* bound to, exactly as `allowedHostnames` and
|
|
221
|
-
*
|
|
222
|
-
*
|
|
220
|
+
* bound to, exactly as `allowedHostnames` is, and putting both in one function
|
|
221
|
+
* is what makes the loopback exclusion legible instead of an invariant spread
|
|
222
|
+
* across two files.
|
|
223
223
|
*
|
|
224
224
|
* It is what makes the ordering safe, too. A preflight answered here never
|
|
225
225
|
* reaches the rebinding guard inside `inner` — and does not need to, because a
|
package/src/server/endpoint.ts
CHANGED
|
@@ -77,16 +77,6 @@ export interface EndpointOptions {
|
|
|
77
77
|
readonly reporter?: EndpointReporter | undefined;
|
|
78
78
|
/** Operational events. Silent when absent, which is what the tests want. */
|
|
79
79
|
readonly log?: Logger | undefined;
|
|
80
|
-
/**
|
|
81
|
-
* Serve the dashboard at `/dashboard`.
|
|
82
|
-
*
|
|
83
|
-
* True for `lanes link start`, absent in a container — the same split as
|
|
84
|
-
* `mintToken`, and for a related reason. A deployed instance has no door a
|
|
85
|
-
* browser can come through (ADR-018), so a page there would be either
|
|
86
|
-
* unreachable or unguarded depending on `deploy.access`, and both are worse
|
|
87
|
-
* than not having one.
|
|
88
|
-
*/
|
|
89
|
-
readonly dashboard?: boolean | undefined;
|
|
90
80
|
}
|
|
91
81
|
|
|
92
82
|
export interface RunningEndpoint {
|
|
@@ -184,12 +174,6 @@ function profileRuntimes(runtimes: ReadonlyMap<string, Runtime>): Map<string, Pr
|
|
|
184
174
|
// through `skills.manage.write`, or by `lanes link skills add` in another
|
|
185
175
|
// terminal — is a prompt without a restart (ADR-014).
|
|
186
176
|
refreshSkills: runtime.refreshSkills,
|
|
187
|
-
// For a surface that reports rather than dispatches. A thunk rather
|
|
188
|
-
// than a snapshot because a reconcile lands between requests, and the
|
|
189
|
-
// dashboard reading a list captured at boot would keep showing an
|
|
190
|
-
// account as unauthorized after the connect that fixed it.
|
|
191
|
-
target: runtime.target,
|
|
192
|
-
connections: () => runtime.state.connections.list(),
|
|
193
177
|
},
|
|
194
178
|
]),
|
|
195
179
|
);
|
|
@@ -336,7 +320,6 @@ export async function startEndpoint(options: EndpointOptions): Promise<RunningEn
|
|
|
336
320
|
: primary.authenticator,
|
|
337
321
|
log,
|
|
338
322
|
...(gate ? { authorization: gate.surface } : {}),
|
|
339
|
-
...(options.dashboard ? { dashboard: true } : {}),
|
|
340
323
|
...(options.port !== undefined ? { port: options.port } : {}),
|
|
341
324
|
...(options.host !== undefined ? { host: options.host } : {}),
|
|
342
325
|
});
|
package/src/server/harness.ts
CHANGED
|
@@ -116,8 +116,6 @@ export interface HarnessOptions {
|
|
|
116
116
|
* serving the old generation" case is reached; absent means nothing new.
|
|
117
117
|
*/
|
|
118
118
|
reopen?: () => Promise<ReadonlyMap<string, ProfileRuntime>>;
|
|
119
|
-
/** Serve `/dashboard`, as `lanes link start` does and a container never does. */
|
|
120
|
-
dashboard?: boolean;
|
|
121
119
|
}
|
|
122
120
|
|
|
123
121
|
/**
|
|
@@ -178,10 +176,6 @@ export function wireProfiles(options: HarnessOptions): WiredProfiles {
|
|
|
178
176
|
dispatcher,
|
|
179
177
|
policy,
|
|
180
178
|
...(options.refreshSkills ? { refreshSkills: () => options.refreshSkills!(registry) } : {}),
|
|
181
|
-
// As `profileRuntimes` supplies them for real. A harness that claims to
|
|
182
|
-
// be the real wiring and omits a field leaves that field untested.
|
|
183
|
-
target: 'local',
|
|
184
|
-
connections: () => state.connections.list(),
|
|
185
179
|
}),
|
|
186
180
|
);
|
|
187
181
|
|
|
@@ -260,7 +254,6 @@ export function startHarness(options: HarnessOptions): Harness {
|
|
|
260
254
|
primary: options.profile,
|
|
261
255
|
authenticator: gate ? new AuthenticatorChain([bearer, gate.authenticator]) : bearer,
|
|
262
256
|
...(gate ? { authorization: gate.surface } : {}),
|
|
263
|
-
...(options.dashboard ? { dashboard: true } : {}),
|
|
264
257
|
log,
|
|
265
258
|
});
|
|
266
259
|
|