@lanes-sh/link 0.2.0 → 0.2.2

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.
@@ -185,3 +185,20 @@ URL and a token.
185
185
  user the command rather than backgrounding it silently on their behalf.
186
186
  Registration works while it is down — the harness simply cannot reach it yet,
187
187
  and the first symptom is a failed call much later.
188
+
189
+ ## When a call does not land
190
+
191
+ Different from the above, and more common: calls were working, and then one does
192
+ not go through. A deployed endpoint is one machine its owner runs, and a client
193
+ can report it unreachable while it is up — sometimes without sending anything at
194
+ all, which is why the endpoint's own log can show no trace of the attempt.
195
+
196
+ Treat it as ordinary. **Say the call did not land, and stop there.** It is not a
197
+ fault to diagnose, and it is not authorization that has lapsed — do not tell them
198
+ to sign in again unless the endpoint itself said so.
199
+
200
+ **Do not redo what already succeeded.** A call that returned is done, and the
201
+ next one failing does not undo it. Re-deriving a finished answer, or rewriting a
202
+ memory entry that was already written, is the expensive mistake here and the one
203
+ that actually gets made. Say which parts landed, which did not, and offer to
204
+ retry the rest.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lanes-sh/link",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "A self-hostable MCP gateway for all your connections, memory, skills, and secrets",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://lanes.sh/link",
package/src/auth/index.ts CHANGED
@@ -212,9 +212,11 @@ export {
212
212
  challenge,
213
213
  protectedResourceMetadata,
214
214
  MCP_SCOPE,
215
+ type ChallengeError,
215
216
  type ResourceIdentity,
216
217
  } from './oauth/metadata.ts';
217
- export { OAuthServer, matchesRegistered, pkceChallengeFor, type AuthorizeRequest, type OAuthResult } from './oauth/server.ts';
218
+ export { OAuthServer, pkceChallengeFor, type AuthorizeRequest, type OAuthResult } from './oauth/server.ts';
219
+ export { matchesRegistered } from './oauth/redirects.ts';
218
220
  export { OAuthStore, hashToken, randomToken } from './oauth/store.ts';
219
221
  export { OidcVerifier, type OidcVerifierOptions, type VerifiedSubject } from './oidc.ts';
220
222
  export { IssuedTokenAuthenticator, OidcAuthenticator } from './remote.ts';
@@ -22,21 +22,62 @@ export interface ResourceIdentity {
22
22
  }
23
23
 
24
24
  /**
25
- * One scope, and it is not a permission axis.
25
+ * Two scopes, and neither is a permission axis.
26
26
  *
27
27
  * What a caller may do is decided by the profile's policy, per capability, per
28
28
  * call, and recorded in the audit log. A second permission system expressed as
29
29
  * scopes could only either duplicate that or disagree with it, and a client
30
- * cannot be trusted to ask for less than it wants anyway. The scope exists
31
- * because the protocol has a slot for one.
30
+ * cannot be trusted to ask for less than it wants anyway. The scopes exist
31
+ * because the protocol has a slot for them, and because a client reads that
32
+ * slot to decide what this endpoint will do for it.
32
33
  */
33
34
  export const MCP_SCOPE = 'mcp';
34
35
 
36
+ /**
37
+ * OIDC Core §11's name for "issue me a refresh token", and the reason this
38
+ * endpoint stopped sending its owner back to a browser.
39
+ *
40
+ * A refresh token has always been issued here, unconditionally. What was missing
41
+ * was saying so. A client's requested scope defaults to whatever the *resource*
42
+ * document lists, and the reference MCP client appends `offline_access` only
43
+ * when the *authorization server* document advertises it:
44
+ *
45
+ * ```js
46
+ * let effectiveScope = requestedScope || resourceMetadata?.scopes_supported?.join(" ") || …
47
+ * if (effectiveScope && authServerMetadata?.scopes_supported?.includes("offline_access") && …)
48
+ * effectiveScope = `${effectiveScope} offline_access`;
49
+ * ```
50
+ *
51
+ * So both documents matter and they matter differently. Advertising it in
52
+ * neither left a client no grounds to request, persist, or use the refresh
53
+ * token it was being handed — and a client with no grounds reconnects, which
54
+ * means its owner approving in a browser.
55
+ */
56
+ export const OFFLINE_ACCESS_SCOPE = 'offline_access';
57
+
58
+ /** Everything grantable here. A request for anything else is narrowed, not refused. */
59
+ export const SUPPORTED_SCOPES = [MCP_SCOPE, OFFLINE_ACCESS_SCOPE] as const;
60
+
61
+ /**
62
+ * The grantable part of what was asked for.
63
+ *
64
+ * Empty means the request named nothing we recognise, and the caller falls back
65
+ * to `MCP_SCOPE` — refusing with `invalid_scope` would turn an unknown token in
66
+ * a client's default string into a connector that cannot be added at all, and
67
+ * scope is not the thing protecting anything here.
68
+ */
69
+ export function grantableScope(requested: string | null | undefined): string {
70
+ const asked = new Set((requested ?? '').split(/\s+/).filter(Boolean));
71
+ return SUPPORTED_SCOPES.filter((scope) => asked.has(scope)).join(' ');
72
+ }
73
+
35
74
  export function protectedResourceMetadata(identity: ResourceIdentity): Record<string, unknown> {
36
75
  return {
37
76
  resource: identity.resource,
38
77
  authorization_servers: [identity.issuer],
39
- scopes_supported: [MCP_SCOPE],
78
+ // Where a client's *default* requested scope comes from, so this is the
79
+ // list that decides what an untouched connector asks for.
80
+ scopes_supported: [...SUPPORTED_SCOPES],
40
81
  bearer_methods_supported: ['header'],
41
82
  };
42
83
  }
@@ -47,7 +88,9 @@ export function authorizationServerMetadata(origin: string): Record<string, unkn
47
88
  authorization_endpoint: `${origin}/authorize`,
48
89
  token_endpoint: `${origin}/token`,
49
90
  registration_endpoint: `${origin}/register`,
50
- scopes_supported: [MCP_SCOPE],
91
+ // And this is the list that gates whether `offline_access` is appended at
92
+ // all. Both documents have to carry it; neither one alone is enough.
93
+ scopes_supported: [...SUPPORTED_SCOPES],
51
94
  response_types_supported: ['code'],
52
95
  grant_types_supported: ['authorization_code', 'refresh_token'],
53
96
  // Advertised because a spec-compliant client checks for it before starting
@@ -60,6 +103,27 @@ export function authorizationServerMetadata(origin: string): Record<string, unkn
60
103
  };
61
104
  }
62
105
 
106
+ /**
107
+ * Why a credential was refused, in RFC 6750 §3.1's vocabulary.
108
+ *
109
+ * There is one code worth sending and it carries the whole distinction a client
110
+ * needs: `invalid_token` says the credential was *rejected*, where an otherwise
111
+ * identical challenge says only that authorization is required. A client that
112
+ * cannot tell those apart cannot tell "refresh — you hold a refresh token for
113
+ * this" from "start a new authorization", and the safe-looking guess is the
114
+ * second, which means the owner approving in a browser for a credential a
115
+ * silent refresh would have replaced.
116
+ *
117
+ * Deliberately absent when nothing was presented. RFC 6750 §3: a resource
118
+ * server SHOULD NOT include an error code where the request carried no
119
+ * authentication information — and sending one would set a client refreshing a
120
+ * credential it does not have.
121
+ */
122
+ export interface ChallengeError {
123
+ readonly code: 'invalid_token';
124
+ readonly description: string;
125
+ }
126
+
63
127
  /**
64
128
  * The `WWW-Authenticate` value on a 401.
65
129
  *
@@ -67,9 +131,19 @@ export function authorizationServerMetadata(origin: string): Record<string, unkn
67
131
  * has to guess the document's location by probing well-known paths, which costs
68
132
  * round trips and fails entirely on a host that does not serve them. Clients do
69
133
  * not honour this header on a `200`, so the status has to be right too.
134
+ *
135
+ * It stays on the header even when a token was rejected. A client that decides
136
+ * to authorize after all — because the refresh was refused too — must not have
137
+ * to go and find the document a second time.
70
138
  */
71
- export function challenge(metadataUrl: string | null): string {
72
- return metadataUrl
73
- ? `Bearer realm="lanes-link", resource_metadata="${metadataUrl}"`
74
- : 'Bearer realm="lanes-link"';
139
+ export function challenge(metadataUrl: string | null, error?: ChallengeError): string {
140
+ // Every value is a quoted-string, so none may contain a quote. Both of these
141
+ // are constants in this repository and the types keep them that way.
142
+ const parts = [
143
+ 'realm="lanes-link"',
144
+ ...(error ? [`error="${error.code}"`, `error_description="${error.description}"`] : []),
145
+ ...(metadataUrl ? [`resource_metadata="${metadataUrl}"`] : []),
146
+ ];
147
+
148
+ return `Bearer ${parts.join(', ')}`;
75
149
  }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Which redirect URIs this server will send an authorization code to.
3
+ *
4
+ * Its own file because it is its own subject. `server.ts` is the flow as
5
+ * decisions — a code exchanged, a token rotated, an owner approving — and none
6
+ * of it is about URL shapes. Both halves stayed inside the file-size budget
7
+ * until they did not, and the budget exists to point at exactly this: it was
8
+ * not too long, it was two things.
9
+ *
10
+ * Nothing here consults configuration. What a client registered is checked
11
+ * against what it now presents, and the rules are RFC 8252's rather than ours.
12
+ */
13
+
14
+ /**
15
+ * https, or loopback for a native client.
16
+ *
17
+ * A native client cannot receive an https redirect, so RFC 8252 has it listen
18
+ * on a loopback port instead. Everything else is refused: a redirect to `http://`
19
+ * on a routable host puts an authorization code on the wire in clear text.
20
+ */
21
+ export function isSafeRedirect(uri: string): boolean {
22
+ let parsed: URL;
23
+ try {
24
+ parsed = new URL(uri);
25
+ } catch {
26
+ return false;
27
+ }
28
+
29
+ if (parsed.protocol === 'https:') return true;
30
+ return parsed.protocol === 'http:' && isLoopbackHost(parsed.hostname);
31
+ }
32
+
33
+ function isLoopbackHost(hostname: string): boolean {
34
+ return hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]' || hostname === 'localhost';
35
+ }
36
+
37
+ /**
38
+ * Exact match, except for the port of a loopback URI.
39
+ *
40
+ * RFC 8252 §7.3 requires ignoring the port for the IP-literal form, because a
41
+ * native client binds an ephemeral one it cannot know at registration time.
42
+ * Claude Code declares `http://localhost/callback` and `http://127.0.0.1/callback`
43
+ * and then listens on whatever port it got, so the same allowance has to cover
44
+ * `localhost` or it never connects.
45
+ */
46
+ export function matchesRegistered(candidate: string, registered: readonly string[]): boolean {
47
+ if (registered.includes(candidate)) return true;
48
+
49
+ let parsed: URL;
50
+ try {
51
+ parsed = new URL(candidate);
52
+ } catch {
53
+ return false;
54
+ }
55
+ if (!isLoopbackHost(parsed.hostname)) return false;
56
+
57
+ return registered.some((uri) => {
58
+ try {
59
+ const other = new URL(uri);
60
+ return (
61
+ isLoopbackHost(other.hostname) &&
62
+ other.protocol === parsed.protocol &&
63
+ other.hostname === parsed.hostname &&
64
+ other.pathname === parsed.pathname
65
+ );
66
+ } catch {
67
+ return false;
68
+ }
69
+ });
70
+ }
@@ -1,4 +1,5 @@
1
- import { MCP_SCOPE } from './metadata.ts';
1
+ import { grantableScope, MCP_SCOPE } from './metadata.ts';
2
+ import { isSafeRedirect, matchesRegistered } from './redirects.ts';
2
3
  import {
3
4
  hashToken,
4
5
  randomToken,
@@ -26,6 +27,21 @@ import {
26
27
  const CODE_TTL_MS = 60_000;
27
28
  const REFRESH_TTL_MS = 30 * 24 * 60 * 60 * 1000;
28
29
 
30
+ /**
31
+ * How long a spent refresh token still answers.
32
+ *
33
+ * A client whose refresh succeeded but whose *response* was lost holds a token
34
+ * the server has already spent, and retrying with it is the only move it has.
35
+ * Without a window that retry is `invalid_grant`, and the reference MCP client
36
+ * rethrows every `OAuthError` but `server_error` rather than recovering — so
37
+ * the connector dies and its owner is sent to a browser, over a network blip.
38
+ *
39
+ * Thirty seconds is the band Auth0's reuse interval (0–60 s) and Okta's grace
40
+ * period occupy. What it costs: a captured refresh token keeps working for up
41
+ * to this long after the real client next rotates it.
42
+ */
43
+ const REFRESH_REUSE_MS = 30_000;
44
+
29
45
  export type OAuthResult =
30
46
  | { readonly kind: 'json'; readonly status: number; readonly body: unknown }
31
47
  | { readonly kind: 'redirect'; readonly location: string }
@@ -61,6 +77,9 @@ export interface OAuthServerOptions {
61
77
  /** Proof of being the owner. The same token the endpoint already accepts. */
62
78
  readonly verifyOwner: (presented: string) => Promise<boolean>;
63
79
  readonly accessTokenTtlMs: number;
80
+ /** Where a replayed refresh token is recorded. Structural, because this layer
81
+ * may not import `#connectivity`; the endpoint's own logger satisfies it. */
82
+ readonly log?: { warn(message: string, detail?: Record<string, unknown>): void };
64
83
  readonly now?: () => number;
65
84
  }
66
85
 
@@ -163,7 +182,11 @@ export class OAuthServer {
163
182
  redirectUri,
164
183
  codeChallenge: challenge,
165
184
  state: params.get('state') ?? undefined,
166
- scope: params.get('scope') || MCP_SCOPE,
185
+ // The grantable part of what was asked for, not the request verbatim.
186
+ // Echoing it back through `#issue` was granting by echo, which was inert
187
+ // while `mcp` was the only scope and stops being inert now that there is
188
+ // a second one that means something.
189
+ scope: grantableScope(params.get('scope')) || MCP_SCOPE,
167
190
  resource: params.get('resource') ?? undefined,
168
191
  },
169
192
  };
@@ -198,7 +221,12 @@ export class OAuthServer {
198
221
  clientId: request.clientId,
199
222
  redirectUri: request.redirectUri,
200
223
  codeChallenge: request.codeChallenge,
201
- scope: request.scope,
224
+ // Narrowed here as well as in `authorize`, and this is the one that
225
+ // matters: the request arrives back through hidden form fields, so a
226
+ // caller can post any scope it likes straight to this endpoint. Nothing
227
+ // round-tripped through the form is trusted — the client and the redirect
228
+ // URI are re-checked above for the same reason.
229
+ scope: grantableScope(request.scope) || MCP_SCOPE,
202
230
  ...(request.resource ? { resource: request.resource } : {}),
203
231
  expiresAt: this.#now() + CODE_TTL_MS,
204
232
  };
@@ -252,15 +280,25 @@ export class OAuthServer {
252
280
  return invalid('invalid_grant', 'That refresh token is unknown or expired.');
253
281
  }
254
282
 
255
- // A spent token presented again is the one signal that it has been copied.
256
- // Rotation alone does not answer it: whoever refreshes first walks away
257
- // with a live pair, and rejecting only the token in hand leaves that pair
258
- // working while the other party usually the real client — is locked out.
259
- // So the whole chain goes. A client retrying a response it never saw and a
260
- // thief replaying are indistinguishable from here, and re-authorising is
261
- // the cheaper of the two mistakes.
283
+ // A spent token presented again used to take its whole family with it, on
284
+ // the reading that a replay is a theft. Against a real connector that was
285
+ // wrong twice over, and ADR-035 has the evidence. Two answers replace it,
286
+ // and the tombstone's age is what tells them apart.
262
287
  if (record.kind === 'consumed') {
263
- await this.#options.store.revokeFamily(record.family);
288
+ // Inside the window it is a retry of a request already answered, and the
289
+ // client is owed the answer rather than a dead connector. Not re-consumed:
290
+ // a client retrying twice is still retrying.
291
+ const spentAt = record.consumedAt;
292
+ if (spentAt !== undefined && this.#now() - spentAt <= REFRESH_REUSE_MS) {
293
+ return this.#issue(record.clientId, record.scope, randomToken('llr'), record.family);
294
+ }
295
+
296
+ // Outside it, refused on its own — and the family survives, which is the
297
+ // half that was taking live sessions down with it.
298
+ this.#options.log?.warn('refresh token replayed', {
299
+ clientId: record.clientId,
300
+ family: record.family,
301
+ });
264
302
  return invalid('invalid_grant', 'That refresh token has already been used.');
265
303
  }
266
304
 
@@ -324,62 +362,4 @@ function invalid(error: string, description: string): OAuthResult {
324
362
  return { kind: 'json', status: 400, body: { error, error_description: description } };
325
363
  }
326
364
 
327
- /**
328
- * https, or loopback for a native client.
329
- *
330
- * A native client cannot receive an https redirect, so RFC 8252 has it listen
331
- * on a loopback port instead. Everything else is refused: a redirect to `http://`
332
- * on a routable host puts an authorization code on the wire in clear text.
333
- */
334
- function isSafeRedirect(uri: string): boolean {
335
- let parsed: URL;
336
- try {
337
- parsed = new URL(uri);
338
- } catch {
339
- return false;
340
- }
341
-
342
- if (parsed.protocol === 'https:') return true;
343
- return parsed.protocol === 'http:' && isLoopbackHost(parsed.hostname);
344
- }
345
-
346
- function isLoopbackHost(hostname: string): boolean {
347
- return hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]' || hostname === 'localhost';
348
- }
349
-
350
- /**
351
- * Exact match, except for the port of a loopback URI.
352
- *
353
- * RFC 8252 §7.3 requires ignoring the port for the IP-literal form, because a
354
- * native client binds an ephemeral one it cannot know at registration time.
355
- * Claude Code declares `http://localhost/callback` and `http://127.0.0.1/callback`
356
- * and then listens on whatever port it got, so the same allowance has to cover
357
- * `localhost` or it never connects.
358
- */
359
- export function matchesRegistered(candidate: string, registered: readonly string[]): boolean {
360
- if (registered.includes(candidate)) return true;
361
-
362
- let parsed: URL;
363
- try {
364
- parsed = new URL(candidate);
365
- } catch {
366
- return false;
367
- }
368
- if (!isLoopbackHost(parsed.hostname)) return false;
369
-
370
- return registered.some((uri) => {
371
- try {
372
- const other = new URL(uri);
373
- return (
374
- isLoopbackHost(other.hostname) &&
375
- other.protocol === parsed.protocol &&
376
- other.hostname === parsed.hostname &&
377
- other.pathname === parsed.pathname
378
- );
379
- } catch {
380
- return false;
381
- }
382
- });
383
- }
384
-
385
365
  export { hashToken };
@@ -53,6 +53,9 @@ export interface AuthorizationCode {
53
53
  * it arrived. A tombstone keeps the family id and nothing else useful, and it
54
54
  * opens no more than a deleted row does — every check that admits a credential
55
55
  * tests for `access` by name.
56
+ *
57
+ * What is *done* about a detected replay changed in ADR-035: the presented
58
+ * token is refused and the replay logged, rather than the family revoked.
56
59
  */
57
60
  export type TokenKind = 'access' | 'refresh' | 'consumed';
58
61
 
@@ -71,6 +74,15 @@ export interface IssuedToken {
71
74
  * the theft and the retry look identical from here.
72
75
  */
73
76
  readonly family: string;
77
+ /**
78
+ * When this token was spent, on a `consumed` tombstone and nowhere else.
79
+ *
80
+ * What makes the reuse interval possible: without it a spent token carries no
81
+ * hint whether it was spent a second ago or a month ago, and those are a retry
82
+ * and a replay. A tombstone written before this existed has no `consumedAt`
83
+ * and is read as the older one, which is the safe direction.
84
+ */
85
+ readonly consumedAt?: number;
74
86
  }
75
87
 
76
88
  export function hashToken(value: string): string {
@@ -182,16 +194,18 @@ export class OAuthStore {
182
194
  const key = hashToken(token);
183
195
  const record = await this.#read<IssuedToken>(TOKENS, key);
184
196
  if (!record) return;
185
- await this.#state.set(TOKENS, key, JSON.stringify({ ...record, kind: 'consumed' }));
197
+ const spent: IssuedToken = { ...record, kind: 'consumed', consumedAt: this.#now() };
198
+ await this.#state.set(TOKENS, key, JSON.stringify(spent));
186
199
  }
187
200
 
188
201
  /**
189
202
  * Drop every token in a refresh family.
190
203
  *
191
- * Called when a rotated-away refresh token is presented again, which is
192
- * either a client retrying or a thief replaying. Both are answered the same
193
- * way, because from here they are indistinguishable and the safe reading is
194
- * the expensive one.
204
+ * A replay no longer calls this, and a replay was the only thing that did
205
+ * see `OAuthServer.#refresh` and ADR-035. Kept because it is the shape a
206
+ * deliberate revocation takes: one authorization's whole chain, dropped on
207
+ * purpose. Nothing in `src/` reaches it today, so read a call site as new
208
+ * policy rather than as the old one returning.
195
209
  */
196
210
  async revokeFamily(family: string): Promise<void> {
197
211
  for (const key of await this.#state.keys(TOKENS)) {
@@ -10,6 +10,7 @@ import {
10
10
  toPolicyDocument,
11
11
  } from '#registry';
12
12
  import { announce, emit, fail, ok, print, warn } from '../../output.ts';
13
+ import { staleNudge } from '../../release.ts';
13
14
  import { capabilityDiff, discoveryProbe } from '../../runtime/discovery.ts';
14
15
  import { openRuntime, resolveProfile, type GlobalFlags } from '../../runtime.ts';
15
16
 
@@ -189,6 +190,12 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
189
190
  });
190
191
  }
191
192
 
193
+ // Every finding above is about this profile; this one is about the binary
194
+ // reading it. Silent when the registry cannot be reached — `doctor` is
195
+ // expected to work on a plane, and "could not check" is not a finding.
196
+ const stale = await staleNudge();
197
+ if (stale !== null) warnings.push({ kind: 'stale_release', message: stale });
198
+
192
199
  await reportCapabilityDrift(runtime, (message) =>
193
200
  warnings.push({ kind: 'capability_drift', message }),
194
201
  );
@@ -1,6 +1,7 @@
1
1
  import { startEndpoint } from '#server/endpoint.ts';
2
2
  import { streamLogger } from '#server/logging.ts';
3
3
  import { announce, ok, print, style, warn } from '../../output.ts';
4
+ import { staleNudge } from '../../release.ts';
4
5
  import { resolveProfile, type GlobalFlags } from '../../runtime.ts';
5
6
 
6
7
  /** `lanes link start` — reconcile, then serve every profile on one endpoint. */
@@ -41,6 +42,12 @@ export async function start(
41
42
 
42
43
  print(ok(`serving ${style.bold(endpoint.url)}`));
43
44
  print(style.dim(` profiles: ${endpoint.profiles.join(', ')}`));
45
+
46
+ // Last, not first: the endpoint is what someone ran this for, and a version
47
+ // note in front of it would be the first thing they read and the least useful.
48
+ const stale = await staleNudge();
49
+ if (stale !== null) print(warn(stale));
50
+
44
51
  print(style.dim('Ctrl-C to stop.'));
45
52
 
46
53
  const shutdown = async (): Promise<void> => {
@@ -0,0 +1,255 @@
1
+ import { homedir } from 'node:os';
2
+ import { join, sep } from 'node:path';
3
+ import { installRoot } from '#profile';
4
+ import { emit, fail, ok, print, printErr, progress, style, warn } from '../output.ts';
5
+ import { PACKAGE, release, type ReleaseState } from '../release.ts';
6
+ import { version } from '../version.ts';
7
+
8
+ /**
9
+ * `lanes link update` — install the newer release, or say why it will not.
10
+ *
11
+ * There is no build step and no compiled artifact, so updating means exactly
12
+ * one thing: replace the installed package directory with a newer tarball from
13
+ * the registry. `bin/lanes` resolves its own symlink chain and execs Bun on the
14
+ * `src/` inside that directory, so the shipped source *is* the running code and
15
+ * the symlink on the PATH never has to move.
16
+ *
17
+ * Bun is the only installer this drives. `bun install -g @lanes-sh/link` is the
18
+ * only install documented, `engines.bun` requires it, and the shim refuses to
19
+ * run without it — so inferring a package manager would be machinery serving a
20
+ * case nobody is told to create. The case that does exist is handled below
21
+ * rather than ignored: an `npm i -g` install updated with Bun gets a second
22
+ * copy somewhere else on the PATH, which this detects and reports instead of
23
+ * doing quietly.
24
+ *
25
+ * Nothing here is control plane — it touches the install, not a profile — so it
26
+ * resolves no profile and no target, and is the second command after `version`
27
+ * that prints no `announce` line.
28
+ */
29
+
30
+ export interface UpdateFlags {
31
+ /** Report and exit without installing anything. */
32
+ readonly check?: boolean | undefined;
33
+ readonly json?: boolean | undefined;
34
+ }
35
+
36
+ /**
37
+ * What `update` would do, and why.
38
+ *
39
+ * `'checkout'` is a refusal: `bun link` puts a checkout on the same PATH entry
40
+ * a published install would occupy, so installing from the registry there would
41
+ * leave two copies of this CLI and no indication of which one answers. `git
42
+ * pull` is the update in a checkout, and saying so is more useful than doing
43
+ * something surprising.
44
+ */
45
+ export type UpdateAction = 'install' | 'current' | 'ahead' | 'checkout' | 'unknown';
46
+
47
+ export interface UpdateDecision {
48
+ readonly action: UpdateAction;
49
+ /** The argv to run, or `null` when nothing should be run. */
50
+ readonly install: readonly string[] | null;
51
+ readonly message: string;
52
+ /** Something true and unwelcome about this install, if there is anything. */
53
+ readonly warning: string | null;
54
+ }
55
+
56
+ export interface UpdateInput {
57
+ readonly installed: string;
58
+ readonly latest: string | null;
59
+ readonly state: ReleaseState;
60
+ /** Where this CLI is installed — `installRoot()`, not the workspace. */
61
+ readonly root: string;
62
+ /** Where Bun keeps global installs, so a copy landing elsewhere is visible. */
63
+ readonly bunGlobal: string;
64
+ }
65
+
66
+ /**
67
+ * The whole decision, as a function of five strings.
68
+ *
69
+ * Split from the spawn because the alternative is a command whose only test is
70
+ * one that replaces the copy of this CLI on the machine running the suite. Every
71
+ * branch below is reachable from `update.test.ts` with no network and no
72
+ * subprocess — including the stale branch, which the checkout this is written in
73
+ * can never reach on its own, being by definition the newest thing there is.
74
+ */
75
+ export function updatePlan(input: UpdateInput): UpdateDecision {
76
+ const { installed, latest, state, root, bunGlobal } = input;
77
+
78
+ // A published install lives under `node_modules`; a checkout does not. Cheaper
79
+ // and steadier than looking for `.git`, which a tarball could carry and a
80
+ // shallow export could lack.
81
+ const published = root.split(sep).includes('node_modules');
82
+
83
+ if (!published) {
84
+ return {
85
+ action: 'checkout',
86
+ install: null,
87
+ message: `${root} is a checkout, not an install — git pull is the update here`,
88
+ warning: null,
89
+ };
90
+ }
91
+
92
+ if (state === 'unknown') {
93
+ return {
94
+ action: 'unknown',
95
+ install: null,
96
+ message:
97
+ latest === null
98
+ ? `could not reach the registry — ${installed} is installed`
99
+ : `cannot compare ${installed} against ${latest}`,
100
+ warning: null,
101
+ };
102
+ }
103
+
104
+ if (state === 'ahead') {
105
+ return {
106
+ action: 'ahead',
107
+ install: null,
108
+ message: `${installed} is installed, ahead of the published ${latest ?? 'release'}`,
109
+ warning: null,
110
+ };
111
+ }
112
+
113
+ if (state === 'current') {
114
+ return { action: 'current', install: null, message: `${installed} is current`, warning: null };
115
+ }
116
+
117
+ // Installed by npm, updated by Bun: `bun install -g` writes into its own
118
+ // global prefix and leaves the npm copy where it is, so both are on the PATH
119
+ // and its order decides which one answers. Worth saying before, not after.
120
+ const elsewhere = !root.startsWith(bunGlobal + sep);
121
+
122
+ return {
123
+ action: 'install',
124
+ install: ['install', '-g', PACKAGE],
125
+ message: `${installed} installed, ${latest} available`,
126
+ warning: elsewhere
127
+ ? `this copy is at ${root}, which is not under ${bunGlobal} — ` +
128
+ 'Bun will install a second copy there rather than replace this one, ' +
129
+ 'and your PATH order decides which one answers'
130
+ : null,
131
+ };
132
+ }
133
+
134
+ /** Where Bun keeps global installs, honouring `BUN_INSTALL`. */
135
+ export function bunGlobalRoot(env: Record<string, string | undefined> = process.env): string {
136
+ return join(env['BUN_INSTALL'] ?? join(homedir(), '.bun'), 'install', 'global');
137
+ }
138
+
139
+ export async function update(flags: UpdateFlags): Promise<void> {
140
+ const current = await release();
141
+ const root = installRoot(import.meta.dir);
142
+ const decision = updatePlan({
143
+ installed: current.installed,
144
+ latest: current.latest,
145
+ state: current.state,
146
+ root,
147
+ bunGlobal: bunGlobalRoot(),
148
+ });
149
+
150
+ // A gate wants a non-zero exit for the one state that needs action. An
151
+ // unreachable registry is not that state — failing a build because a network
152
+ // was down would make this the flakiest check in it.
153
+ if (flags.check === true && decision.action === 'install') process.exitCode = 1;
154
+
155
+ const report = {
156
+ installed: current.installed,
157
+ latest: current.latest,
158
+ state: current.state,
159
+ action: decision.action,
160
+ root,
161
+ ...(decision.install !== null ? { install: `bun ${decision.install.join(' ')}` } : {}),
162
+ ...(decision.warning !== null ? { warning: decision.warning } : {}),
163
+ };
164
+
165
+ if (flags.check === true || decision.action !== 'install') {
166
+ return emit(flags.json, report, () => {
167
+ if (decision.action === 'install') {
168
+ print(warn(decision.message));
169
+ if (decision.warning !== null) print(style.dim(` ${decision.warning}`));
170
+ print(style.dim(' run: lanes link update'));
171
+ return;
172
+ }
173
+
174
+ // Green for the two states that need nothing. A refusal and an
175
+ // unreachable registry are neither wrong nor fine, and `ok` would claim
176
+ // the second of those.
177
+ if (decision.action === 'current' || decision.action === 'ahead') {
178
+ print(ok(decision.message));
179
+ return;
180
+ }
181
+
182
+ print(style.dim(decision.message));
183
+ });
184
+ }
185
+
186
+ // Stderr, both of them. What this command produces is the version change, and
187
+ // with `--json` that is a document — a line of prose in front of it corrupts
188
+ // it for whatever is parsing, which is the whole reason `emit` exists.
189
+ if (decision.warning !== null) progress(warn(decision.warning));
190
+ progress(style.dim(`bun ${decision.install!.join(' ')}`));
191
+
192
+ const installed = await runInstall(decision.install!, flags.json === true);
193
+ if (!installed) {
194
+ printErr(fail('the install did not complete — nothing was changed'));
195
+ process.exitCode = 1;
196
+ return;
197
+ }
198
+
199
+ // Read the version back off disk rather than trusting the exit code. `version()`
200
+ // reads `package.json` from the install root at call time, so this is the one
201
+ // question worth asking after a successful install: did *this* copy change?
202
+ // Unchanged after a clean install is the second-copy case above, seen from the
203
+ // other side.
204
+ const landed = version();
205
+
206
+ return emit(
207
+ flags.json,
208
+ {
209
+ ...report,
210
+ // The action was `install`; this is what came of it. Inventing a third
211
+ // action value would describe an outcome as a decision.
212
+ result: landed === current.installed ? 'unchanged' : 'installed',
213
+ installed: landed,
214
+ previous: current.installed,
215
+ },
216
+ () => {
217
+ if (landed === current.installed) {
218
+ print(warn(`bun reported success, but ${root} is still ${landed}`));
219
+ print(style.dim(' the copy it installed is somewhere else on your PATH'));
220
+ print(style.dim(' check with: which -a lanes'));
221
+ return;
222
+ }
223
+
224
+ print(ok(`${current.installed} → ${style.bold(landed)}`));
225
+ print(style.dim(' a running endpoint serves the old code until it is restarted'));
226
+ },
227
+ );
228
+ }
229
+
230
+ /**
231
+ * Hand the install to Bun and let it own the terminal.
232
+ *
233
+ * `process.execPath` rather than `Bun.which('bun')`: this process is already
234
+ * running under the Bun that should do the installing, and a PATH lookup can
235
+ * find a different one — which would resolve the dependency set with a
236
+ * different resolver than the one that will run the result.
237
+ *
238
+ * Output is inherited rather than captured. Bun prints its own progress and its
239
+ * own errors, and paraphrasing a package manager's failure is how a report ends
240
+ * up less useful than the thing it replaced. Its stdout is dropped under
241
+ * `--json` for the same reason the lines above go to stderr: the document on
242
+ * stdout has to be the only thing on stdout. Its stderr is kept either way,
243
+ * because a failure is worth reading in both modes.
244
+ */
245
+ async function runInstall(argv: readonly string[], json: boolean): Promise<boolean> {
246
+ try {
247
+ const child = Bun.spawn([process.execPath, ...argv], {
248
+ stdout: json ? 'ignore' : 'inherit',
249
+ stderr: 'inherit',
250
+ });
251
+ return (await child.exited) === 0;
252
+ } catch {
253
+ return false;
254
+ }
255
+ }
package/src/cli/main.ts CHANGED
@@ -38,6 +38,7 @@ import {
38
38
  vaultRemove,
39
39
  vaultSet,
40
40
  } from './commands/owner.ts';
41
+ import { update } from './commands/update.ts';
41
42
  import { globalFlags, ownerFlags, parseArgv, text } from './argv.ts';
42
43
  import { PROGRAM, USAGE } from './usage.ts';
43
44
  import { version } from './version.ts';
@@ -335,6 +336,9 @@ export async function run(argv: readonly string[]): Promise<void> {
335
336
  print(version());
336
337
  return;
337
338
 
339
+ case 'update':
340
+ return update({ check: flags['check'] === true, json });
341
+
338
342
  default:
339
343
  throw new Error(`Unknown command "${first}". Run: ${PROGRAM} help`);
340
344
  }
@@ -0,0 +1,111 @@
1
+ import { version } from './version.ts';
2
+
3
+ /**
4
+ * Whether a newer release than this one has been published.
5
+ *
6
+ * `version.ts` answers which release is installed, which stopped being the
7
+ * whole question when this started shipping from npm: two machines can now sit
8
+ * a release apart with nothing on either to say so, and the only upgrade
9
+ * affordance in the tree was a `contract` mismatch telling someone to "Upgrade
10
+ * lanes-link" without naming a command.
11
+ *
12
+ * Every function here degrades to `null` or `'unknown'` rather than throwing. A
13
+ * version check is never the reason a command fails — `doctor`, `start`, and
14
+ * `deploy` each print one line from this and must all work on a plane.
15
+ */
16
+
17
+ /** The npm package this CLI ships as, and the only thing `update` will install. */
18
+ export const PACKAGE = '@lanes-sh/link';
19
+
20
+ /**
21
+ * The dist-tags document, not the packument.
22
+ *
23
+ * `registry.npmjs.org/<name>` carries every version ever published with its
24
+ * full manifest — hundreds of kilobytes to answer a question whose answer is
25
+ * eighteen bytes. This endpoint returns `{"latest":"0.2.0"}` and nothing else.
26
+ */
27
+ const DIST_TAGS = `https://registry.npmjs.org/-/package/${encodeURIComponent(PACKAGE)}/dist-tags`;
28
+
29
+ /**
30
+ * The same budget `endpointHealth` gives its probe.
31
+ *
32
+ * Long enough for a warm connection, short enough that a command which only
33
+ * mentions staleness in passing does not appear to hang on a captive-portal
34
+ * network that accepts the connection and then says nothing.
35
+ */
36
+ const PROBE_TIMEOUT_MS = 700;
37
+
38
+ export type ReleaseState = 'current' | 'stale' | 'ahead' | 'unknown';
39
+
40
+ export interface Release {
41
+ readonly installed: string;
42
+ /** `null` when the registry could not be reached, or answered something else. */
43
+ readonly latest: string | null;
44
+ readonly state: ReleaseState;
45
+ }
46
+
47
+ /**
48
+ * How the installed version stands against the published one.
49
+ *
50
+ * `'ahead'` is not a mistake: a contributor running from a checkout is usually
51
+ * a version ahead of the registry, and telling them they are behind would be
52
+ * both wrong and the thing they see most often.
53
+ *
54
+ * Pure, and separate from the fetch, so every branch is testable without a
55
+ * network — which is the only way the stale path gets covered at all, given the
56
+ * checkout this is written in is by definition current.
57
+ */
58
+ export function releaseState(installed: string, latest: string | null): ReleaseState {
59
+ if (latest === null) return 'unknown';
60
+
61
+ try {
62
+ const order = Bun.semver.order(installed, latest);
63
+ return order === 0 ? 'current' : order < 0 ? 'stale' : 'ahead';
64
+ } catch {
65
+ // `Bun.semver.order` throws on anything it cannot parse rather than
66
+ // ordering it arbitrarily. A registry that answers with something other
67
+ // than a version, or a hand-edited `package.json`, is an unknown state and
68
+ // not a reason to claim either answer.
69
+ return 'unknown';
70
+ }
71
+ }
72
+
73
+ /** What the registry calls `latest`, or `null` if it did not say. */
74
+ export async function latestRelease(): Promise<string | null> {
75
+ try {
76
+ const response = await fetch(DIST_TAGS, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
77
+ if (!response.ok) return null;
78
+
79
+ const body = (await response.json()) as { latest?: unknown };
80
+ return typeof body.latest === 'string' ? body.latest : null;
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ /** The installed version, the published one, and how they stand. */
87
+ export async function release(): Promise<Release> {
88
+ const installed = version();
89
+ const latest = await latestRelease();
90
+
91
+ return { installed, latest, state: releaseState(installed, latest) };
92
+ }
93
+
94
+ /**
95
+ * The one line `doctor`, `start`, and `deploy` print when this install is behind.
96
+ *
97
+ * One string in one place, because three commands saying it three ways is how
98
+ * two of them end up naming a command that has been renamed. `null` for every
99
+ * state but `'stale'`: nothing is worth saying about an install that is current,
100
+ * and an unreachable registry is not news.
101
+ */
102
+ export function staleLine(current: Release): string | null {
103
+ if (current.state !== 'stale') return null;
104
+
105
+ return `${current.installed} is installed, ${current.latest} is out — run: lanes link update`;
106
+ }
107
+
108
+ /** `staleLine` over a fresh probe, for a caller that has no `Release` in hand. */
109
+ export async function staleNudge(): Promise<string | null> {
110
+ return staleLine(await release());
111
+ }
package/src/cli/usage.ts CHANGED
@@ -90,6 +90,7 @@ ${style.bold('Inspection')}
90
90
  ${PROGRAM} audit verify has anything in the log been altered or removed
91
91
  ${PROGRAM} config show
92
92
  ${PROGRAM} version which release this is — same as lanes --version
93
+ ${PROGRAM} update [--check] [--json] install the newer release, or say what is available
93
94
 
94
95
  ${style.bold('Attachments')}
95
96
  ${PROGRAM} attach <file> --connection <provider>.<account>
@@ -1,5 +1,6 @@
1
1
  import { ConfigError, resolveDeployTarget, type DeployConfig } from '#profile';
2
2
  import { announce, fail, heading, ok, print, style, warn } from '#cli/output.ts';
3
+ import { staleNudge } from '#cli/release.ts';
3
4
  import { confirm, isInteractive } from '#cli/prompt.ts';
4
5
  import { openSecretStoreFor, resolveProfile, type GlobalFlags } from '#cli/runtime.ts';
5
6
  import { resolveTarget, vaultEnv } from './bootstrap.ts';
@@ -57,6 +58,12 @@ export async function deploy(flags: DeployFlags): Promise<void> {
57
58
  // rejected on boot should be rejected here, not after a five-minute build.
58
59
  print(ok(`${resolution.profilePath} is valid`));
59
60
 
61
+ // The CLI planning this rollout, not the image it will build. An old one
62
+ // plans an old rollout, and a build is the most expensive place to find that
63
+ // out.
64
+ const stale = await staleNudge();
65
+ if (stale !== null) print(warn(stale));
66
+
60
67
  const declared = await resolveTarget({
61
68
  config,
62
69
  profilePath: resolution.profilePath,
@@ -113,6 +113,12 @@ export function deployPlan(input: PlanInput): DeployStep[] {
113
113
  // harness can mint one — so a target reached by a remote MCP client
114
114
  // declares `public` and gates the request in the application instead.
115
115
  cloudrun.access === 'iam' ? '--no-allow-unauthenticated' : '--allow-unauthenticated',
116
+ // Always passed, including the zero. Config is the source of truth here
117
+ // (ADR-004), and a flag sent only when non-zero would let a value be
118
+ // raised and never lowered — the revision would keep whatever the last
119
+ // deploy that bothered to mention it had set.
120
+ '--min-instances',
121
+ String(cloudrun.min_instances),
116
122
  ],
117
123
  },
118
124
  ];
@@ -127,6 +127,9 @@ export async function surveyCloudRun(input: SurveyInput): Promise<SurveyResult>
127
127
 
128
128
  const deploy: DeployConfig = {
129
129
  platform: 'cloudrun',
130
+ // Not asked about. Zero is right for almost every target and the question
131
+ // would cost every operator a decision to buy one of them a knob.
132
+ min_instances: current.min_instances ?? 0,
130
133
  project,
131
134
  region,
132
135
  service,
@@ -34,11 +34,20 @@ const selfAuthorizationSchema = z.object({
34
34
  /**
35
35
  * How long an issued access token lives.
36
36
  *
37
- * Short by design and refreshed rather than lengthened: a client that holds a
38
- * long-lived token has something worth stealing, and the refresh path is the
39
- * one that can be revoked by dropping a row.
37
+ * Twelve hours, where this used to be one. The old reading short by design,
38
+ * refreshed rather than lengthened assumed the refresh happens. Expiry is
39
+ * in practice where a remote client loses its session: one observed against
40
+ * this endpoint let its access token lapse and reported needing authorization
41
+ * while the matching refresh token sat in the store unused, weeks from its
42
+ * own expiry. That is the client's bug and nothing here can fix it. What the
43
+ * endpoint can do is stop offering the chance twenty-four times a day.
44
+ *
45
+ * What is given up is real and bounded: a stolen access token is useful for
46
+ * longer. The revocable half is unchanged — the refresh path is still what a
47
+ * dropped row closes — and the refresh token was already the longer-lived of
48
+ * the pair at thirty days. `1440` is the ceiling, for one window a day.
40
49
  */
41
- access_token_ttl_minutes: z.number().int().positive().max(1440).default(60),
50
+ access_token_ttl_minutes: z.number().int().positive().max(1440).default(720),
42
51
  });
43
52
 
44
53
  const oidcAuthorizationSchema = z.object({
@@ -178,6 +178,20 @@ export const deployTargetSchema = z.object({
178
178
  billing_account: z.string().optional(),
179
179
  /** The identity the running revision assumes. Needs read access to the credential store. */
180
180
  service_account: z.string().optional(),
181
+ /**
182
+ * Instances kept running when nothing is calling.
183
+ *
184
+ * Zero is the default and the right answer for almost everything here: a cold
185
+ * start on the MCP path measures under three seconds, and the platform queues
186
+ * the request behind it, so scaling to zero is invisible to a caller.
187
+ *
188
+ * It is a knob because one path is not a caller. A client refreshes its token
189
+ * exactly when it wakes after an idle gap — which is exactly when the instance
190
+ * is cold — and a refresh that fails at the network level sends a remote
191
+ * client through a fresh browser authorization rather than surfacing an error.
192
+ * Raise it if a re-authorization ever lines up with a cold `/token`.
193
+ */
194
+ min_instances: z.number().int().min(0).max(10).default(0),
181
195
  });
182
196
 
183
197
  /**
@@ -221,7 +235,18 @@ export const targetSchema = z
221
235
  .transform(({ cloudrun, ...target }) =>
222
236
  target.deploy || !cloudrun
223
237
  ? target
224
- : { ...target, deploy: { ...cloudrun, platform: 'cloudrun' as const, access: 'iam' as const } },
238
+ : {
239
+ ...target,
240
+ // The pre-`deploy` spelling predates both of these, so it gets the
241
+ // same defaults the current one would: the closed door, and no
242
+ // instance kept warm.
243
+ deploy: {
244
+ ...cloudrun,
245
+ platform: 'cloudrun' as const,
246
+ access: 'iam' as const,
247
+ min_instances: 0,
248
+ },
249
+ },
225
250
  );
226
251
 
227
252
  /**
@@ -169,6 +169,7 @@ function closeAll(runtimes: ReadonlyMap<string, Runtime>): Promise<unknown> {
169
169
  */
170
170
  async function openAuthorization(
171
171
  primary: Runtime,
172
+ log: Logger,
172
173
  ): Promise<{ surface: AuthorizationSurface; authenticator: Authenticator } | null> {
173
174
  const declared = primary.config.auth.authorization;
174
175
  if (!declared) return null;
@@ -210,6 +211,10 @@ async function openAuthorization(
210
211
  const server = new OAuthServer({
211
212
  store,
212
213
  accessTokenTtlMs: declared.access_token_ttl_minutes * 60_000,
214
+ // So a replayed refresh token leaves a line. It is refused rather than
215
+ // acted on (ADR-035), and a refusal nobody can see is how a connector
216
+ // losing its authorization came to need log forensics to explain.
217
+ log,
213
218
  // Approval is proof of holding the endpoint token, compared the same way
214
219
  // the request path compares it. There is one person behind this endpoint
215
220
  // and they already have exactly one credential; a second one invented for
@@ -228,6 +233,7 @@ async function openAuthorization(
228
233
 
229
234
  export async function startEndpoint(options: EndpointOptions): Promise<RunningEndpoint> {
230
235
  const reporter = options.reporter ?? SILENT;
236
+ const log = options.log ?? silentLogger();
231
237
  const { primary, runtimes } = await openReconciled(options);
232
238
 
233
239
  try {
@@ -251,7 +257,7 @@ export async function startEndpoint(options: EndpointOptions): Promise<RunningEn
251
257
  }
252
258
  }
253
259
 
254
- const gate = await openAuthorization(primary);
260
+ const gate = await openAuthorization(primary, log);
255
261
 
256
262
  // The authenticator and the authorization gate are built once, from the
257
263
  // runtime this endpoint booted with, and are deliberately not part of what
@@ -273,7 +279,10 @@ export async function startEndpoint(options: EndpointOptions): Promise<RunningEn
273
279
  // not refresh skills`, and every `mcp handler error` the endpoint raises
274
280
  // all went to those empty methods. A silent endpoint is not a quiet one —
275
281
  // it is one whose failures have to be reconstructed from request sizes.
276
- { primary: primary.resolution.profile, log: options.log ?? silentLogger() },
282
+ // `remoteClients` is the gate's existence, not a second setting: a profile
283
+ // declaring `auth.authorization` is one a connector reaches by URL, which
284
+ // is exactly the client the extra paragraph is written for.
285
+ { primary: primary.resolution.profile, log, ...(gate ? { remoteClients: true } : {}) },
277
286
  );
278
287
 
279
288
  const server = serve({
@@ -282,7 +291,7 @@ export async function startEndpoint(options: EndpointOptions): Promise<RunningEn
282
291
  authenticator: gate
283
292
  ? new AuthenticatorChain([primary.authenticator, gate.authenticator])
284
293
  : primary.authenticator,
285
- log: options.log ?? silentLogger(),
294
+ log,
286
295
  ...(gate ? { authorization: gate.surface } : {}),
287
296
  ...(options.port !== undefined ? { port: options.port } : {}),
288
297
  ...(options.host !== undefined ? { host: options.host } : {}),
@@ -204,6 +204,7 @@ export class Generation {
204
204
  principal,
205
205
  clientLabel,
206
206
  ...(this.#deps.version ? { version: this.#deps.version } : {}),
207
+ ...(this.#deps.remoteClients ? { remoteClients: true } : {}),
207
208
  }),
208
209
  {
209
210
  onerror: (error: Error) =>
@@ -52,6 +52,8 @@ export interface GenerationDeps {
52
52
  readonly primary: string;
53
53
  readonly log: Logger;
54
54
  readonly version?: string | undefined;
55
+ /** Whether an authorization surface is published. See `BuildServerOptions`. */
56
+ readonly remoteClients?: boolean | undefined;
55
57
  }
56
58
 
57
59
  /** What a reload did, as the `/reload` route reports it. */
@@ -86,6 +86,14 @@ export { parseConfig } from '#profile';
86
86
  export interface HarnessOptions {
87
87
  profile: string;
88
88
  log?: Logger;
89
+ /**
90
+ * The clock the authorization server and its store share.
91
+ *
92
+ * Shared deliberately: a tombstone's `consumedAt` is written by the store and
93
+ * compared by the server, so two clocks would make the reuse interval
94
+ * untestable in the one direction that matters. Absent means `Date.now`.
95
+ */
96
+ now?: () => number;
89
97
  port: number;
90
98
  policy: string;
91
99
  token?: string;
@@ -218,13 +226,17 @@ export function startHarness(options: HarnessOptions): Harness {
218
226
  // The real wiring from `endpoint.ts`, not a stand-in: the flow under test is
219
227
  // the one a connector drives over HTTP, and a fake authorization server would
220
228
  // demonstrate that the fake works.
221
- const store = options.authorization ? new OAuthStore(state.kv) : null;
229
+ const log = options.log ?? silentLogger();
230
+
231
+ const store = options.authorization ? new OAuthStore(state.kv, options.now) : null;
222
232
  const gate = store
223
233
  ? {
224
234
  surface: {
225
235
  server: new OAuthServer({
226
236
  store,
227
237
  accessTokenTtlMs: 3_600_000,
238
+ log,
239
+ ...(options.now ? { now: options.now } : {}),
228
240
  verifyOwner: (presented) => Promise.resolve(tokensMatch(presented, token)),
229
241
  }),
230
242
  issuer: (origin: string) => origin,
@@ -235,8 +247,6 @@ export function startHarness(options: HarnessOptions): Harness {
235
247
  }
236
248
  : null;
237
249
 
238
- const log = options.log ?? silentLogger();
239
-
240
250
  const nothing = () => Promise.resolve();
241
251
  const generations = new Generations(
242
252
  { profiles, close: nothing },
@@ -1,4 +1,4 @@
1
- import { challenge, type Authenticator } from '#auth';
1
+ import { challenge, type Authenticator, type AuthOutcome, type ChallengeError } from '#auth';
2
2
  import type { Logger } from '#connectivity';
3
3
  import { capabilityIdForToolName } from '#server/mcp';
4
4
  import { ATTACHMENTS_PATH, stageAttachment } from './attachments.ts';
@@ -51,6 +51,34 @@ export interface ServerOptions {
51
51
  readonly allowedHostnames?: readonly string[] | undefined;
52
52
  }
53
53
 
54
+ type RefusalReason = Extract<AuthOutcome, { ok: false }>['reason'];
55
+
56
+ /**
57
+ * What a caller should do about each refusal.
58
+ *
59
+ * `invalid` is the only one a client can act on by itself: it presented a
60
+ * credential and this endpoint did not accept it, which is what a refresh is
61
+ * for. RFC 6750 §3.1 has a name for that and clients branch on it; the others
62
+ * mean there is nothing to refresh, and §3 says to stay quiet rather than send
63
+ * a client after a token it does not hold. `malformed` says nothing either —
64
+ * `invalid_request` carries a SHOULD of a 400 status, and changing that path's
65
+ * status is a larger question than this answers.
66
+ */
67
+ const CHALLENGE: Partial<Record<RefusalReason, ChallengeError>> = {
68
+ invalid: {
69
+ code: 'invalid_token',
70
+ description: 'The credential is expired, revoked, or not one this endpoint issued.',
71
+ },
72
+ };
73
+
74
+ /** The same four, for whoever is reading the body rather than the header. */
75
+ const HINTS: Record<RefusalReason, string> = {
76
+ missing: 'Present the profile token as: Authorization: Bearer <token>',
77
+ malformed: 'Present the profile token as: Authorization: Bearer <token>',
78
+ invalid: 'Refresh the credential. Authorize again only if the refresh is refused too.',
79
+ not_configured: 'This profile has no token yet. Run: lanes link token rotate',
80
+ };
81
+
54
82
  export const MCP_PATH = '/mcp';
55
83
  export const RELOAD_PATH = '/reload';
56
84
 
@@ -174,16 +202,13 @@ export function createRequestHandler(options: ServerOptions): RequestHandler {
174
202
  JSON.stringify({
175
203
  error: 'unauthorized',
176
204
  reason: outcome.reason,
177
- hint:
178
- outcome.reason === 'not_configured'
179
- ? 'This profile has no token yet. Run: lanes link token rotate'
180
- : 'Present the profile token as: Authorization: Bearer <token>',
205
+ hint: HINTS[outcome.reason],
181
206
  }),
182
207
  {
183
208
  status: 401,
184
209
  headers: {
185
210
  'content-type': 'application/json',
186
- 'www-authenticate': challenge(metadata),
211
+ 'www-authenticate': challenge(metadata, CHALLENGE[outcome.reason]),
187
212
  },
188
213
  },
189
214
  );
@@ -46,7 +46,7 @@ export function buildMcpServer(options: BuildServerOptions): McpServer {
46
46
  // and `Implementation` would take it as an unknown extra and drop it from
47
47
  // `initialize` without complaining.
48
48
  {
49
- instructions: serverInstructions(names, merged),
49
+ instructions: serverInstructions(names, merged, options.remoteClients),
50
50
  // Declared `false` because it is false, and the SDK defaults it to `true`.
51
51
  //
52
52
  // `listChanged` is a promise to send `notifications/tools/list_changed`
@@ -34,10 +34,10 @@ import type { MergedCapability } from './visibility.ts';
34
34
  /**
35
35
  * The habits, in the order they are needed.
36
36
  *
37
- * Routing first because it gates every call; refusals last because that is when
38
- * an agent is most tempted to improvise. Second person, and specific about what
39
- * *not* to do — "ask which profile" is advice, "do not default to the first" is
40
- * a rule.
37
+ * Routing first because it gates every call; the two ways a call ends badly last,
38
+ * because that is when an agent is most tempted to improvise. Second person, and
39
+ * specific about what *not* to do — "ask which profile" is advice, "do not
40
+ * default to the first" is a rule.
41
41
  *
42
42
  * **Four of these are conditional**, and that is a correctness property rather
43
43
  * than a saving. This used to be one fixed string that told every client to
@@ -95,6 +95,27 @@ const REFUSAL = `**A refused call is the permission system working**, not an obs
95
95
  around. Report what was refused and let the owner decide whether to widen it.
96
96
  Every call, including a refused one, is recorded.`;
97
97
 
98
+ /**
99
+ * The one about not reaching here at all.
100
+ *
101
+ * Only for a client that authorises against this endpoint over the network —
102
+ * the one that cannot be handed the bundled skill, and the one whose connector
103
+ * decides on its own whether this endpoint is available. Observed: with the
104
+ * endpoint up and idle, a connector reported it unreachable without issuing a
105
+ * request at all, and the model read that as a fault, then re-derived an answer
106
+ * it had already given and re-composed an entry it had already written. Nothing
107
+ * here can prevent it, because nothing here is consulted — the call never
108
+ * arrives. Telling the model what the state means is the whole of what is left.
109
+ *
110
+ * Deliberately *not* "the endpoint is asleep". Usually it is not, and prose
111
+ * asserting a cause the model cannot check is how a wrong diagnosis gets
112
+ * repeated with confidence.
113
+ */
114
+ const AVAILABILITY = `**A call may simply not go through.** This endpoint is one machine its owner
115
+ runs, and a client can report it unreachable while it is up. That is ordinary —
116
+ not a fault to diagnose, and not authorization you have lost. Say the call did
117
+ not land, do not redo what already succeeded, and offer to retry.`;
118
+
98
119
  /** Which paragraph each owner-layer provider brings, when it is reachable. */
99
120
  const OWNER_HABITS: Record<string, string> = {
100
121
  memory: MEMORY,
@@ -111,6 +132,11 @@ const OWNER_HABITS: Record<string, string> = {
111
132
  * it is the prompt to ask whether the paragraph belongs in the skill instead,
112
133
  * where it is loaded only when relevant.
113
134
  *
135
+ * It was raised once, from 2000, for `AVAILABILITY` — and that question was
136
+ * asked and answered the other way: the client that paragraph exists for is
137
+ * precisely the one that holds no skills directory, so the skill is not a place
138
+ * it can go. Only an endpoint serving remote clients spends it.
139
+ *
114
140
  * Exported because the test asserted `2000` as a literal while the code
115
141
  * reserved room against a second, differently-derived number — so the two could
116
142
  * disagree, and did. There is no separate listing allowance any more: `spent`
@@ -118,7 +144,7 @@ const OWNER_HABITS: Record<string, string> = {
118
144
  * exactly the final length, because `join` adds the same two characters the
119
145
  * reduce already counted.
120
146
  */
121
- export const MAX_INSTRUCTIONS = 2000;
147
+ export const MAX_INSTRUCTIONS = 2300;
122
148
 
123
149
  /** Which of the owner-layer providers this principal can actually reach. */
124
150
  function ownerProviders(merged: ReadonlyMap<string, MergedCapability>): string[] {
@@ -172,6 +198,9 @@ function connectionsByProfile(
172
198
  export function serverInstructions(
173
199
  profiles: readonly string[],
174
200
  merged: ReadonlyMap<string, MergedCapability>,
201
+ /** Whether a client authorises against this endpoint rather than being handed
202
+ * a token — see `AVAILABILITY`, the only paragraph that reads it. */
203
+ remoteClients = false,
175
204
  ): string {
176
205
  const reachable = connectionsByProfile(profiles, merged);
177
206
  const owner = ownerProviders(merged);
@@ -185,6 +214,7 @@ export function serverInstructions(
185
214
  ...owner.map((id) => OWNER_HABITS[id]).filter((habit): habit is string => habit !== undefined),
186
215
  FILES,
187
216
  REFUSAL,
217
+ ...(remoteClients ? [AVAILABILITY] : []),
188
218
  ];
189
219
 
190
220
  if (reachable.size === 0) {
@@ -53,6 +53,15 @@ export interface BuildServerOptions {
53
53
  /** Self-reported by the client. Recorded in audit; never used to authorize. */
54
54
  readonly clientLabel?: string | undefined;
55
55
  readonly version?: string;
56
+ /**
57
+ * Whether this endpoint publishes an authorization surface, and therefore
58
+ * serves clients that arrived by URL alone.
59
+ *
60
+ * Read only by the instructions, which gain a paragraph for them. Absent over
61
+ * a pipe and on a loopback endpoint, where the client holds the skill and the
62
+ * transport cannot fail the way this describes.
63
+ */
64
+ readonly remoteClients?: boolean | undefined;
56
65
  }
57
66
 
58
67
  /** One profile as the map the builder wants. */