@lanes-sh/link 0.6.8 → 0.6.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/.gcloudignore +50 -0
  2. package/README.md +5 -0
  3. package/package.json +4 -3
  4. package/src/auth/oidc.ts +65 -12
  5. package/src/cli/brand.ts +16 -9
  6. package/src/cli/commands/operate/auth.ts +333 -0
  7. package/src/cli/commands/operate/desktop.ts +220 -0
  8. package/src/cli/commands/operate/findings.ts +9 -38
  9. package/src/cli/commands/operate/inspect.ts +59 -40
  10. package/src/cli/commands/operate/serve.ts +0 -3
  11. package/src/cli/commands/operate.ts +5 -3
  12. package/src/cli/main.ts +19 -4
  13. package/src/cli/selection.ts +15 -4
  14. package/src/cli/usage.ts +5 -1
  15. package/src/connectivity/auth/index.ts +1 -0
  16. package/src/connectivity/auth/oauth-authcode/provider.ts +17 -1
  17. package/src/connectivity/auth/oauth-authcode/refresh.ts +43 -11
  18. package/src/connectivity/auth/oauth-jwt/index.ts +12 -1
  19. package/src/connectivity/auth/reauth.ts +48 -0
  20. package/src/deployments/gcp/Dockerfile +27 -2
  21. package/src/deployments/gcp/bucket.ts +82 -9
  22. package/src/deployments/gcp/driver.ts +43 -5
  23. package/src/deployments/gcp/lifecycle.json +12 -0
  24. package/src/deployments/gcp/survey.ts +12 -1
  25. package/src/policy/limits.ts +76 -3
  26. package/src/profile/index.ts +1 -0
  27. package/src/profile/legacy.ts +8 -3
  28. package/src/profile/schema.ts +65 -0
  29. package/src/server/cors.ts +3 -3
  30. package/src/server/edge.ts +188 -1
  31. package/src/server/endpoint.ts +0 -17
  32. package/src/server/harness.ts +10 -7
  33. package/src/server/index.ts +62 -90
  34. package/src/server/mcp/index.ts +0 -1
  35. package/src/server/mcp/visibility.ts +0 -33
  36. package/src/server/oauth.ts +21 -2
  37. package/src/cli/commands/operate/dashboard.ts +0 -107
  38. package/src/cli/dashboard-page.ts +0 -293
  39. package/src/cli/dashboard-shell.ts +0 -125
  40. package/src/cli/provider-marks.ts +0 -45
  41. package/src/server/dashboard.ts +0 -212
@@ -217,9 +217,9 @@ function withCors(response: Response, headers: Record<string, string>): Response
217
217
  *
218
218
  * Wrapped at `serve()` rather than inside the router, which is also where the
219
219
  * policy is decided: cross-origin access is a property of the address this is
220
- * bound to, exactly as `allowedHostnames` and the dashboard are, and putting all
221
- * three in one function is what makes the loopback exclusion legible instead of
222
- * an invariant spread across two files.
220
+ * bound to, exactly as `allowedHostnames` is, and putting both in one function
221
+ * is what makes the loopback exclusion legible instead of an invariant spread
222
+ * across two files.
223
223
  *
224
224
  * It is what makes the ordering safe, too. A preflight answered here never
225
225
  * reaches the rebinding guard inside `inner` — and does not need to, because a
@@ -1,3 +1,4 @@
1
+ import { challenge, type AuthOutcome, type ChallengeError } from '#auth';
1
2
  import { RateLimiter } from '#policy';
2
3
 
3
4
  /**
@@ -15,6 +16,56 @@ import { RateLimiter } from '#policy';
15
16
  */
16
17
  export const FAILED_AUTH_PER_MINUTE = 30;
17
18
 
19
+ /**
20
+ * A ceiling on the surface that answers *before* authentication.
21
+ *
22
+ * The limit above sits behind the bearer gate, and for a long time that was the
23
+ * whole of it — which left the four things that answer in front of the gate with
24
+ * no ceiling at all. On a `public` deployment every one of them costs the owner
25
+ * something, and none of them needs a credential to reach:
26
+ *
27
+ * - `/health` presented with a credential re-reads the credential store, which
28
+ * on a deployed target is a Secret Manager call — two, when the value the
29
+ * process cached does not match, because a mismatch against a cached value
30
+ * forces the re-read that makes a rotation take effect.
31
+ * - `/register` writes an object to the workspace bucket, then lists two
32
+ * namespaces to decide whether anything needs evicting.
33
+ * - `/authorize` compares against the endpoint token, which is another read of
34
+ * the credential store.
35
+ * - `/token` reads and writes bucket objects.
36
+ *
37
+ * `/health` presented with *no* credential is deliberately free: it reads
38
+ * nothing, and it is what a platform probe and `lanes link outputs` send.
39
+ * Metering it would put a ceiling on the one request that costs nothing to
40
+ * answer.
41
+ */
42
+ export const UNAUTHENTICATED_PER_MINUTE = 30;
43
+
44
+ /**
45
+ * The same ceiling for the endpoint as a whole, keyed on nothing.
46
+ *
47
+ * **This is the one that actually holds.** The per-caller key below is the first
48
+ * `X-Forwarded-For` hop, which anyone talking to the endpoint directly can write
49
+ * as they please — so a per-caller limit alone bounds only a caller who is not
50
+ * trying, and rotating the header walks straight through it.
51
+ *
52
+ * A shared bucket has the opposite problem: whoever is spending it locks
53
+ * everyone else out, which is why `index.ts` refuses to key the *failed-auth*
54
+ * limit that way. Here the trade is different, because what is behind these
55
+ * paths is not the owner's ability to use their endpoint — a client that has
56
+ * already authorised holds a token and never comes back through them — it is a
57
+ * discovery document, a registration, and a consent screen. Losing those for a
58
+ * minute is an authorization that has to be retried. Not losing them costs a
59
+ * stranger's arbitrary spend against the credential store.
60
+ *
61
+ * Two hundred a minute is far above what an authorization flow uses: a connector
62
+ * being added is a handful of requests, once.
63
+ */
64
+ export const UNAUTHENTICATED_TOTAL_PER_MINUTE = 200;
65
+
66
+ /** The key the endpoint-wide bucket is held under. Constant on purpose. */
67
+ const EVERYONE = 'endpoint';
68
+
18
69
  /**
19
70
  * Who an attempt is counted against.
20
71
  *
@@ -47,7 +98,143 @@ export function tooManyAttempts(retryAfterMs: number): Response {
47
98
  );
48
99
  }
49
100
 
50
- /** One bucket set per endpoint. Idle callers are dropped so keys do not accumulate. */
101
+ /**
102
+ * One bucket set per endpoint.
103
+ *
104
+ * The map is bounded by `RateLimiter` itself rather than by a caller
105
+ * remembering to prune it. This comment used to say idle callers were dropped
106
+ * "so keys do not accumulate", and nothing anywhere called `prune` — so on a
107
+ * public URL the map grew one entry per distinct `X-Forwarded-For` for as long
108
+ * as the process lived, which is a header a stranger writes.
109
+ */
51
110
  export function failedAuthLimiter(): RateLimiter {
52
111
  return new RateLimiter();
53
112
  }
113
+
114
+ /** The same, for the pre-authentication surface. Separate so one cannot spend the other. */
115
+ export function unauthenticatedLimiter(): RateLimiter {
116
+ return new RateLimiter();
117
+ }
118
+
119
+ /**
120
+ * The refusal for a pre-authentication request over budget, or `undefined` to
121
+ * let it through.
122
+ *
123
+ * One function rather than a branch in the router, for the reason `./cors.ts`
124
+ * and `./oauth.ts` are their own files: `index.ts` gains a delegation instead of
125
+ * the whole of this behaviour, and it stays inside the size budget that would
126
+ * otherwise be the rule relaxed to fit this in.
127
+ *
128
+ * `isAuthorizationPath` is passed rather than imported so this file does not
129
+ * reach back into the router's path constants — `corsAware` takes the same shape
130
+ * for the same reason.
131
+ *
132
+ * Two buckets are taken rather than one short-circuiting the other, so a caller
133
+ * that has exhausted its own budget still counts against the endpoint's: the
134
+ * alternative lets a flood of distinct forwarded-for values leave the shared
135
+ * bucket untouched, which is precisely the case the shared bucket exists for.
136
+ */
137
+ export function unauthenticatedRefusal(input: {
138
+ readonly request: Request;
139
+ readonly pathname: string;
140
+ readonly limiter: RateLimiter;
141
+ readonly healthPath: string;
142
+ readonly isAuthorizationPath: (pathname: string) => boolean;
143
+ readonly authorizationEnabled: boolean;
144
+ }): Response | undefined {
145
+ // A `/health` carrying no credential reads nothing and is deliberately free —
146
+ // it is what a platform probe and `lanes link outputs` send, and a ceiling on
147
+ // the one request that costs nothing to answer is an outage waiting for an
148
+ // attack that did not have to cause one.
149
+ const costly =
150
+ input.pathname === input.healthPath
151
+ ? input.request.headers.get('authorization') !== null
152
+ : input.authorizationEnabled && input.isAuthorizationPath(input.pathname);
153
+
154
+ if (!costly) return undefined;
155
+
156
+ const caller = callerKey(input.request);
157
+ const mine = input.limiter.take(`caller:${caller}`, UNAUTHENTICATED_PER_MINUTE);
158
+ const everyone = input.limiter.take(EVERYONE, UNAUTHENTICATED_TOTAL_PER_MINUTE);
159
+ if (mine.allowed && everyone.allowed) return undefined;
160
+
161
+ return tooManyAttempts(Math.max(mine.retryAfterMs, everyone.retryAfterMs));
162
+ }
163
+
164
+ type RefusalReason = Extract<AuthOutcome, { ok: false }>['reason'];
165
+
166
+ /**
167
+ * What a caller should do about each refusal.
168
+ *
169
+ * `invalid` is the only one a client can act on by itself: it presented a
170
+ * credential and this endpoint did not accept it, which is what a refresh is
171
+ * for. RFC 6750 §3.1 has a name for that and clients branch on it; the others
172
+ * mean there is nothing to refresh, and §3 says to stay quiet rather than send
173
+ * a client after a token it does not hold. `malformed` says nothing either —
174
+ * `invalid_request` carries a SHOULD of a 400 status, and changing that path's
175
+ * status is a larger question than this answers.
176
+ */
177
+ const CHALLENGE: Partial<Record<RefusalReason, ChallengeError>> = {
178
+ invalid: {
179
+ code: 'invalid_token',
180
+ description: 'The credential is expired, revoked, or not one this endpoint issued.',
181
+ },
182
+ };
183
+
184
+ /** The same four, for whoever is reading the body rather than the header. */
185
+ const HINTS: Record<RefusalReason, string> = {
186
+ missing: 'Present the profile token as: Authorization: Bearer <token>',
187
+ malformed: 'Present the profile token as: Authorization: Bearer <token>',
188
+ invalid: 'Refresh the credential. Authorize again only if the refresh is refused too.',
189
+ not_configured: 'This profile has no token yet. Run: lanes link token rotate',
190
+ };
191
+
192
+ /**
193
+ * The `401`, with whatever the caller can act on.
194
+ *
195
+ * Here rather than in the router because it is the same subject as the two
196
+ * ceilings above — what this endpoint does about a caller who has not
197
+ * authenticated — and because the router was over its size budget carrying both.
198
+ * The vocabulary is RFC 6750's on the header and plain English in the body, so
199
+ * whoever is reading a terminal and whoever is writing a client each get the
200
+ * version they can use.
201
+ */
202
+ function unauthorized(reason: RefusalReason, metadataUrl: string | null): Response {
203
+ return new Response(
204
+ JSON.stringify({ error: 'unauthorized', reason, hint: HINTS[reason] }),
205
+ {
206
+ status: 401,
207
+ headers: {
208
+ 'content-type': 'application/json',
209
+ 'www-authenticate': challenge(metadataUrl, CHALLENGE[reason]),
210
+ },
211
+ },
212
+ );
213
+ }
214
+
215
+ /**
216
+ * What a caller who failed authentication gets: the `401`, or the `429` once
217
+ * they have failed too often.
218
+ *
219
+ * The ceiling is spent **after** the attempt rather than before it. Keyed on the
220
+ * caller alone, anyone able to reach the endpoint could spend the owner's budget
221
+ * and lock them out, which trades a cost problem for a worse availability one.
222
+ * Only a failure consumes a token, so a valid credential is never refused by
223
+ * this.
224
+ *
225
+ * `metadataUrl` is the whole handshake for a remote client: it reads the named
226
+ * document, finds the authorization server, and starts a flow. Without it the
227
+ * client has to guess the document's location, and a client that guesses wrong
228
+ * reports the endpoint as unreachable.
229
+ */
230
+ export function authRefusal(input: {
231
+ readonly request: Request;
232
+ readonly reason: RefusalReason;
233
+ readonly limiter: RateLimiter;
234
+ readonly metadataUrl: string | null;
235
+ }): Response {
236
+ const budget = input.limiter.take(callerKey(input.request), FAILED_AUTH_PER_MINUTE);
237
+ return budget.allowed
238
+ ? unauthorized(input.reason, input.metadataUrl)
239
+ : tooManyAttempts(budget.retryAfterMs);
240
+ }
@@ -77,16 +77,6 @@ export interface EndpointOptions {
77
77
  readonly reporter?: EndpointReporter | undefined;
78
78
  /** Operational events. Silent when absent, which is what the tests want. */
79
79
  readonly log?: Logger | undefined;
80
- /**
81
- * Serve the dashboard at `/dashboard`.
82
- *
83
- * True for `lanes link start`, absent in a container — the same split as
84
- * `mintToken`, and for a related reason. A deployed instance has no door a
85
- * browser can come through (ADR-018), so a page there would be either
86
- * unreachable or unguarded depending on `deploy.access`, and both are worse
87
- * than not having one.
88
- */
89
- readonly dashboard?: boolean | undefined;
90
80
  }
91
81
 
92
82
  export interface RunningEndpoint {
@@ -184,12 +174,6 @@ function profileRuntimes(runtimes: ReadonlyMap<string, Runtime>): Map<string, Pr
184
174
  // through `skills.manage.write`, or by `lanes link skills add` in another
185
175
  // terminal — is a prompt without a restart (ADR-014).
186
176
  refreshSkills: runtime.refreshSkills,
187
- // For a surface that reports rather than dispatches. A thunk rather
188
- // than a snapshot because a reconcile lands between requests, and the
189
- // dashboard reading a list captured at boot would keep showing an
190
- // account as unauthorized after the connect that fixed it.
191
- target: runtime.target,
192
- connections: () => runtime.state.connections.list(),
193
177
  },
194
178
  ]),
195
179
  );
@@ -336,7 +320,6 @@ export async function startEndpoint(options: EndpointOptions): Promise<RunningEn
336
320
  : primary.authenticator,
337
321
  log,
338
322
  ...(gate ? { authorization: gate.surface } : {}),
339
- ...(options.dashboard ? { dashboard: true } : {}),
340
323
  ...(options.port !== undefined ? { port: options.port } : {}),
341
324
  ...(options.host !== undefined ? { host: options.host } : {}),
342
325
  });
@@ -116,8 +116,15 @@ export interface HarnessOptions {
116
116
  * serving the old generation" case is reached; absent means nothing new.
117
117
  */
118
118
  reopen?: () => Promise<ReadonlyMap<string, ProfileRuntime>>;
119
- /** Serve `/dashboard`, as `lanes link start` does and a container never does. */
120
- dashboard?: boolean;
119
+ /**
120
+ * Meter the pre-authentication surface, as a routable deployment does.
121
+ *
122
+ * A harness binds loopback, where `serve()` leaves this off — the ceiling
123
+ * protects a credential-store call over the network and an object written to a
124
+ * bucket, and on loopback both are a local file. Set it to drive the deployed
125
+ * behaviour without a deployment.
126
+ */
127
+ meterUnauthenticated?: boolean;
121
128
  }
122
129
 
123
130
  /**
@@ -178,10 +185,6 @@ export function wireProfiles(options: HarnessOptions): WiredProfiles {
178
185
  dispatcher,
179
186
  policy,
180
187
  ...(options.refreshSkills ? { refreshSkills: () => options.refreshSkills!(registry) } : {}),
181
- // As `profileRuntimes` supplies them for real. A harness that claims to
182
- // be the real wiring and omits a field leaves that field untested.
183
- target: 'local',
184
- connections: () => state.connections.list(),
185
188
  }),
186
189
  );
187
190
 
@@ -260,7 +263,7 @@ export function startHarness(options: HarnessOptions): Harness {
260
263
  primary: options.profile,
261
264
  authenticator: gate ? new AuthenticatorChain([bearer, gate.authenticator]) : bearer,
262
265
  ...(gate ? { authorization: gate.surface } : {}),
263
- ...(options.dashboard ? { dashboard: true } : {}),
266
+ ...(options.meterUnauthenticated ? { meterUnauthenticated: true } : {}),
264
267
  log,
265
268
  });
266
269
 
@@ -1,18 +1,17 @@
1
- import { challenge, type Authenticator, type AuthOutcome, type ChallengeError } from '#auth';
1
+ import type { Authenticator } 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';
5
5
  import { allowedHostnamesFor, rebindingRefusal } from './rebinding.ts';
6
6
  import { ANY_ORIGIN, corsAware, type CorsPolicy } from './cors.ts';
7
- import {
8
- DASHBOARD_PATH,
9
- dashboardSessions,
10
- handleDashboard,
11
- servesDashboard,
12
- } from './dashboard.ts';
13
7
  import type { Generation } from './generation.ts';
14
8
  import type { Generations } from './generations.ts';
15
- import { callerKey, failedAuthLimiter, FAILED_AUTH_PER_MINUTE, tooManyAttempts } from './edge.ts';
9
+ import {
10
+ authRefusal,
11
+ failedAuthLimiter,
12
+ unauthenticatedLimiter,
13
+ unauthenticatedRefusal,
14
+ } from './edge.ts';
16
15
  import {
17
16
  handleAuthorization,
18
17
  isAuthorizationPath,
@@ -57,46 +56,28 @@ export interface ServerOptions {
57
56
  /** Hostnames this endpoint answers to. See `./rebinding.ts`. */
58
57
  readonly allowedHostnames?: readonly string[] | undefined;
59
58
  /**
60
- * Serve the dashboard.
59
+ * Meter the surface that answers before authentication.
61
60
  *
62
- * Off unless asked for, and `serve()` withholds it anyway when the bind
63
- * address is not loopback. `lanes link start` asks; `container.ts` does not.
64
- * See `./dashboard.ts` for why a deployed instance has no browser-shaped door
65
- * to put it behind.
61
+ * A property of what this is bound to, exactly as `cors` and
62
+ * `allowedHostnames` are, and decided in the same lines of `serve()`. Off on
63
+ * loopback, and that is not a gap: what the ceiling protects is a
64
+ * credential-store call over the network and an object written to a bucket,
65
+ * and on loopback both are a local file belonging to whoever is already
66
+ * standing at the machine. `./rebinding.ts` refuses the one caller that is not
67
+ * — a page the owner happens to be visiting — before this would be reached.
66
68
  */
67
- readonly dashboard?: boolean | undefined;
69
+ readonly meterUnauthenticated?: boolean | undefined;
68
70
  }
69
71
 
70
- type RefusalReason = Extract<AuthOutcome, { ok: false }>['reason'];
71
-
72
- /**
73
- * What a caller should do about each refusal.
74
- *
75
- * `invalid` is the only one a client can act on by itself: it presented a
76
- * credential and this endpoint did not accept it, which is what a refresh is
77
- * for. RFC 6750 §3.1 has a name for that and clients branch on it; the others
78
- * mean there is nothing to refresh, and §3 says to stay quiet rather than send
79
- * a client after a token it does not hold. `malformed` says nothing either —
80
- * `invalid_request` carries a SHOULD of a 400 status, and changing that path's
81
- * status is a larger question than this answers.
82
- */
83
- const CHALLENGE: Partial<Record<RefusalReason, ChallengeError>> = {
84
- invalid: {
85
- code: 'invalid_token',
86
- description: 'The credential is expired, revoked, or not one this endpoint issued.',
87
- },
88
- };
89
-
90
- /** The same four, for whoever is reading the body rather than the header. */
91
- const HINTS: Record<RefusalReason, string> = {
92
- missing: 'Present the profile token as: Authorization: Bearer <token>',
93
- malformed: 'Present the profile token as: Authorization: Bearer <token>',
94
- invalid: 'Refresh the credential. Authorize again only if the refresh is refused too.',
95
- not_configured: 'This profile has no token yet. Run: lanes link token rotate',
96
- };
97
-
98
72
  export const MCP_PATH = '/mcp';
99
73
  export const RELOAD_PATH = '/reload';
74
+ /**
75
+ * Named rather than inline, because two places now decide about it: the branch
76
+ * that answers it, and the ceiling in front of that branch which has to know
77
+ * that a `/health` carrying a credential is not the free request the other one
78
+ * is.
79
+ */
80
+ export const HEALTH_PATH = '/health';
100
81
 
101
82
  /**
102
83
  * Loopback addresses. What binding to one changes is the browser threat model,
@@ -126,7 +107,7 @@ export interface RequestHandler {
126
107
  export function createRequestHandler(options: ServerOptions): RequestHandler {
127
108
  let probedAt = 0;
128
109
  const failedAuth = failedAuthLimiter();
129
- const sessions = dashboardSessions();
110
+ const unauthenticated = options.meterUnauthenticated ? unauthenticatedLimiter() : null;
130
111
 
131
112
  /**
132
113
  * Re-read the config because a call named a tool we do not serve.
@@ -160,6 +141,27 @@ export function createRequestHandler(options: ServerOptions): RequestHandler {
160
141
 
161
142
  const url = new URL(request.url);
162
143
 
144
+ // Ahead of every path that answers without a credential, because the
145
+ // ceiling further down is inside the `401` branch and so has never covered
146
+ // any of them. Each costs a read of the credential store or a write to the
147
+ // workspace bucket, and none of them asks who is calling first. See
148
+ // `./edge.ts` for which ones, and for why `/health` is only sometimes
149
+ // among them.
150
+ if (unauthenticated) {
151
+ const refusal = unauthenticatedRefusal({
152
+ request,
153
+ pathname: url.pathname,
154
+ limiter: unauthenticated,
155
+ healthPath: HEALTH_PATH,
156
+ isAuthorizationPath,
157
+ authorizationEnabled: options.authorization !== undefined,
158
+ });
159
+ if (refusal) {
160
+ options.log.warn('rejected request', { reason: 'unauthenticated_rate' });
161
+ return refusal;
162
+ }
163
+ }
164
+
163
165
  // Before authentication, deliberately: a client's first request is the
164
166
  // one that discovers how to authenticate, so requiring a token to read
165
167
  // the document that says where tokens come from would close the loop it
@@ -168,7 +170,7 @@ export function createRequestHandler(options: ServerOptions): RequestHandler {
168
170
  return await handleAuthorization(request, options.authorization);
169
171
  }
170
172
 
171
- if (url.pathname === '/health') {
173
+ if (url.pathname === HEALTH_PATH) {
172
174
  // `status` is unauthenticated because the platform's own probe reads it
173
175
  // and a deploy waits on it. The profile *names* are not: on a public URL
174
176
  // that is a list of what this endpoint holds, handed to anyone who asks,
@@ -186,19 +188,6 @@ export function createRequestHandler(options: ServerOptions): RequestHandler {
186
188
  });
187
189
  }
188
190
 
189
- // Above the 404 gate and outside the bearer path below, because a
190
- // top-level browser navigation carries no `Authorization` header — so it
191
- // authenticates itself, against the same authenticator. Unset, this is
192
- // never reached and `/dashboard` is a 404 like any other unknown path.
193
- if (options.dashboard && url.pathname === DASHBOARD_PATH) {
194
- return await handleDashboard(request, {
195
- generations: options.generations,
196
- authenticator: options.authenticator,
197
- primary: options.primary,
198
- sessions,
199
- });
200
- }
201
-
202
191
  if (
203
192
  url.pathname !== MCP_PATH &&
204
193
  url.pathname !== ATTACHMENTS_PATH &&
@@ -211,37 +200,17 @@ export function createRequestHandler(options: ServerOptions): RequestHandler {
211
200
  request.headers.get('authorization'),
212
201
  );
213
202
 
203
+ // The refusal, and the ceiling on how often one may be provoked. Both in
204
+ // `./edge.ts`, which is the subject: what this endpoint does about a
205
+ // caller who has not authenticated.
214
206
  if (!outcome.ok) {
215
207
  options.log.warn('rejected request', { reason: outcome.reason });
216
-
217
- // After the attempt rather than before it: keyed on the caller alone,
218
- // anyone able to reach the endpoint could spend the owner's budget and
219
- // lock them out, which trades a cost problem for a worse availability
220
- // one. Only a failure consumes a token, so a valid credential is never
221
- // refused by this.
222
- const budget = failedAuth.take(callerKey(request), FAILED_AUTH_PER_MINUTE);
223
- if (!budget.allowed) return tooManyAttempts(budget.retryAfterMs);
224
-
225
- // The pointer is the whole handshake for a remote client: it reads the
226
- // named document, finds the authorization server, and starts a flow.
227
- // Without it the client has to guess the document's location, and a
228
- // client that guesses wrong reports the endpoint as unreachable.
229
- const metadata = options.authorization ? resourceMetadataUrl(request) : null;
230
-
231
- return new Response(
232
- JSON.stringify({
233
- error: 'unauthorized',
234
- reason: outcome.reason,
235
- hint: HINTS[outcome.reason],
236
- }),
237
- {
238
- status: 401,
239
- headers: {
240
- 'content-type': 'application/json',
241
- 'www-authenticate': challenge(metadata, CHALLENGE[outcome.reason]),
242
- },
243
- },
244
- );
208
+ return authRefusal({
209
+ request,
210
+ reason: outcome.reason,
211
+ limiter: failedAuth,
212
+ metadataUrl: options.authorization ? resourceMetadataUrl(request) : null,
213
+ });
245
214
  }
246
215
 
247
216
  // Behind the same bearer check as everything else, and deliberately not a
@@ -369,15 +338,18 @@ export function serve(options: ServeOptions): RunningServer {
369
338
  const allowedHostnames = options.allowedHostnames ?? allowedHostnamesFor(host, loopback);
370
339
 
371
340
  // Cross-origin access, and its absence, are decided here for the same reason
372
- // `allowedHostnames` and `dashboard` are: they are all properties of what this
373
- // is bound to. The two are mutually exclusive and the exclusion is the
374
- // decision — see `./cors.ts`, and ADR-039.
341
+ // `allowedHostnames` is: both are properties of what this is bound to. The
342
+ // two are mutually exclusive and the exclusion is the decision — see
343
+ // `./cors.ts`, and ADR-039.
375
344
  const cors: CorsPolicy | undefined = loopback
376
345
  ? undefined
377
346
  : { allowedOrigins: primary.config.auth.allowed_origins ?? [ANY_ORIGIN] };
378
347
  const handler = createRequestHandler({
379
348
  ...options,
380
- dashboard: servesDashboard(options.dashboard, loopback),
349
+ // Off on loopback for the same reason `cors` is undefined there, and decided
350
+ // here so every property of the bind address is decided together. An
351
+ // explicit `true` wins, which is how a test drives the deployed behaviour.
352
+ meterUnauthenticated: options.meterUnauthenticated ?? !loopback,
381
353
  ...(allowedHostnames ? { allowedHostnames } : {}),
382
354
  });
383
355
 
@@ -28,7 +28,6 @@ export {
28
28
  visibleCapabilities,
29
29
  visibleToolCount,
30
30
  type BuildServerOptions,
31
- type ConnectionState,
32
31
  type MergedCapability,
33
32
  type ProfileRuntime,
34
33
  } from './visibility.ts';
@@ -20,20 +20,6 @@ import { allowedConnections } from '#policy';
20
20
  * still a leak.
21
21
  */
22
22
 
23
- /**
24
- * A connection's reconciled state, as much of it as a reader needs.
25
- *
26
- * Structural rather than the store's own `ConnectionRecord`: `server` does not
27
- * import `stores` (`src/architecture.test.ts`), and what a surface that reports
28
- * wants from a connection is its key and whether it is working — not the
29
- * timestamps and credential expiry the repository keeps behind it.
30
- */
31
- export interface ConnectionState {
32
- readonly provider: string;
33
- readonly id: string;
34
- readonly status: string;
35
- }
36
-
37
23
  /** Everything one profile contributes to the endpoint. */
38
24
  export interface ProfileRuntime {
39
25
  readonly config: Config;
@@ -49,25 +35,6 @@ export interface ProfileRuntime {
49
35
  * how often to ask (ADR-014).
50
36
  */
51
37
  refreshSkills?(): Promise<void>;
52
- /**
53
- * Which target's adapters this profile was opened against.
54
- *
55
- * Not derivable from `config`: a target is *selected* per run, and the config
56
- * only says which one is the default. Optional for the same reason the one
57
- * below is — a runtime built to answer "what is visible" was never opened
58
- * against anything.
59
- */
60
- readonly target?: string | undefined;
61
- /**
62
- * Reconciled connection state, for a surface that reports rather than
63
- * dispatches.
64
- *
65
- * Optional exactly as `refreshSkills` is: only a served endpoint holds the
66
- * state handle this reads through, and nothing on the dispatch path asks —
67
- * a capability's visibility is decided by policy, not by whether the
68
- * credential behind it currently works.
69
- */
70
- connections?(): Promise<readonly ConnectionState[]>;
71
38
  }
72
39
 
73
40
  export interface BuildServerOptions {
@@ -62,11 +62,30 @@ export function isAuthorizationPath(pathname: string): boolean {
62
62
  * `http` and every metadata document would name a resource no client asked for
63
63
  * — which fails the exact-match the specification requires. Config cannot help
64
64
  * either: the hostname carries a project hash assigned at deploy time.
65
+ *
66
+ * **The host is `Host`, and `X-Forwarded-Host` is not consulted.** It used to
67
+ * be, ahead of `Host`, and the justification above is entirely about the
68
+ * *scheme* — nothing ever needed the other header. What it cost is that four
69
+ * documents were steerable per request by whoever sent it: both discovery
70
+ * documents, the `resource_metadata` pointer on every `401`, and the `action` of
71
+ * the consent form that asks the owner to paste their endpoint token. Cloud Run
72
+ * sets `Host` and routes on it, so it is the one value a caller cannot invent
73
+ * without the request going somewhere else; a proxy that genuinely rewrites it
74
+ * rewrites `Host` too, which is what a domain mapping does.
75
+ *
76
+ * The scheme is checked rather than trusted for the same reason, and it is a
77
+ * smaller hole — naming `http` in a document downgrades nothing, it just makes
78
+ * the exact-match fail — but a header that decides part of a URL should not
79
+ * accept an arbitrary string.
65
80
  */
66
81
  export function publicOrigin(request: Request): string {
67
82
  const url = new URL(request.url);
68
- const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host') ?? url.host;
69
- const proto = request.headers.get('x-forwarded-proto') ?? url.protocol.replace(':', '');
83
+ const host = request.headers.get('host') ?? url.host;
84
+
85
+ const forwarded = request.headers.get('x-forwarded-proto');
86
+ const proto =
87
+ forwarded === 'https' || forwarded === 'http' ? forwarded : url.protocol.replace(':', '');
88
+
70
89
  return `${proto}://${host}`;
71
90
  }
72
91