@lanes-sh/link 0.6.9 → 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.
package/.gcloudignore ADDED
@@ -0,0 +1,50 @@
1
+ # What `gcloud builds submit` uploads, and what it must not.
2
+ #
3
+ # **This file exists because `.dockerignore` is not consulted for the upload.**
4
+ # The build context is packed and sent to a Cloud Build staging bucket before any
5
+ # Dockerfile is read, so the first block of `.dockerignore` — which its own
6
+ # comment calls "a security control, not an image-size optimisation" — governs
7
+ # what reaches the *image* and says nothing about what reaches Google.
8
+ #
9
+ # Absent this file, gcloud derives its exclusions from `.gitignore` when the
10
+ # context happens to be a git checkout, and from nothing at all when it is not.
11
+ # `lanes link deploy` sends `installRoot`, which for the documented install
12
+ # method is a directory under `~/.bun` with no `.git` in it — so the safe
13
+ # behaviour was being inherited from a coincidence.
14
+ #
15
+ # Keep the first block in step with `.dockerignore`. `data/` holds the encrypted
16
+ # credential store *and* the key that opens it.
17
+ data/
18
+ *.key
19
+ *.pem
20
+ *.enc
21
+ *.p12
22
+ .env
23
+ .env.*
24
+
25
+ # Not needed to build, and `.git` in particular carries every branch.
26
+ .git/
27
+ .gitignore
28
+ .worktrees/
29
+ node_modules/
30
+ **/node_modules/
31
+ coverage/
32
+ dist/
33
+ build/
34
+ *.tsbuildinfo
35
+
36
+ # Tests, docs and tooling: the image runs the endpoint and nothing else.
37
+ **/*.test.ts
38
+ **/*.test.json
39
+ docs/
40
+ instructions/
41
+ **/README.md
42
+ .lanes/
43
+ .claude/
44
+ .vscode/
45
+ .idea/
46
+ .DS_Store
47
+ .playwright-mcp/
48
+
49
+ # The compiled binary from `bun build --compile`, if one was made locally.
50
+ /lanes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lanes-sh/link",
3
- "version": "0.6.9",
3
+ "version": "0.6.10",
4
4
  "description": "A self-hostable MCP gateway for all your connections, memory, tasks, files, and secrets",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://lanes.sh/link",
@@ -44,13 +44,14 @@
44
44
  "README.md",
45
45
  "LICENSE",
46
46
  "bunfig.toml",
47
- ".dockerignore"
47
+ ".dockerignore",
48
+ ".gcloudignore"
48
49
  ],
49
50
  "scripts": {
50
51
  "test": "bun test",
51
52
  "typecheck": "tsc --noEmit",
52
53
  "lanes": "bun run ./src/cli/lanes.ts",
53
- "audit": "bun pm scan",
54
+ "audit": "bun audit",
54
55
  "vendor:bunq": "bun run ./src/providers/bunq/specs/vendor.ts",
55
56
  "vendor:discord": "bun run ./src/providers/discord/specs/vendor.ts",
56
57
  "vendor:google": "bun run ./src/providers/google/specs/vendor.ts"
package/src/auth/oidc.ts CHANGED
@@ -108,8 +108,27 @@ export class OidcVerifier {
108
108
  return verified;
109
109
  }
110
110
 
111
- async #endpoint(): Promise<string | null> {
112
- if (this.#options.introspectionEndpoint) return this.#options.introspectionEndpoint;
111
+ /**
112
+ * Where to ask about a token, and whether the operator chose it.
113
+ *
114
+ * The second half decides one thing: whether the non-standard GET shape below
115
+ * is attempted at all. A discovered endpoint is one the issuer publishes as
116
+ * RFC 7662, and RFC 7662 is a form POST — so an issuer that answers discovery
117
+ * and then needs its token in a query string is not a case that exists. The
118
+ * shape is for issuers that ship an equivalent without advertising one, and
119
+ * those have to be named in config regardless.
120
+ */
121
+ async #endpoint(): Promise<{ url: string; explicit: boolean } | null> {
122
+ const named = this.#options.introspectionEndpoint;
123
+ if (named) {
124
+ if (!isHttps(named)) {
125
+ throw new Error(
126
+ `auth.authorization.introspection_endpoint is ${named}, which is not https. ` +
127
+ 'A token is sent to it on every call.',
128
+ );
129
+ }
130
+ return { url: named, explicit: true };
131
+ }
113
132
 
114
133
  this.#discovered ??= this.#fetch(
115
134
  `${this.#options.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`,
@@ -120,16 +139,33 @@ export class OidcVerifier {
120
139
 
121
140
  const metadata = await this.#discovered;
122
141
  const endpoint = metadata['introspection_endpoint'];
123
- return typeof endpoint === 'string' ? endpoint : null;
142
+ if (typeof endpoint !== 'string') return null;
143
+
144
+ // **A discovery document decides where a credential is sent, so what it
145
+ // names is checked rather than followed.** The issuer is config and the
146
+ // operator chose it; the endpoint inside its metadata is a value fetched
147
+ // over the network, and until now anything there — any host, any scheme —
148
+ // received this endpoint's tokens. Same origin as the issuer is what a
149
+ // conforming document says anyway.
150
+ if (!isHttps(endpoint) || !sameOrigin(endpoint, this.#options.issuer)) return null;
151
+
152
+ return { url: endpoint, explicit: false };
124
153
  }
125
154
 
126
155
  /**
127
156
  * Two request shapes, because two are in the wild.
128
157
  *
129
158
  * RFC 7662 is a form POST of `token`. The other common spelling is a GET with
130
- * the token in the query string, which several issuers ship instead. Trying
131
- * the standard one first and the other on failure covers both without either
132
- * being named in a conditional.
159
+ * the token in the query string, which several issuers ship instead — Google's
160
+ * `tokeninfo` among them, which is why the second shape exists at all.
161
+ *
162
+ * **The GET is attempted only for an endpoint the operator named.** A token in
163
+ * a query string is a credential in the issuer's access logs and in every
164
+ * proxy between here and it, which is a cost worth paying for the issuer whose
165
+ * documented setup requires it and worth paying for no other. A discovered
166
+ * endpoint publishes itself as RFC 7662 and RFC 7662 is the POST, so trying
167
+ * the query-string shape against one could only ever put a credential in a URL
168
+ * for an issuer that did not ask for it.
133
169
  */
134
170
  async #introspect(token: string): Promise<Introspection | null> {
135
171
  const endpoint = await this.#endpoint();
@@ -138,20 +174,21 @@ export class OidcVerifier {
138
174
  // audience would leave the confused-deputy hole open while looking like
139
175
  // it verified something.
140
176
  throw new Error(
141
- `The issuer ${this.#options.issuer} publishes no introspection_endpoint. ` +
142
- 'Set auth.authorization.introspection_endpoint to the URL that answers ' +
143
- 'questions about a token, or this endpoint cannot check who a token was issued to.',
177
+ `The issuer ${this.#options.issuer} publishes no introspection_endpoint this endpoint ` +
178
+ 'will use it is absent, not https, or not on the issuer\'s own origin. Set ' +
179
+ 'auth.authorization.introspection_endpoint to the URL that answers questions about a ' +
180
+ 'token, or this endpoint cannot check who a token was issued to.',
144
181
  );
145
182
  }
146
183
 
147
- const posted = await this.#ask(endpoint, {
184
+ const posted = await this.#ask(endpoint.url, {
148
185
  method: 'POST',
149
186
  headers: { 'content-type': 'application/x-www-form-urlencoded' },
150
187
  body: new URLSearchParams({ token }).toString(),
151
188
  });
152
- if (posted) return posted;
189
+ if (posted || !endpoint.explicit) return posted;
153
190
 
154
- const url = new URL(endpoint);
191
+ const url = new URL(endpoint.url);
155
192
  url.searchParams.set('access_token', token);
156
193
  return this.#ask(url.toString(), { method: 'GET' });
157
194
  }
@@ -167,6 +204,22 @@ export class OidcVerifier {
167
204
  }
168
205
  }
169
206
 
207
+ function isHttps(candidate: string): boolean {
208
+ try {
209
+ return new URL(candidate).protocol === 'https:';
210
+ } catch {
211
+ return false;
212
+ }
213
+ }
214
+
215
+ function sameOrigin(candidate: string, issuer: string): boolean {
216
+ try {
217
+ return new URL(candidate).origin === new URL(issuer).origin;
218
+ } catch {
219
+ return false;
220
+ }
221
+ }
222
+
170
223
  /**
171
224
  * One shape out of several spellings of the same three facts.
172
225
  *
package/src/cli/brand.ts CHANGED
@@ -160,6 +160,12 @@ a { color: inherit; }
160
160
  */
161
161
  export const PAGE_CSP =
162
162
  "frame-ancestors 'none'; default-src 'none'; " +
163
+ // `form-action` does **not** fall back to `default-src`, so `'none'` above
164
+ // says nothing about where a form may post. One page here posts the owner's
165
+ // endpoint token, and its `action` is built from the request's own `Host` —
166
+ // this is the second lock on that, so a form target that ever came from
167
+ // somewhere else is refused by the browser rather than followed.
168
+ "form-action 'self'; " +
163
169
  "style-src 'unsafe-inline' https://fonts.googleapis.com; " +
164
170
  'font-src https://fonts.gstatic.com';
165
171
 
@@ -11,7 +11,21 @@
11
11
  # There is no build step: Bun runs TypeScript directly, so the image holds
12
12
  # source, and `bun install` only fetches the third-party dependencies.
13
13
 
14
- FROM oven/bun:1.3.11-slim
14
+ # Pinned by digest, with the tag kept beside it so the next bump is legible.
15
+ #
16
+ # A tag is a pointer its publisher can move. `1.3.11-slim` is not a promise about
17
+ # bytes — it resolves to whatever was last pushed under that name — so a rebuild
18
+ # months from now can ship a base image nobody here reviewed, in a container that
19
+ # holds live OAuth refresh tokens. The digest is the bytes.
20
+ #
21
+ # `bunfig.toml` already makes this argument about npm packages, with a seven-day
22
+ # release-age floor; the base image is the larger half of the same supply chain
23
+ # and had nothing.
24
+ #
25
+ # To bump: change the tag, then resolve it —
26
+ # docker buildx imagetools inspect oven/bun:<tag> --format '{{.Manifest.Digest}}'
27
+ # The digest below is the multi-arch index, so it still resolves per architecture.
28
+ FROM oven/bun:1.3.11-slim@sha256:478281fdd196871c7e51ba6a820b7803a8ae97042ec86cdbc2e1c6b6626442d9
15
29
 
16
30
  WORKDIR /app
17
31
 
@@ -42,7 +56,18 @@ COPY package.json bunfig.toml bun.lock* ./
42
56
  # in `package.json` is an exact version, so the direct set is identical either
43
57
  # way; the lockfile pins what those depend on in turn, and a published package
44
58
  # has never been able to carry one.
45
- RUN if [ -f bun.lock ]; then bun install --frozen-lockfile; else bun install; fi
59
+ #
60
+ # The fallback says so out loud. An unfrozen resolve is a real difference in what
61
+ # ships and it used to be silent — the build log looked identical either way, so
62
+ # "which transitive versions did this image get" was answerable only by opening
63
+ # the image. One line makes it answerable from the build.
64
+ RUN if [ -f bun.lock ]; then \
65
+ bun install --frozen-lockfile; \
66
+ else \
67
+ echo "no bun.lock in the build context: resolving transitive dependencies fresh." >&2; \
68
+ echo "Direct dependencies are exact in package.json, so the direct set is unchanged." >&2; \
69
+ bun install; \
70
+ fi
46
71
 
47
72
  COPY src/ src/
48
73
 
@@ -1,4 +1,5 @@
1
- import { layout } from '#profile';
1
+ import { join } from 'node:path';
2
+ import { installRoot, layout } from '#profile';
2
3
  import type { DeployStep } from '../driver.ts';
3
4
  import {
4
5
  removalStep,
@@ -103,6 +104,68 @@ export function bucketGrants(bucket: string, profiles: readonly string[]): Condi
103
104
  ];
104
105
  }
105
106
 
107
+ /**
108
+ * How long a deleted or overwritten object can still be recovered.
109
+ *
110
+ * The revision holds `objectAdmin` on everything under `data/`, and
111
+ * `objectAdmin` contains `storage.objects.delete`. That grant is correct — the
112
+ * endpoint writes state, memory, tasks, assets and the audit log, and rewriting
113
+ * an object is deleting the old one — but it means the process most exposed to
114
+ * the internet is also the one that can erase the record of what it did.
115
+ * `audit.tamper-evident` already says deleting a run whole is not *detectable*;
116
+ * without this it was not *recoverable* either.
117
+ *
118
+ * Thirty days rather than the platform's default seven, because the gap this
119
+ * closes is noticing late. A compromise found the same afternoon needs no
120
+ * retention policy at all.
121
+ */
122
+ const SOFT_DELETE_DURATION = '30d';
123
+
124
+ /**
125
+ * The three protections a deploy applies to the bucket every time it runs.
126
+ *
127
+ * `update` rather than flags on `create`, so they reach a bucket that already
128
+ * exists — see the call site. Idempotent: setting a policy to what it already is
129
+ * is a no-op that costs one API call.
130
+ *
131
+ * - **Public access prevention**, enforced. Nothing in this bucket is served to
132
+ * a browser and nothing in it should ever be anonymous-readable, so the useful
133
+ * setting is the one that makes granting that impossible rather than merely
134
+ * absent. Uniform bucket-level access already removes per-object ACLs; this
135
+ * removes the bucket-level way to do the same thing.
136
+ * - **Soft delete**, so a deletion is recoverable — see above.
137
+ * - **Object versioning**, with the lifecycle rule that bounds it. Versioning
138
+ * covers what soft delete does not: an object *overwritten* in place, where
139
+ * the previous content is the thing worth keeping. The rule is a file shipped
140
+ * beside this one rather than written at plan time, because `--dry-run` writes
141
+ * nothing and prints what it would run — a temporary file would break both.
142
+ */
143
+ function durabilitySteps(bucket: string): DeployStep[] {
144
+ const lifecycle = join(installRoot(import.meta.dir), 'src/deployments/gcp/lifecycle.json');
145
+
146
+ return [
147
+ {
148
+ title: 'make the bucket unable to be shared publicly, and its deletions recoverable',
149
+ argv: [
150
+ 'storage',
151
+ 'buckets',
152
+ 'update',
153
+ `gs://${bucket}`,
154
+ '--public-access-prevention',
155
+ '--soft-delete-duration',
156
+ SOFT_DELETE_DURATION,
157
+ '--versioning',
158
+ // Without this, versioning keeps every prior copy of every state key
159
+ // forever, and state is the one thing here that is rewritten rather than
160
+ // appended. The rule bounds it by age and by count.
161
+ '--lifecycle-file',
162
+ lifecycle,
163
+ ],
164
+ tolerateFailure: true,
165
+ },
166
+ ];
167
+ }
168
+
106
169
  const TITLES: Record<string, string> = {
107
170
  'owns-its-data': 'let the revision write its own data, but not the manifests in it',
108
171
  'reads-its-config': 'let the revision read its config, and only read it',
@@ -133,20 +196,30 @@ export async function bucketSteps(input: {
133
196
  // served publicly; uniform access removes per-object ACLs as a way to
134
197
  // get that wrong.
135
198
  '--uniform-bucket-level-access',
136
- // Autoclass rather than a lifecycle rule, because no one fixed class is
137
- // right for this bucket: it holds the config the endpoint reads on every
138
- // boot next to assets and audit rows nobody opens again. Inside an
139
- // Autoclass bucket there are no retrieval and no early-deletion fees,
140
- // which is what makes ARCHIVE safe as the floor rather than a bet on
141
- // never reading the thing again — a read pulls the object back to
142
- // Standard at no charge. Objects under 128 KiB never leave Standard, so
143
- // this costs the config and the log nothing and saves on attachments.
199
+ // Autoclass rather than a storage-class lifecycle rule, because no one
200
+ // fixed class is right for this bucket: it holds the config the endpoint
201
+ // reads on every boot next to assets and audit rows nobody opens again.
202
+ // Inside an Autoclass bucket there are no retrieval and no
203
+ // early-deletion fees, which is what makes ARCHIVE safe as the floor
204
+ // rather than a bet on never reading the thing again — a read pulls the
205
+ // object back to Standard at no charge. Objects under 128 KiB never
206
+ // leave Standard, so this costs the config and the log nothing and saves
207
+ // on attachments.
144
208
  '--enable-autoclass',
145
209
  '--autoclass-terminal-storage-class',
146
210
  'ARCHIVE',
147
211
  ],
148
212
  tolerateFailure: true,
149
213
  },
214
+ // Everything about durability, applied separately from the create.
215
+ //
216
+ // **Not folded into the flags above, and that is the whole point.** Every
217
+ // step here tolerates failure, so the create is refused as `ALREADY_EXISTS`
218
+ // on the second deploy onwards — which means a protection added to that
219
+ // argv reaches a bucket made after this commit and no other. Every
220
+ // deployment that already exists is exactly the one that has an audit log
221
+ // worth keeping.
222
+ ...durabilitySteps(input.bucket),
150
223
  ];
151
224
 
152
225
  if (!input.serviceAccount) return steps;
@@ -126,12 +126,43 @@ export function deployPlan(input: PlanInput): DeployStep[] {
126
126
  // harness can mint one — so a target reached by a remote MCP client
127
127
  // declares `public` and gates the request in the application instead.
128
128
  cloudrun.access === 'iam' ? '--no-allow-unauthenticated' : '--allow-unauthenticated',
129
- // Always passed, including the zero. Config is the source of truth here
130
- // (ADR-004), and a flag sent only when non-zero would let a value be
131
- // raised and never loweredthe revision would keep whatever the last
132
- // deploy that bothered to mention it had set.
129
+ // What the platform will accept a connection from at all, which is a
130
+ // different question from who it lets through. An `iam` target is by
131
+ // definition not reached by an MCP client no agent harness can mint the
132
+ // identity token Cloud Run wants — so it is reached by other cloud
133
+ // workloads or by nothing, and neither needs an internet-facing listener.
134
+ // `public` is the case where the listener *is* the point.
135
+ '--ingress',
136
+ cloudrun.access === 'iam' ? 'internal-and-cloud-load-balancing' : 'all',
137
+ // Always passed, including the zero and including every default below
138
+ // it. Config is the source of truth here (ADR-004), and a flag sent only
139
+ // when non-zero would let a value be raised and never lowered — the
140
+ // revision would keep whatever the last deploy that bothered to mention
141
+ // it had set.
133
142
  '--min-instances',
134
143
  String(cloudrun.min_instances),
144
+ // The five that used to be absent, and absent meant the platform's own
145
+ // defaults: a hundred instances, eighty concurrent requests each, and
146
+ // 512 MiB to serve a 64 MiB upload in. Each one is argued at its field in
147
+ // `deployTargetSchema`; what they have in common is that a public URL
148
+ // with no ceiling on any of them is an endpoint whose cost and whose
149
+ // credential-store traffic are decided by whoever is calling it.
150
+ '--max-instances',
151
+ String(cloudrun.max_instances),
152
+ '--concurrency',
153
+ String(cloudrun.concurrency),
154
+ '--timeout',
155
+ String(cloudrun.timeout_seconds),
156
+ '--memory',
157
+ cloudrun.memory,
158
+ '--cpu',
159
+ cloudrun.cpu,
160
+ // Named rather than inherited, like the five above. gen2 is the current
161
+ // default and the one this image is tested on; pinning it means a
162
+ // platform migration is a commit here rather than a change under a
163
+ // running endpoint.
164
+ '--execution-environment',
165
+ 'gen2',
135
166
  ],
136
167
  },
137
168
  ];
@@ -151,7 +182,14 @@ export function deployPlan(input: PlanInput): DeployStep[] {
151
182
  */
152
183
  function secretMounts(secretEnv: PlanInput['secretEnv']): string[] {
153
184
  const entries = Object.entries(secretEnv ?? {});
154
- if (entries.length === 0) return [];
185
+
186
+ // `--clear-secrets`, not nothing. `gcloud run deploy` leaves a setting it is
187
+ // not told about exactly as the last revision had it, so an empty map used to
188
+ // mean "keep whatever is mounted" rather than "mount nothing" — and removing a
189
+ // vault from config left its secret still resolved into the new revision's
190
+ // environment, indefinitely, with nothing in the config saying so. The same
191
+ // argument `--min-instances` makes about always passing the zero.
192
+ if (entries.length === 0) return ['--clear-secrets'];
155
193
 
156
194
  return [
157
195
  '--set-secrets',
@@ -0,0 +1,12 @@
1
+ {
2
+ "rule": [
3
+ {
4
+ "action": { "type": "Delete" },
5
+ "condition": { "daysSinceNoncurrentTime": 30 }
6
+ },
7
+ {
8
+ "action": { "type": "Delete" },
9
+ "condition": { "numNewerVersions": 10 }
10
+ }
11
+ ]
12
+ }
@@ -1,4 +1,4 @@
1
- import { ConfigError, type DeployConfig, type TargetConfig } from '#profile';
1
+ import { ConfigError, DEPLOY_DEFAULTS, type DeployConfig, type TargetConfig } from '#profile';
2
2
  import { heading, print, style, waiting } from '#cli/output.ts';
3
3
  import { ask, confirm } from '#cli/prompt.ts';
4
4
  import type { SurveyInput, SurveyResult } from '../driver.ts';
@@ -130,6 +130,17 @@ export async function surveyCloudRun(input: SurveyInput): Promise<SurveyResult>
130
130
  // Not asked about. Zero is right for almost every target and the question
131
131
  // would cost every operator a decision to buy one of them a knob.
132
132
  min_instances: current.min_instances ?? 0,
133
+ // Not asked about either, and for a stronger version of the same reason: a
134
+ // ceiling is only interesting to somebody who has already hit it, and the
135
+ // defaults are the ones a single-user endpoint wants. Carried through from
136
+ // what the target already says so that an operator who *has* edited them
137
+ // keeps their edit — pressing return through the survey changes nothing,
138
+ // which is the property every other field here has too.
139
+ max_instances: current.max_instances ?? DEPLOY_DEFAULTS.max_instances,
140
+ concurrency: current.concurrency ?? DEPLOY_DEFAULTS.concurrency,
141
+ timeout_seconds: current.timeout_seconds ?? DEPLOY_DEFAULTS.timeout_seconds,
142
+ memory: current.memory ?? DEPLOY_DEFAULTS.memory,
143
+ cpu: current.cpu ?? DEPLOY_DEFAULTS.cpu,
133
144
  project,
134
145
  region,
135
146
  service,
@@ -27,6 +27,29 @@ interface Bucket {
27
27
  lastRefill: number;
28
28
  }
29
29
 
30
+ /**
31
+ * How many keys one limiter will hold.
32
+ *
33
+ * The bound exists because a key is not always something the endpoint chose. At
34
+ * the HTTP edge a caller is identified by the first `X-Forwarded-For` hop, which
35
+ * on a public deployment is a header a stranger writes — so an unbounded map is
36
+ * an unbounded allocation driven from outside, in a container that now has an
37
+ * explicit memory limit to exceed.
38
+ *
39
+ * Ten thousand is far above any real caller count for a single-user endpoint and
40
+ * far below a problem: a bucket is two numbers and a string key.
41
+ */
42
+ const DEFAULT_MAX_KEYS = 10_000;
43
+
44
+ /**
45
+ * How many go at once when the cap is reached.
46
+ *
47
+ * A batch rather than one, so the sort that finds them is amortised. Evicting a
48
+ * single key per overflow would run an O(n log n) pass on every request once the
49
+ * map is full, which turns the bound into its own denial of service.
50
+ */
51
+ const EVICTION_FRACTION = 10;
52
+
30
53
  /**
31
54
  * Token bucket, refilled continuously rather than on a fixed window boundary.
32
55
  *
@@ -37,9 +60,16 @@ interface Bucket {
37
60
  export class RateLimiter {
38
61
  readonly #buckets = new Map<string, Bucket>();
39
62
  readonly #now: () => number;
63
+ readonly #maxKeys: number;
40
64
 
41
- constructor(now: () => number = Date.now) {
65
+ constructor(now: () => number = Date.now, maxKeys: number = DEFAULT_MAX_KEYS) {
42
66
  this.#now = now;
67
+ this.#maxKeys = maxKeys;
68
+ }
69
+
70
+ /** How many callers are currently held. For tests, and for nothing else. */
71
+ get size(): number {
72
+ return this.#buckets.size;
43
73
  }
44
74
 
45
75
  /**
@@ -52,7 +82,13 @@ export class RateLimiter {
52
82
 
53
83
  const now = this.#now();
54
84
  const refillPerMs = perMinute / 60_000;
55
- const bucket = this.#buckets.get(key) ?? { tokens: perMinute, lastRefill: now };
85
+ const existing = this.#buckets.get(key);
86
+
87
+ // Before the insert, not after: the cap is on what this map holds, and
88
+ // checking afterwards means it is briefly one over on every overflow.
89
+ if (!existing) this.#makeRoom();
90
+
91
+ const bucket = existing ?? { tokens: perMinute, lastRefill: now };
56
92
 
57
93
  bucket.tokens = Math.min(perMinute, bucket.tokens + (now - bucket.lastRefill) * refillPerMs);
58
94
  bucket.lastRefill = now;
@@ -67,11 +103,48 @@ export class RateLimiter {
67
103
  return { allowed: true, retryAfterMs: 0 };
68
104
  }
69
105
 
70
- /** Drop idle buckets so a long-lived process does not accumulate keys forever. */
106
+ /**
107
+ * Drop idle buckets so a long-lived process does not accumulate keys forever.
108
+ *
109
+ * Public because it reads as the obvious lever, and because the tests drive it
110
+ * directly. It is **not** what bounds the map, and nothing's correctness may
111
+ * depend on a caller remembering it: for most of this file's life nothing did
112
+ * call it, `edge.ts` claimed idle callers were dropped, and the map grew for
113
+ * as long as the process lived. `#makeRoom` is the bound now, and it runs on
114
+ * the insert path where it cannot be forgotten.
115
+ */
71
116
  prune(idleMs = 300_000): void {
72
117
  const cutoff = this.#now() - idleMs;
73
118
  for (const [key, bucket] of this.#buckets) {
74
119
  if (bucket.lastRefill < cutoff) this.#buckets.delete(key);
75
120
  }
76
121
  }
122
+
123
+ /**
124
+ * Make space for one more key, if the map is full.
125
+ *
126
+ * Idle callers first, because dropping one costs nothing — a bucket that has
127
+ * not been touched in five minutes has refilled to full, so re-creating it
128
+ * gives back exactly what was discarded.
129
+ *
130
+ * Only when that is not enough does this evict a live caller, oldest first by
131
+ * last use. That *does* forgive whatever the evicted caller had spent, which
132
+ * is the honest cost of a bounded map: an attacker who can mint ten thousand
133
+ * distinct keys can push their own bucket out and start again. What they
134
+ * cannot do is grow the map, and on the paths this limiter guards there is a
135
+ * second bucket keyed on nothing at all — see `edge.ts` — which is the one
136
+ * that holds when the per-caller key is worthless.
137
+ */
138
+ #makeRoom(): void {
139
+ if (this.#buckets.size < this.#maxKeys) return;
140
+
141
+ this.prune();
142
+ if (this.#buckets.size < this.#maxKeys) return;
143
+
144
+ const oldest = [...this.#buckets.entries()]
145
+ .sort((a, b) => a[1].lastRefill - b[1].lastRefill)
146
+ .slice(0, Math.max(1, Math.ceil(this.#maxKeys / EVICTION_FRACTION)));
147
+
148
+ for (const [key] of oldest) this.#buckets.delete(key);
149
+ }
77
150
  }
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  export {
15
+ DEPLOY_DEFAULTS,
15
16
  SUPPORTED_CONTRACT,
16
17
  configSchema,
17
18
  declaredTarget,
@@ -2,6 +2,7 @@ import { z } from 'zod';
2
2
  import {
3
3
  auditTargetSchema,
4
4
  credentialsTargetSchema,
5
+ DEPLOY_DEFAULTS,
5
6
  deployTargetSchema,
6
7
  storageTargetSchema,
7
8
  vaultTargetSchema,
@@ -54,14 +55,18 @@ export const legacyTargetSchema = z
54
55
  ? target
55
56
  : {
56
57
  ...target,
57
- // The pre-`deploy` spelling predates both of these, so it gets the
58
- // same defaults the current one would: the closed door, and no
59
- // instance kept warm.
58
+ // The pre-`deploy` spelling predates all of these, so it gets the
59
+ // same defaults the current one would: the closed door, no instance
60
+ // kept warm, and the ceilings a public URL is deployed under. Built by
61
+ // hand here rather than parsed, so `DEPLOY_DEFAULTS` is spread rather
62
+ // than left to zod — a block that reaches `deployPlan` without them
63
+ // sends `undefined` to `gcloud` as the string "undefined".
60
64
  deploy: {
61
65
  ...cloudrun,
62
66
  platform: 'cloudrun' as const,
63
67
  access: 'iam' as const,
64
68
  min_instances: 0,
69
+ ...DEPLOY_DEFAULTS,
65
70
  },
66
71
  },
67
72
  );
@@ -158,6 +158,25 @@ export const vaultTargetSchema = z.object({
158
158
  * discriminated union per platform — buys precision this file cannot use and
159
159
  * costs a schema edit on every field any host ever adds.
160
160
  */
161
+ /**
162
+ * The ceilings a deployed revision runs under, in one place.
163
+ *
164
+ * A named constant rather than five literals inside `z.default()` because two
165
+ * callers need the same numbers and neither can read a zod default out of a
166
+ * schema: the survey writes a target's block field by field, and the deploy plan
167
+ * sends every one of them on every rollout. Two spellings of a ceiling is a
168
+ * ceiling that is one value in a fresh profile and another in a surveyed one.
169
+ *
170
+ * Each is argued at its own field below.
171
+ */
172
+ export const DEPLOY_DEFAULTS = {
173
+ max_instances: 4,
174
+ concurrency: 40,
175
+ timeout_seconds: 300,
176
+ memory: '1Gi',
177
+ cpu: '1',
178
+ } as const;
179
+
161
180
  export const deployTargetSchema = z.object({
162
181
  platform: z.enum(['cloudrun']),
163
182
  // Non-empty here rather than in a referential check further down. Under
@@ -210,6 +229,52 @@ export const deployTargetSchema = z.object({
210
229
  * Raise it if a re-authorization ever lines up with a cold `/token`.
211
230
  */
212
231
  min_instances: z.number().int().min(0).max(10).default(0),
232
+ /**
233
+ * The ceiling on instances, and the only thing bounding what a public URL can
234
+ * spend.
235
+ *
236
+ * Four, not the platform's hundred. `access: public` is a routable address on
237
+ * the internet, and every instance that starts reads the credential store and
238
+ * lists the bucket — so scaling out multiplies cost *and* traffic against the
239
+ * two things this endpoint most wants kept quiet. A single-user endpoint that
240
+ * genuinely needs a fifth concurrent instance has an agent in a loop, which is
241
+ * the case the ceiling is for.
242
+ *
243
+ * It is also what makes `limits.requests_per_minute` mean something in
244
+ * aggregate. Those limits are per instance and always were; with no ceiling
245
+ * the aggregate had no value at all, and `docs/detailed/deployment-cloudrun.md`
246
+ * told the reader to cap this themselves because nothing here did.
247
+ */
248
+ max_instances: z.number().int().min(1).max(100).default(DEPLOY_DEFAULTS.max_instances),
249
+ /**
250
+ * Requests one instance serves at once.
251
+ *
252
+ * Forty rather than the platform's eighty, paired with `memory` below: an
253
+ * attachment upload is capped at 64 MiB and is buffered before it is written,
254
+ * so what bounds this is memory per instance rather than CPU.
255
+ */
256
+ concurrency: z.number().int().min(1).max(1000).default(DEPLOY_DEFAULTS.concurrency),
257
+ /**
258
+ * How long one request may run before the platform cuts it.
259
+ *
260
+ * The platform's own default, stated rather than inherited — the point is that
261
+ * it is written down and always sent, so a platform changing its default is
262
+ * not a silent change to what this serves.
263
+ */
264
+ timeout_seconds: z.number().int().min(1).max(3600).default(DEPLOY_DEFAULTS.timeout_seconds),
265
+ /**
266
+ * Memory per instance.
267
+ *
268
+ * A gigabyte because 512 MiB is not enough for what the endpoint already
269
+ * accepts: `MAX_UPLOAD_BYTES` is 64 MiB, and staging one costs roughly twice
270
+ * that at peak — the chunks as they arrive, and the single buffer they are
271
+ * copied into. At the platform default that is one upload away from an
272
+ * out-of-memory kill, and an OOM is a 503 for every request the instance was
273
+ * also serving.
274
+ */
275
+ memory: z.string().min(1).default(DEPLOY_DEFAULTS.memory),
276
+ /** CPU per instance. Explicit for the same reason `timeout_seconds` is. */
277
+ cpu: z.string().min(1).default(DEPLOY_DEFAULTS.cpu),
213
278
  });
214
279
 
215
280
  /**
@@ -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
+ }
@@ -116,6 +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
+ /**
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;
119
128
  }
120
129
 
121
130
  /**
@@ -254,6 +263,7 @@ export function startHarness(options: HarnessOptions): Harness {
254
263
  primary: options.profile,
255
264
  authenticator: gate ? new AuthenticatorChain([bearer, gate.authenticator]) : bearer,
256
265
  ...(gate ? { authorization: gate.surface } : {}),
266
+ ...(options.meterUnauthenticated ? { meterUnauthenticated: true } : {}),
257
267
  log,
258
268
  });
259
269
 
@@ -1,4 +1,4 @@
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';
@@ -6,7 +6,12 @@ import { allowedHostnamesFor, rebindingRefusal } from './rebinding.ts';
6
6
  import { ANY_ORIGIN, corsAware, type CorsPolicy } from './cors.ts';
7
7
  import type { Generation } from './generation.ts';
8
8
  import type { Generations } from './generations.ts';
9
- 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';
10
15
  import {
11
16
  handleAuthorization,
12
17
  isAuthorizationPath,
@@ -50,38 +55,29 @@ export interface ServerOptions {
50
55
  readonly authorization?: AuthorizationSurface | undefined;
51
56
  /** Hostnames this endpoint answers to. See `./rebinding.ts`. */
52
57
  readonly allowedHostnames?: readonly string[] | undefined;
58
+ /**
59
+ * Meter the surface that answers before authentication.
60
+ *
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.
68
+ */
69
+ readonly meterUnauthenticated?: boolean | undefined;
53
70
  }
54
71
 
55
- type RefusalReason = Extract<AuthOutcome, { ok: false }>['reason'];
56
-
57
- /**
58
- * What a caller should do about each refusal.
59
- *
60
- * `invalid` is the only one a client can act on by itself: it presented a
61
- * credential and this endpoint did not accept it, which is what a refresh is
62
- * for. RFC 6750 §3.1 has a name for that and clients branch on it; the others
63
- * mean there is nothing to refresh, and §3 says to stay quiet rather than send
64
- * a client after a token it does not hold. `malformed` says nothing either —
65
- * `invalid_request` carries a SHOULD of a 400 status, and changing that path's
66
- * status is a larger question than this answers.
67
- */
68
- const CHALLENGE: Partial<Record<RefusalReason, ChallengeError>> = {
69
- invalid: {
70
- code: 'invalid_token',
71
- description: 'The credential is expired, revoked, or not one this endpoint issued.',
72
- },
73
- };
74
-
75
- /** The same four, for whoever is reading the body rather than the header. */
76
- const HINTS: Record<RefusalReason, string> = {
77
- missing: 'Present the profile token as: Authorization: Bearer <token>',
78
- malformed: 'Present the profile token as: Authorization: Bearer <token>',
79
- invalid: 'Refresh the credential. Authorize again only if the refresh is refused too.',
80
- not_configured: 'This profile has no token yet. Run: lanes link token rotate',
81
- };
82
-
83
72
  export const MCP_PATH = '/mcp';
84
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';
85
81
 
86
82
  /**
87
83
  * Loopback addresses. What binding to one changes is the browser threat model,
@@ -111,6 +107,7 @@ export interface RequestHandler {
111
107
  export function createRequestHandler(options: ServerOptions): RequestHandler {
112
108
  let probedAt = 0;
113
109
  const failedAuth = failedAuthLimiter();
110
+ const unauthenticated = options.meterUnauthenticated ? unauthenticatedLimiter() : null;
114
111
 
115
112
  /**
116
113
  * Re-read the config because a call named a tool we do not serve.
@@ -144,6 +141,27 @@ export function createRequestHandler(options: ServerOptions): RequestHandler {
144
141
 
145
142
  const url = new URL(request.url);
146
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
+
147
165
  // Before authentication, deliberately: a client's first request is the
148
166
  // one that discovers how to authenticate, so requiring a token to read
149
167
  // the document that says where tokens come from would close the loop it
@@ -152,7 +170,7 @@ export function createRequestHandler(options: ServerOptions): RequestHandler {
152
170
  return await handleAuthorization(request, options.authorization);
153
171
  }
154
172
 
155
- if (url.pathname === '/health') {
173
+ if (url.pathname === HEALTH_PATH) {
156
174
  // `status` is unauthenticated because the platform's own probe reads it
157
175
  // and a deploy waits on it. The profile *names* are not: on a public URL
158
176
  // that is a list of what this endpoint holds, handed to anyone who asks,
@@ -182,37 +200,17 @@ export function createRequestHandler(options: ServerOptions): RequestHandler {
182
200
  request.headers.get('authorization'),
183
201
  );
184
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.
185
206
  if (!outcome.ok) {
186
207
  options.log.warn('rejected request', { reason: outcome.reason });
187
-
188
- // After the attempt rather than before it: keyed on the caller alone,
189
- // anyone able to reach the endpoint could spend the owner's budget and
190
- // lock them out, which trades a cost problem for a worse availability
191
- // one. Only a failure consumes a token, so a valid credential is never
192
- // refused by this.
193
- const budget = failedAuth.take(callerKey(request), FAILED_AUTH_PER_MINUTE);
194
- if (!budget.allowed) return tooManyAttempts(budget.retryAfterMs);
195
-
196
- // The pointer is the whole handshake for a remote client: it reads the
197
- // named document, finds the authorization server, and starts a flow.
198
- // Without it the client has to guess the document's location, and a
199
- // client that guesses wrong reports the endpoint as unreachable.
200
- const metadata = options.authorization ? resourceMetadataUrl(request) : null;
201
-
202
- return new Response(
203
- JSON.stringify({
204
- error: 'unauthorized',
205
- reason: outcome.reason,
206
- hint: HINTS[outcome.reason],
207
- }),
208
- {
209
- status: 401,
210
- headers: {
211
- 'content-type': 'application/json',
212
- 'www-authenticate': challenge(metadata, CHALLENGE[outcome.reason]),
213
- },
214
- },
215
- );
208
+ return authRefusal({
209
+ request,
210
+ reason: outcome.reason,
211
+ limiter: failedAuth,
212
+ metadataUrl: options.authorization ? resourceMetadataUrl(request) : null,
213
+ });
216
214
  }
217
215
 
218
216
  // Behind the same bearer check as everything else, and deliberately not a
@@ -348,6 +346,10 @@ export function serve(options: ServeOptions): RunningServer {
348
346
  : { allowedOrigins: primary.config.auth.allowed_origins ?? [ANY_ORIGIN] };
349
347
  const handler = createRequestHandler({
350
348
  ...options,
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,
351
353
  ...(allowedHostnames ? { allowedHostnames } : {}),
352
354
  });
353
355
 
@@ -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