From e69210dd28838ce9404a246fc0a2028bfb22b8a4 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Tue, 20 Oct 2026 18:22:09 +0200 Subject: [PATCH 1/4] fix(proxy): forward user-agent, pass back x-dfo-commit and Allow, and serve /codes The proxy forwards the caller's user-agent to the APIs, so their logs name the client library, and passes back x-dfo-commit and Allow beside Server-Timing and x-dfo-trace-id. GET /codes/{code} forwards to the API's refusal-code registry, so the root-relative `type` link in a problem document resolves on the public host. A call the limiter could not count (a database error or timeout, which fails open) carries no X-RateLimit-* headers, since they would describe a count that never ran. `rateHeaders` moves to rateHeaders.ts, a module free of server-only, so node --test loads it. --- src/app/codes/[code]/route.ts | 18 ++++++++++++++++ src/lib/proxy/codes.ts | 9 ++++++++ src/lib/proxy/forward.ts | 7 +++++-- src/lib/proxy/headers.ts | 10 ++++++--- src/lib/proxy/rateHeaders.ts | 25 ++++++++++++++++++++++ src/lib/proxy/rateLimit.ts | 29 +++++++++++++++----------- src/lib/proxy/test/codes.test.ts | 20 ++++++++++++++++++ src/lib/proxy/test/headers.test.ts | 17 +++++++++++++++ src/lib/proxy/test/rateHeaders.test.ts | 22 +++++++++++++++++++ src/lib/search/storedFile.ts | 3 ++- 10 files changed, 142 insertions(+), 18 deletions(-) create mode 100644 src/app/codes/[code]/route.ts create mode 100644 src/lib/proxy/codes.ts create mode 100644 src/lib/proxy/rateHeaders.ts create mode 100644 src/lib/proxy/test/codes.test.ts create mode 100644 src/lib/proxy/test/rateHeaders.test.ts diff --git a/src/app/codes/[code]/route.ts b/src/app/codes/[code]/route.ts new file mode 100644 index 0000000..74628f6 --- /dev/null +++ b/src/app/codes/[code]/route.ts @@ -0,0 +1,18 @@ +import { NextRequest } from 'next/server'; + +import { codeTarget } from '@/lib/proxy/codes'; +import { forward } from '@/lib/proxy/forward'; +import { searchApiBase } from '@/lib/searchApi'; + +// The API's refusal-code registry (GET /codes/{code}), served at the same +// root path on the site's domain. A problem document's `type` is that +// root-relative path, so the link in it resolves here (see src/lib/proxy/codes). +export const GET = async (req: NextRequest, { params }: { params: Promise<{ code: string }> }) => { + const { code } = await params; + + return forward(req, { + surface: 'api', + target: codeTarget(searchApiBase(), code, req.nextUrl.search), + unreachable: 'The Search API is unreachable.', + }); +}; diff --git a/src/lib/proxy/codes.ts b/src/lib/proxy/codes.ts new file mode 100644 index 0000000..abb98aa --- /dev/null +++ b/src/lib/proxy/codes.ts @@ -0,0 +1,9 @@ +/** + * The upstream address of the refusal-code registry. The API's problem + * documents carry a root-relative `type` (`/codes/request_invalid`), which + * resolves against the site's own host, so `src/app/codes/[code]/route.ts` + * forwards that path to the API. Kept apart from forward.ts, which imports + * server-only, so `node --test` can load it. + */ +export const codeTarget = (apiBase: string, code: string, search: string): string => + `${apiBase}/codes/${encodeURIComponent(code)}${search}`; diff --git a/src/lib/proxy/forward.ts b/src/lib/proxy/forward.ts index 617d0ac..0784c70 100644 --- a/src/lib/proxy/forward.ts +++ b/src/lib/proxy/forward.ts @@ -7,14 +7,16 @@ import { apisFetch } from '@/lib/apisAuth'; import { limiterKeyFor, setCaller, trustedHops } from './caller'; import { relayBody } from './compress'; import { clientResponseHeaders, dropEncodedLength, pick, upstreamRequestHeaders } from './headers'; -import { hit, rateHeaders, type Surface } from './rateLimit'; +import { rateHeaders } from './rateHeaders'; +import { hit, type Surface } from './rateLimit'; /** * The platform proxy: the DFO APIs and the MCP servers mirrored 1:1 under * the site's own domain, so one public host serves the browser and outside * callers alike (the engines ship no CORS headers, and the harness token * must not reach a client). An /api/* or /mcp call is rate-limited per IP - * (src/lib/proxy/rateLimit). A call is forwarded + * (src/lib/proxy/rateLimit; the X-RateLimit-* headers go out only on a + * counted call). A call is forwarded * with its method, query, body, content headers and every mcp-* header * (src/lib/proxy/headers), and the upstream's response streamed back: SSE * (search resolve, studio turns, MCP) and file downloads both depend on @@ -25,6 +27,7 @@ import { hit, rateHeaders, type Surface } from './rateLimit'; * * Upstreams: * /api/v1/search|datasets|graph/* → SEARCH_API_BASE (dfo-apis, src/lib/searchApi) + * /codes/{code} → SEARCH_API_BASE (the refusal-code registry) * /api/v1/studio/turn|interrupt|cancel|composition * → STUDIO_API_BASE (dfo-ai), STUDIO_TOKEN * diff --git a/src/lib/proxy/headers.ts b/src/lib/proxy/headers.ts index 479ddaa..db1bfdd 100644 --- a/src/lib/proxy/headers.ts +++ b/src/lib/proxy/headers.ts @@ -2,10 +2,12 @@ * Which headers cross the platform proxy (src/lib/proxy/forward). Kept apart * from forward.ts, which imports server-only, so `node --test` can load it. * - * A request carries its content headers and every `mcp-*` header (the MCP + * A request carries its content headers, the caller's user-agent (the API + * logs it to tell client families apart) and every `mcp-*` header (the MCP * transport defines mcp-method, mcp-name, mcp-session-id and * mcp-protocol-version, and validates the first two against the body). A - * response carries its content headers, location (a redirect, as the + * response carries its content headers, allow (the methods of a 405), + * x-dfo-commit (the build that answered), location (a redirect, as the * Datasets API's file links answer), cache-control (the `no-store` on such a * redirect to a signed URL, which forward.ts's default `no-cache` would * weaken), retry-after (on a busy refusal), www-authenticate, server-timing @@ -19,7 +21,7 @@ const MCP_PREFIX = 'mcp-'; // Request headers worth carrying upstream; everything else (cookies, the // site's Basic auth, hop-by-hop) stays here. -const REQUEST_HEADERS = ['content-type', 'accept']; +const REQUEST_HEADERS = ['content-type', 'accept', 'user-agent']; // Response headers worth carrying back; the rest is the upstream's own // business (server, date, transfer-encoding…). const RESPONSE_HEADERS = [ @@ -32,6 +34,8 @@ const RESPONSE_HEADERS = [ 'www-authenticate', 'server-timing', 'x-dfo-trace-id', + 'x-dfo-commit', + 'allow', ]; // Copy the named headers present on `source` onto `into`. diff --git a/src/lib/proxy/rateHeaders.ts b/src/lib/proxy/rateHeaders.ts new file mode 100644 index 0000000..986db11 --- /dev/null +++ b/src/lib/proxy/rateHeaders.ts @@ -0,0 +1,25 @@ +/** + * What a rate-limit check decided, and the headers that tell a client so. No + * `server-only` import, so node:test loads it. + */ +import type { Verdict } from './limitCounter'; + +/** A verdict, and whether a count ran to reach it. */ +export type RateVerdict = Verdict & { + // False for the verdict of the fail-open path (a database error or + // timeout): the call passed without a count, so no limit applies. + counted: boolean; +}; + +/** + * The standard rate-limit headers for a verdict. An uncounted verdict gets + * none, since no limit applies to describe. + */ +export const rateHeaders = (verdict: RateVerdict): Record => + verdict.counted + ? { + 'X-RateLimit-Limit': String(verdict.limit), + 'X-RateLimit-Remaining': String(verdict.remaining), + 'X-RateLimit-Reset': String(verdict.reset), + } + : {}; diff --git a/src/lib/proxy/rateLimit.ts b/src/lib/proxy/rateLimit.ts index 5366a10..4829a94 100644 --- a/src/lib/proxy/rateLimit.ts +++ b/src/lib/proxy/rateLimit.ts @@ -3,7 +3,8 @@ import 'server-only'; import { counterDb } from '@/lib/db'; import { limiterBucket } from './caller'; -import { countHit, pruneCounters, type Verdict } from './limitCounter'; +import { countHit, pruneCounters } from './limitCounter'; +import type { RateVerdict } from './rateHeaders'; /** * Per-IP rate limit for the platform proxy's /api/* and /mcp routes @@ -15,6 +16,10 @@ import { countHit, pruneCounters, type Verdict } from './limitCounter'; * timed-out count fails open: the * platform's own doors are the backstop, and the limit is a courtesy. * + * A fail-open call gets a verdict with `counted: false`, and the response + * carries no X-RateLimit-* headers (see rateHeaders), so the proxy advertises + * a limit only while one is counted. + * * The window and the allowance per surface are env-tunable: * RATE_LIMIT_WINDOW_S (default 60) * RATE_LIMIT_API calls per window per IP on /api/* (default 120) @@ -44,12 +49,12 @@ let lastPrune = 0; /** * Count one call for `ip` on `surface` and say whether it is within the - * allowance. A database that fails or is slow lets the call through. Each + * allowance. A database that fails or is slow lets the call through, uncounted. Each * instance also deletes dead counters, at most once an hour, the Studio's * included (every Studio call passes here too). The delete uses the same * bounded counter pool as the counts. */ -export const hit = async (surface: Surface, ip: string): Promise => { +export const hit = async (surface: Surface, ip: string): Promise => { const limit = LIMITS[surface]; if (Date.now() - lastPrune > PRUNE_EVERY_MS) { @@ -58,17 +63,17 @@ export const hit = async (surface: Surface, ip: string): Promise => { } try { - return await countHit(counterDb, `${surface}:${limiterBucket(ip)}`, limit, WINDOW_S); + const verdict = await countHit( + counterDb, + `${surface}:${limiterBucket(ip)}`, + limit, + WINDOW_S, + ); + + return { ...verdict, counted: true }; } catch (error) { console.warn('rate limit unavailable, passing', error); - return { allowed: true, limit, remaining: limit, reset: WINDOW_S }; + return { allowed: true, counted: false, limit, remaining: limit, reset: WINDOW_S }; } }; - -/** The standard rate-limit headers for a verdict. */ -export const rateHeaders = (verdict: Verdict): Record => ({ - 'X-RateLimit-Limit': String(verdict.limit), - 'X-RateLimit-Remaining': String(verdict.remaining), - 'X-RateLimit-Reset': String(verdict.reset), -}); diff --git a/src/lib/proxy/test/codes.test.ts b/src/lib/proxy/test/codes.test.ts new file mode 100644 index 0000000..53a0919 --- /dev/null +++ b/src/lib/proxy/test/codes.test.ts @@ -0,0 +1,20 @@ +/** + * `codes.ts`: the upstream address a `/codes/{code}` request forwards to. + */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { codeTarget } from '../codes.ts'; + +test('codeTarget: puts the code at the API root, keeping the query', () => { + assert.equal( + codeTarget('https://api.example', 'request_invalid', '?lang=fr'), + 'https://api.example/codes/request_invalid?lang=fr', + ); +}); + +test('codeTarget: a code cannot leave the /codes segment', () => { + const target = new URL(codeTarget('https://api.example', '../openapi.json', '')); + + assert.equal(target.pathname, '/codes/..%2Fopenapi.json'); +}); diff --git a/src/lib/proxy/test/headers.test.ts b/src/lib/proxy/test/headers.test.ts index 78f5ede..62601e0 100644 --- a/src/lib/proxy/test/headers.test.ts +++ b/src/lib/proxy/test/headers.test.ts @@ -41,6 +41,12 @@ test('upstreamRequestHeaders: keeps the content headers and the session and prot }); }); +test("upstreamRequestHeaders: carries the caller's user-agent", () => { + const out = upstreamRequestHeaders(new Headers({ 'User-Agent': 'dfo-python/0.4.1' })); + + assert.deepEqual(Object.fromEntries(out), { 'user-agent': 'dfo-python/0.4.1' }); +}); + test('upstreamRequestHeaders: drops credentials, caller and other headers', () => { const out = upstreamRequestHeaders( new Headers({ @@ -160,6 +166,17 @@ test('clientResponseHeaders: carries the upstream Server-Timing and trace id', ( }); }); +test('clientResponseHeaders: carries the upstream x-dfo-commit and the Allow of a 405', () => { + const out = clientResponseHeaders( + new Headers({ 'X-DFO-Commit': '0eb97117', Allow: 'GET, HEAD' }), + ); + + assert.deepEqual(Object.fromEntries(out), { + allow: 'GET, HEAD', + 'x-dfo-commit': '0eb97117', + }); +}); + test('clientResponseHeaders: leaves the content-length of an encoded body behind', () => { const encoded = { 'content-type': 'application/json', 'content-length': '512' }; diff --git a/src/lib/proxy/test/rateHeaders.test.ts b/src/lib/proxy/test/rateHeaders.test.ts new file mode 100644 index 0000000..06b01ce --- /dev/null +++ b/src/lib/proxy/test/rateHeaders.test.ts @@ -0,0 +1,22 @@ +/** + * `rateHeaders.ts`: a counted call advertises its limit, an uncounted one + * advertises none. + */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { rateHeaders, type RateVerdict } from '../rateHeaders.ts'; + +const counted: RateVerdict = { allowed: true, counted: true, limit: 120, remaining: 7, reset: 41 }; + +test('a counted call carries the three X-RateLimit headers', () => { + assert.deepEqual(rateHeaders(counted), { + 'X-RateLimit-Limit': '120', + 'X-RateLimit-Remaining': '7', + 'X-RateLimit-Reset': '41', + }); +}); + +test('a call passed without a count carries no X-RateLimit header', () => { + assert.deepEqual(rateHeaders({ ...counted, counted: false, remaining: 120, reset: 60 }), {}); +}); diff --git a/src/lib/search/storedFile.ts b/src/lib/search/storedFile.ts index 15108b8..a2cefbc 100644 --- a/src/lib/search/storedFile.ts +++ b/src/lib/search/storedFile.ts @@ -3,7 +3,8 @@ import 'server-only'; import type { NextRequest } from 'next/server'; import { clientIp } from '@/lib/proxy/forward'; -import { hit, rateHeaders } from '@/lib/proxy/rateLimit'; +import { rateHeaders } from '@/lib/proxy/rateHeaders'; +import { hit } from '@/lib/proxy/rateLimit'; import { datasetsFetch } from '@/lib/search/datasetsFetch'; import { artifactHref } from '@/lib/search/searchClient'; import { storedDownload, type VersionLinkDocument } from '@/lib/search/versionLink'; From 10d5445cdc32f966402d51af62163b91b19cc49e Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Wed, 21 Oct 2026 01:25:02 +0200 Subject: [PATCH 2/4] fix(proxy): forward only a registry-shaped code to /codes `encodeURIComponent` leaves `.` alone, so `/codes/..` forwarded to `${SEARCH_API_BASE}/codes/..`, which fetch resolves to the API root, carrying the site's ID token and the caller's query. `codeTarget` now answers an address only for a snake_case code, the shape of every code in the registry, and the route answers 404 for anything else. The rate-limit comments name /codes among the limited routes, and `downloadAllowance` says its headers go out only on a counted call. --- src/app/codes/[code]/route.ts | 7 ++++++- src/lib/proxy/codes.ts | 10 ++++++++-- src/lib/proxy/forward.ts | 2 +- src/lib/proxy/rateLimit.ts | 2 +- src/lib/proxy/test/codes.test.ts | 10 +++++----- src/lib/search/storedFile.ts | 5 +++-- 6 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/app/codes/[code]/route.ts b/src/app/codes/[code]/route.ts index 74628f6..e7535a3 100644 --- a/src/app/codes/[code]/route.ts +++ b/src/app/codes/[code]/route.ts @@ -9,10 +9,15 @@ import { searchApiBase } from '@/lib/searchApi'; // root-relative path, so the link in it resolves here (see src/lib/proxy/codes). export const GET = async (req: NextRequest, { params }: { params: Promise<{ code: string }> }) => { const { code } = await params; + const target = codeTarget(searchApiBase(), code, req.nextUrl.search); + + if (!target) { + return Response.json({ error: 'not_found' }, { status: 404 }); + } return forward(req, { surface: 'api', - target: codeTarget(searchApiBase(), code, req.nextUrl.search), + target, unreachable: 'The Search API is unreachable.', }); }; diff --git a/src/lib/proxy/codes.ts b/src/lib/proxy/codes.ts index abb98aa..76d8d55 100644 --- a/src/lib/proxy/codes.ts +++ b/src/lib/proxy/codes.ts @@ -5,5 +5,11 @@ * forwards that path to the API. Kept apart from forward.ts, which imports * server-only, so `node --test` can load it. */ -export const codeTarget = (apiBase: string, code: string, search: string): string => - `${apiBase}/codes/${encodeURIComponent(code)}${search}`; + +// Every registry code is snake_case, so a code of this shape stays in the +// one path segment after /codes/; `.` and `..` would resolve out of it. +const CODE = /^[a-z][a-z0-9_]*$/; + +/** The registry address for `code`, or null for a value no code can be. */ +export const codeTarget = (apiBase: string, code: string, search: string): string | null => + CODE.test(code) ? `${apiBase}/codes/${code}${search}` : null; diff --git a/src/lib/proxy/forward.ts b/src/lib/proxy/forward.ts index 0784c70..58ff029 100644 --- a/src/lib/proxy/forward.ts +++ b/src/lib/proxy/forward.ts @@ -14,7 +14,7 @@ import { hit, type Surface } from './rateLimit'; * The platform proxy: the DFO APIs and the MCP servers mirrored 1:1 under * the site's own domain, so one public host serves the browser and outside * callers alike (the engines ship no CORS headers, and the harness token - * must not reach a client). An /api/* or /mcp call is rate-limited per IP + * must not reach a client). An /api/*, /codes or /mcp call is rate-limited per IP * (src/lib/proxy/rateLimit; the X-RateLimit-* headers go out only on a * counted call). A call is forwarded * with its method, query, body, content headers and every mcp-* header diff --git a/src/lib/proxy/rateLimit.ts b/src/lib/proxy/rateLimit.ts index 4829a94..057538b 100644 --- a/src/lib/proxy/rateLimit.ts +++ b/src/lib/proxy/rateLimit.ts @@ -7,7 +7,7 @@ import { countHit, pruneCounters } from './limitCounter'; import type { RateVerdict } from './rateHeaders'; /** - * Per-IP rate limit for the platform proxy's /api/* and /mcp routes + * Per-IP rate limit for the platform proxy's /api/*, /codes and /mcp routes * (src/lib/proxy/forward): a fixed window counted in Postgres * (./limitCounter), shared by every instance. An IPv6 client is counted by * its /64 (see limiterBucket). A count runs on its own small pool diff --git a/src/lib/proxy/test/codes.test.ts b/src/lib/proxy/test/codes.test.ts index 53a0919..a7edfcf 100644 --- a/src/lib/proxy/test/codes.test.ts +++ b/src/lib/proxy/test/codes.test.ts @@ -13,8 +13,8 @@ test('codeTarget: puts the code at the API root, keeping the query', () => { ); }); -test('codeTarget: a code cannot leave the /codes segment', () => { - const target = new URL(codeTarget('https://api.example', '../openapi.json', '')); - - assert.equal(target.pathname, '/codes/..%2Fopenapi.json'); -}); +for (const code of ['..', '.', '../openapi.json', 'request_invalid/..', 'Request_Invalid', '']) { + test(`codeTarget: no address for ${JSON.stringify(code)}, which no code can be`, () => { + assert.equal(codeTarget('https://api.example', code, '?q'), null); + }); +} diff --git a/src/lib/search/storedFile.ts b/src/lib/search/storedFile.ts index a2cefbc..ea806cb 100644 --- a/src/lib/search/storedFile.ts +++ b/src/lib/search/storedFile.ts @@ -22,8 +22,9 @@ const VERSION_READ_TIMEOUT_MS = 20_000; /** * Counts the call against the proxy's `api` allowance, the one every - * Datasets API call through the site shares. The headers go on the - * response either way, with `Retry-After` added when the call is over it. + * Datasets API call through the site shares. The X-RateLimit-* headers go + * on the response whenever the call was counted, with `Retry-After` added + * when the call is over it. */ export const downloadAllowance = async ( req: NextRequest, From 145b47a9a2c2e1a515fda162ac079b153156f115 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Wed, 21 Oct 2026 01:30:38 +0200 Subject: [PATCH 3/4] docs(proxy): name user-agent among forwarded headers and /codes under the api allowance The proxy comments say a call carries the caller's user-agent, that a /codes call the route forwards counts against the `api` allowance, and that a value no code can be is refused before any count. That 404 carries `retry` and `detail`, as the Studio proxy's does. --- src/app/codes/[code]/route.ts | 5 ++++- src/lib/proxy/forward.ts | 10 +++++----- src/lib/proxy/rateLimit.ts | 5 +++-- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/app/codes/[code]/route.ts b/src/app/codes/[code]/route.ts index e7535a3..e7e72ed 100644 --- a/src/app/codes/[code]/route.ts +++ b/src/app/codes/[code]/route.ts @@ -12,7 +12,10 @@ export const GET = async (req: NextRequest, { params }: { params: Promise<{ code const target = codeTarget(searchApiBase(), code, req.nextUrl.search); if (!target) { - return Response.json({ error: 'not_found' }, { status: 404 }); + return Response.json( + { error: 'not_found', retry: 'do_not_retry', detail: 'There is no such refusal code.' }, + { status: 404 }, + ); } return forward(req, { diff --git a/src/lib/proxy/forward.ts b/src/lib/proxy/forward.ts index 58ff029..d823d46 100644 --- a/src/lib/proxy/forward.ts +++ b/src/lib/proxy/forward.ts @@ -14,11 +14,11 @@ import { hit, type Surface } from './rateLimit'; * The platform proxy: the DFO APIs and the MCP servers mirrored 1:1 under * the site's own domain, so one public host serves the browser and outside * callers alike (the engines ship no CORS headers, and the harness token - * must not reach a client). An /api/*, /codes or /mcp call is rate-limited per IP - * (src/lib/proxy/rateLimit; the X-RateLimit-* headers go out only on a - * counted call). A call is forwarded - * with its method, query, body, content headers and every mcp-* header - * (src/lib/proxy/headers), and the upstream's response streamed back: SSE + * must not reach a client). An /api/* or /mcp call, and a /codes call the + * route forwards, is rate-limited per IP (src/lib/proxy/rateLimit; the + * X-RateLimit-* headers go out only on a counted call). A call is forwarded + * with its method, query, body, content headers, user-agent and every mcp-* + * header (src/lib/proxy/headers), and the upstream's response streamed back: SSE * (search resolve, studio turns, MCP) and file downloads both depend on * nothing being buffered here. A text, JSON, XML or CSV body of 1,024 bytes * or more is gzipped on the way for a client that accepts it diff --git a/src/lib/proxy/rateLimit.ts b/src/lib/proxy/rateLimit.ts index 057538b..6fdc91f 100644 --- a/src/lib/proxy/rateLimit.ts +++ b/src/lib/proxy/rateLimit.ts @@ -7,7 +7,7 @@ import { countHit, pruneCounters } from './limitCounter'; import type { RateVerdict } from './rateHeaders'; /** - * Per-IP rate limit for the platform proxy's /api/*, /codes and /mcp routes + * Per-IP rate limit for the platform proxy's /api/*, /codes and /mcp calls * (src/lib/proxy/forward): a fixed window counted in Postgres * (./limitCounter), shared by every instance. An IPv6 client is counted by * its /64 (see limiterBucket). A count runs on its own small pool @@ -22,7 +22,8 @@ import type { RateVerdict } from './rateHeaders'; * * The window and the allowance per surface are env-tunable: * RATE_LIMIT_WINDOW_S (default 60) - * RATE_LIMIT_API calls per window per IP on /api/* (default 120) + * RATE_LIMIT_API calls per window per IP on /api/* and /codes + * (default 120) * RATE_LIMIT_PIN calls per window per IP on /api/download-pin * (default 10) * RATE_LIMIT_MCP requests per window per IP on /mcp (default 300) From 9be2d97d1521f25edae10c38abf0c904eee5e84b Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Wed, 21 Oct 2026 01:33:04 +0200 Subject: [PATCH 4/4] docs(proxy): split the code-shape comment into two sentences --- src/lib/proxy/codes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/proxy/codes.ts b/src/lib/proxy/codes.ts index 76d8d55..53b1335 100644 --- a/src/lib/proxy/codes.ts +++ b/src/lib/proxy/codes.ts @@ -7,7 +7,7 @@ */ // Every registry code is snake_case, so a code of this shape stays in the -// one path segment after /codes/; `.` and `..` would resolve out of it. +// one path segment after /codes/. `.` and `..` would resolve out of it. const CODE = /^[a-z][a-z0-9_]*$/; /** The registry address for `code`, or null for a value no code can be. */