@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.
@@ -1,11 +1,12 @@
1
- import { credentialRefFor, formatPlan, planIsNoop, planReconcile } from '#registry';
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 { credentialAge, reportCapabilityDrift } from './findings.ts';
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
- /** Read-only external checks: credentials resolve, stores reachable. */
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
- for (const connection of runtime.config.connections) {
112
- const key = `${connection.provider}.${connection.id}`;
113
- const ref = credentialRefFor(connection, runtime.manifestFor(connection.provider));
114
- if (!ref) {
115
- checks.push(`${key} needs no credential`);
116
- continue;
117
- }
118
- if (await runtime.credentials.has(ref)) {
119
- const staleness = await credentialAge(runtime.credentials, ref);
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
- // A Google project left in "Testing" expires refresh tokens after seven
122
- // days. That is a policy setting rather than a fault, but it presents
123
- // as an authentication failure mid-task — so say it before the call
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: 'stale_credential',
133
- key,
143
+ kind: 'needs_reauth',
144
+ key: result.key,
134
145
  message:
135
- `${key} credential is ${staleness.days} days old a Google app in "Testing" expires at 7. ` +
136
- `Run: lanes link connect ${key}`,
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
- } else {
140
- const age = staleness
141
- ? ` (${staleness.days}d old${staleness.brokered ? ', hosted client' : ''})`
142
- : '';
143
- checks.push(`${key} credential resolves${age}`);
144
- }
145
- } else {
146
- problems.push({
147
- kind: 'missing_credential',
148
- key,
149
- message: `${key} has no stored credential — run: lanes link connect ${key}`,
150
- fix: forSelection(`lanes link connect ${key}`),
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
- * Seven files, one per verb group, because every private helper here served
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
- * dashboard.ts opening the page a local endpoint serves, with the key it needs
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 { dashboard, type DashboardFlags } from './operate/dashboard.ts';
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
- dashboard,
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 one page a person can
293
- // read, and only a local endpoint serves it.
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
- return dashboard({ ...global, print: flags['print'] === true });
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
@@ -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 reads which target it is rendering for before it decides anything: a
137
- // deployed one has no page to open, and the refusal has to name it.
138
- dashboard: 'profile+target',
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
- dashboard: ['print'],
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} dashboard [--print] open the local endpoint's page in a browser
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 Error(
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
- throw new Error(
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(manifest, auth?.app, tokenUrl, refreshToken, credentials, fetchImpl);
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
- throw new Error(
88
+ const message =
74
89
  `The credential for ${manifest.id} could not be refreshed. ` +
75
- `Re-authorise ${manifest.id} for this profile and target.\n${String(
76
- cause instanceof Error ? cause.message : cause,
77
- ).slice(0, 200)}${notice}`,
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
- throw new Error(
136
+ const message =
112
137
  `The credential for ${manifest.id} could not be refreshed (${response.status}). ` +
113
- `Re-authorise ${manifest.id} for this profile and target.\n${text.slice(0, 200)}`,
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
- throw new Error(refusalMessage(manifest, stored, body, response.status));
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
+ }
@@ -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 the dashboard are, and putting all
221
- * three in one function is what makes the loopback exclusion legible instead of
222
- * an invariant spread across two files.
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
@@ -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
  });
@@ -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