@lanes-sh/link 0.2.1 → 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.1",
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)) {
@@ -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. */