diff --git a/src/server/catalog-download.ts b/src/server/catalog-download.ts index 984b22217c..4f8d64325c 100644 --- a/src/server/catalog-download.ts +++ b/src/server/catalog-download.ts @@ -11,6 +11,7 @@ * copy is the one nobody looks at in the dashboard. */ import { createHash } from "node:crypto"; +import { statSync } from "node:fs"; /** * Upper bound for the REMOTE route only. @@ -35,6 +36,29 @@ export interface SerializedCatalog { etag?: string; /** Byte length of `body`, present only when `body` is. */ bytes?: number; + /** Remote-only preflight failure; management serialization never sets this. */ + error?: "too_large"; +} + +interface CatalogFileIdentity { + key: string; + size: number; +} + +let remoteCache: { path: string; identity: string; serialized: SerializedCatalog } | undefined; +let remoteSerialization: { path: string; identity: string; result: Promise } | undefined; + +function catalogFileIdentity(path: string): CatalogFileIdentity | null { + try { + const stat = statSync(path, { bigint: true }); + if (!stat.isFile()) return null; + return { + key: `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`, + size: Number(stat.size), + }; + } catch { + return null; + } } export function catalogEtag(body: string): string { @@ -61,6 +85,46 @@ export async function serializePersistedCatalog(): Promise { return { body, etag: catalogEtag(body), bytes }; } +/** + * Serialize the catalog for the remotely reachable route. + * + * File size is checked before the synchronous reader can materialize it. A + * validated file identity caches the expensive parse/stringify/hash result, + * and the shared promise limits cache misses to one serialization at a time. + */ +export async function serializeRemotePersistedCatalog(): Promise { + const { readCodexCatalogPath } = await import("../codex/catalog"); + const path = readCodexCatalogPath(); + const identity = catalogFileIdentity(path); + if (!identity) return { body: null }; + if (identity.size > MAX_REMOTE_CATALOG_BYTES) return { body: null, error: "too_large" }; + if (remoteCache?.path === path && remoteCache.identity === identity.key) return remoteCache.serialized; + if (remoteSerialization) { + if (remoteSerialization.path === path && remoteSerialization.identity === identity.key) { + return remoteSerialization.result; + } + await remoteSerialization.result; + return serializeRemotePersistedCatalog(); + } + + const result = (async () => { + const serialized = await serializePersistedCatalog(); + if (serialized.bytes !== undefined && serialized.bytes > MAX_REMOTE_CATALOG_BYTES) { + return { body: null, error: "too_large" } as SerializedCatalog; + } + const after = catalogFileIdentity(path); + if (after?.key !== identity.key) return { body: null }; + if (serialized.body !== null) remoteCache = { path, identity: identity.key, serialized }; + return serialized; + })(); + remoteSerialization = { path, identity: identity.key, result }; + try { + return await result; + } finally { + if (remoteSerialization?.result === result) remoteSerialization = undefined; + } +} + /** * The authoritative Codex version for a catalog response, or undefined. * diff --git a/src/server/index.ts b/src/server/index.ts index 6c7e53f062..9be8578239 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1084,34 +1084,32 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server MAX_REMOTE_CATALOG_BYTES) { + if (serialized.body === null) { + // Built directly rather than through formatErrorResponse: that helper derives + // `code` from the status and message via classifyError, and these two need stable, + // specific codes. `catalog_not_found` in particular is what lets a caller — and + // tests/api-key-attribution.test.ts — tell "this route exists and has no catalog" + // apart from "this route is gone", which is the difference between admission proof + // and a vacuous pass. return withCors( new Response(JSON.stringify({ - error: { type: "server_error", code: "catalog_too_large", message: "catalog exceeds the maximum served size" }, + error: { type: "invalid_request_error", code: "catalog_not_found", message: "no materialized catalog is available" }, }), { - status: 507, + status: 404, headers: { "content-type": "application/json" }, }), req, diff --git a/tests/api-catalog-route.test.ts b/tests/api-catalog-route.test.ts index 634ce3e7e1..84d875495c 100644 --- a/tests/api-catalog-route.test.ts +++ b/tests/api-catalog-route.test.ts @@ -230,6 +230,24 @@ describe("GET|HEAD /v1/catalog least-privilege data-plane route (#809)", () => { expect(mgmt?.status).toBe(200); }); + test("caches remote serialization and preflights oversized files", async () => { + isolatedCodexHome = installIsolatedCodexHome("ocx-v1-catalog-cache-"); + const path = join(isolatedCodexHome.path, "opencodex-catalog.json"); + writeFileSync(path, JSON.stringify(catalogFixture)); + + const { serializeRemotePersistedCatalog, MAX_REMOTE_CATALOG_BYTES } = await import("../src/server/catalog-download"); + const first = await serializeRemotePersistedCatalog(); + const cached = await serializeRemotePersistedCatalog(); + expect(cached).toBe(first); + + // A sparse over-limit file proves the ceiling is enforced from metadata, + // before the synchronous JSON reader can allocate or parse its contents. + const { truncateSync } = await import("node:fs"); + truncateSync(path, MAX_REMOTE_CATALOG_BYTES + 1); + const oversized = await serializeRemotePersistedCatalog(); + expect(oversized).toEqual({ body: null, error: "too_large" }); + }); + test("reports a distinguishable code when no catalog is materialized", async () => { isolatedCodexHome = installIsolatedCodexHome("ocx-v1-catalog-missing-"); saveConfig(dataPlaneConfig());