diff --git a/packages/gateway/src/__tests__/kits-registry.test.ts b/packages/gateway/src/__tests__/kits-registry.test.ts new file mode 100644 index 000000000..ef6b85de8 --- /dev/null +++ b/packages/gateway/src/__tests__/kits-registry.test.ts @@ -0,0 +1,1085 @@ +/** + * Black-box tests for the Capability Kit registry (kits K1 slice 1). + * + * Source of truth: ai/.../spec-k1-slice1.md (the "Tests" section + the + * reviewer's detailed point-by-point checklist). Every assertion here is + * taken directly from that spec. Where the implementation disagrees with the + * spec, the assertion is left as the spec states it — a failure here is a + * reportable divergence, not a bug in the test. + * + * Strategy: service unit tests call KitRegistry methods directly; route + * tests go through Fastify's `app.inject` with `kitRoutes` mounted standalone + * (no api-gate/scope-checker — those are covered separately in + * kits-scope.test.ts). The authenticated principal is injected via an + * onRequest hook that sets `(req as any).operatorId`, mirroring + * compliance-routes.test.ts's `buildAuthedApp` pattern. + */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import Fastify, { type FastifyInstance } from "fastify"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { createHash } from "node:crypto"; +import { + canonicalize, + computeKitDigest, + normalizeKitManifest, + type CapabilityKitManifestV1, +} from "@pcc/spec"; +import { + KitRegistry, + KitRegistryError, + KitIntegrityError, + isDurableKitRoot, +} from "../services/kit-registry.js"; +import { kitRoutes } from "../routes/kits.js"; +import type { AuditEntry } from "../services/audit-service.js"; + +// ── Fixtures ───────────────────────────────────────────────────────────── + +/** `H("a")` => `sha256:aaaa...a` (64 hex chars). Matches the spec's helper exactly. */ +const H = (c: string) => `sha256:${c.repeat(64)}`; + +/** A COMPLETE manifest (passes validateKitCompleteness), per the spec's fixture. */ +function kit(overrides: Partial = {}): CapabilityKitManifestV1 { + return { + schema: "pcc.capability-kit/v1", + name: "OT-2 dye serial dilution", + version: "1.0.0", + parentKitDigest: null, + capabilities: [{ csdUrl: "pcc://capabilities/liquid-handling/v1", capabilityContractDigest: H("a") }], + artifacts: [ + { role: "method", name: "serial-dilution.py", mediaType: "text/x-python", digest: H("1") }, + { role: "tests", name: "checks.json", mediaType: "application/json", digest: H("5") }, + { role: "install-recipe", name: "INSTALL.md", mediaType: "text/markdown", digest: H("6") }, + { role: "provenance-recipe", name: "provenance.json", mediaType: "application/json", digest: H("7") }, + ], + compatibility: { deviceFamilies: ["opentrons-ot2"], interfaces: ["opentrons"] }, + declaredAssuranceTiers: [1], + economics: { spdxLicense: "Apache-2.0" }, + ...overrides, + } as CapabilityKitManifestV1; +} + +/** A manifest whose canonical form exceeds 256 KiB via 200 artifacts (zod's cap) each + * carrying a near-max `source` field (2000 chars, unconstrained in format). */ +function oversizedKit(): CapabilityKitManifestV1 { + const filler = (i: number) => ({ + role: "docs" as const, + name: `filler-${String(i).padStart(3, "0")}.md`, + mediaType: "text/markdown", + digest: H("0"), + source: "x".repeat(2000), + }); + return kit({ + version: "9.9.9", + name: "oversized kit for the 413 bound", + artifacts: [ + { role: "method", name: "impl.py", mediaType: "text/x-python", digest: H("1") }, + { role: "tests", name: "checks.json", mediaType: "application/json", digest: H("5") }, + { role: "install-recipe", name: "INSTALL.md", mediaType: "text/markdown", digest: H("6") }, + { role: "provenance-recipe", name: "provenance.json", mediaType: "application/json", digest: H("7") }, + ...Array.from({ length: 196 }, (_, i) => filler(i)), + ], + }); +} + +// ── Temp roots ─────────────────────────────────────────────────────────── + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((d) => fs.rm(d, { recursive: true, force: true }))); +}); + +async function mkTempRoot(): Promise { + const dir = await fs.mkdtemp(path.join(process.env.TMPDIR ?? os.tmpdir(), "kits-")); + tempDirs.push(dir); + return dir; +} + +/** + * A registry rooted in a fresh temp dir. `durable` defaults to `() => true` so + * tests are never at the mercy of the ambient NODE_ENV — the durability guard + * is tested explicitly in its own section below. + */ +async function mkRegistry( + opts: { + now?: () => Date; + dailyLimit?: number; + audit?: (entry: AuditEntry) => void; + durable?: () => boolean; + } = {}, +): Promise<{ registry: KitRegistry; rootDir: string }> { + const rootDir = await mkTempRoot(); + const registry = new KitRegistry({ rootDir, durable: () => true, ...opts }); + return { registry, rootDir }; +} + +/** `/manifests//.json` per the spec's on-disk layout. */ +function manifestFilePath(rootDir: string, digest: string): string { + const hex = digest.slice("sha256:".length); + return path.join(rootDir, "manifests", hex.slice(0, 2), `${hex}.json`); +} + +/** `/publications//.json` per the spec's on-disk layout. */ +function publicationFilePath(rootDir: string, digest: string): string { + const hex = digest.slice("sha256:".length); + return path.join(rootDir, "publications", hex.slice(0, 2), `${hex}.json`); +} + +async function writeRawPublication( + rootDir: string, + atDigest: string, + record: { schema: string; kitDigest: string; publisher: string; publishedAt: string }, +): Promise { + const file = publicationFilePath(rootDir, atDigest); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, canonicalize(record)); +} + +/** + * Build a Fastify app with only kitRoutes mounted. When `operatorId` is + * provided, an onRequest hook stamps `(req as any).operatorId` before the + * route runs (the compliance-routes.test.ts `buildAuthedApp` pattern). + * Omitting it simulates an unauthenticated caller (no principal at all). + */ +async function buildApp(registry: KitRegistry, operatorId?: string, scopes: string[] = ["template_author"]): Promise { + const app = Fastify({ logger: false }); + if (operatorId !== undefined) { + app.decorateRequest("operatorId", null); + app.decorateRequest("apiKeyId", null); + // An authenticated API-key caller, as api-gate records one (k1-511: publishing needs an API key with a publishing scope). + app.addHook("onRequest", async (req) => { + (req as unknown as { operatorId: string }).operatorId = operatorId; + (req as unknown as { apiKeyId: string }).apiKeyId = `key-${operatorId}`; + }); + } + await app.register(kitRoutes, { registry, callerScopes: () => scopes }); + return app; +} + +// ── Publish and read ───────────────────────────────────────────────────── + +describe("publish and read", () => { + it("publish then get round-trips the normalized manifest", async () => { + const { registry } = await mkRegistry(); + const app = await buildApp(registry, "alice@kits.test"); + const manifest = kit({ version: "1.0.0" }); + + const publishRes = await app.inject({ method: "POST", url: "/api/kits", payload: manifest }); + expect(publishRes.statusCode).toBe(201); + const { kitDigest } = publishRes.json(); + + const getRes = await app.inject({ method: "GET", url: `/api/kits/${kitDigest}` }); + expect(getRes.statusCode).toBe(200); + const body = getRes.json(); + expect(body.kit.kitDigest).toBe(kitDigest); + expect(body.kit.manifest).toEqual(normalizeKitManifest(manifest)); + expect(typeof body.kit.publishedAt).toBe("string"); + await app.close(); + }); + + it("the digest equals computeKitDigest", async () => { + const { registry } = await mkRegistry(); + const app = await buildApp(registry, "alice@kits.test"); + const manifest = kit({ version: "1.0.1" }); + + const res = await app.inject({ method: "POST", url: "/api/kits", payload: manifest }); + expect(res.json().kitDigest).toBe(await computeKitDigest(manifest)); + await app.close(); + }); + + it("two manifests differing only in artifact list order get the same digest; the second publish is created:false", async () => { + const { registry } = await mkRegistry(); + const app = await buildApp(registry, "alice@kits.test"); + const manifest = kit({ version: "1.0.2" }); + const reordered = { ...manifest, artifacts: [...manifest.artifacts].reverse() }; + + const res1 = await app.inject({ method: "POST", url: "/api/kits", payload: manifest }); + expect(res1.statusCode).toBe(201); + expect(res1.json().created).toBe(true); + + const res2 = await app.inject({ method: "POST", url: "/api/kits", payload: reordered }); + expect(res2.statusCode).toBe(200); + expect(res2.json().created).toBe(false); + expect(res2.json().kitDigest).toBe(res1.json().kitDigest); + await app.close(); + }); + + it("a re-publish by another principal is created:false, and the publisher on record is unchanged", async () => { + const { registry } = await mkRegistry(); + const manifest = kit({ version: "1.0.3" }); + const appAlice = await buildApp(registry, "alice@kits.test"); + const res1 = await appAlice.inject({ method: "POST", url: "/api/kits", payload: manifest }); + const digest = res1.json().kitDigest; + + const appBob = await buildApp(registry, "bob@kits.test"); + const res2 = await appBob.inject({ method: "POST", url: "/api/kits", payload: manifest }); + expect(res2.statusCode).toBe(200); + expect(res2.json().created).toBe(false); + expect(await registry.publisherOf(digest)).toBe("alice@kits.test"); + + await appAlice.close(); + await appBob.close(); + }); +}); + +// ── Service unit: get() ────────────────────────────────────────────────── + +describe("service unit: KitRegistry.get", () => { + it("throws KitRegistryError invalid_digest (400) for a malformed digest", async () => { + const { registry } = await mkRegistry(); + const err = await registry.get("not-a-digest").catch((e: unknown) => e); + expect(err).toBeInstanceOf(KitRegistryError); + expect((err as KitRegistryError).code).toBe("invalid_digest"); + expect((err as KitRegistryError).status).toBe(400); + }); + + it("returns null for a well-formed digest that was never published", async () => { + const { registry } = await mkRegistry(); + expect(await registry.get(H("e"))).toBeNull(); + }); +}); + +// ── Integrity ───────────────────────────────────────────────────────────── + +describe("integrity", () => { + it("flipping one byte of a stored manifest: get gives 500; after a reload the kit is skipped and absent from list", async () => { + const { registry, rootDir } = await mkRegistry(); + const app = await buildApp(registry, "alice@kits.test"); + const res = await app.inject({ method: "POST", url: "/api/kits", payload: kit({ version: "1.1.0" }) }); + const digest = res.json().kitDigest; + + const mp = manifestFilePath(rootDir, digest); + const bytes = await fs.readFile(mp); + bytes[0] = bytes[0]! ^ 0xff; + await fs.writeFile(mp, bytes); + + const getRes = await app.inject({ method: "GET", url: `/api/kits/${digest}` }); + expect(getRes.statusCode).toBe(500); + expect(getRes.json().error).toBe("kit_integrity_failure"); + + const reloaded = new KitRegistry({ rootDir, durable: () => true }); + const list = await reloaded.list(); + expect(list.kits.find((k) => k.kitDigest === digest)).toBeUndefined(); + expect(reloaded.skipped).toContain(digest); + + await app.close(); + }); + + it("service unit: KitRegistry.get throws KitIntegrityError on corrupted manifest bytes", async () => { + const { registry, rootDir } = await mkRegistry(); + const result = await registry.publish(kit({ version: "1.1.05" }), "alice@kits.test"); + const mp = manifestFilePath(rootDir, result.kitDigest); + const bytes = await fs.readFile(mp); + bytes[0] = bytes[0]! ^ 0xff; + await fs.writeFile(mp, bytes); + + const err = await registry.get(result.kitDigest).catch((e: unknown) => e); + expect(err).toBeInstanceOf(KitIntegrityError); + expect((err as KitIntegrityError).code).toBe("kit_integrity_failure"); + }); + + it("refuses hash-valid but non-canonical manifest bytes (pretty-printed JSON)", async () => { + const { registry, rootDir } = await mkRegistry(); + const manifest = normalizeKitManifest(kit({ version: "1.1.1" })); + const prettyPrinted = JSON.stringify(manifest, null, 2); + // Sanity: pretty-printing really does differ from the canonical form. + expect(prettyPrinted).not.toBe(canonicalize(manifest)); + const digest = `sha256:${createHash("sha256").update(prettyPrinted, "utf8").digest("hex")}`; + + const mp = manifestFilePath(rootDir, digest); + await fs.mkdir(path.dirname(mp), { recursive: true }); + await fs.writeFile(mp, prettyPrinted, "utf8"); + await writeRawPublication(rootDir, digest, { + schema: "pcc.kit-publication.v1", + kitDigest: digest, + publisher: "attacker@kits.test", + publishedAt: new Date(0).toISOString(), + }); + + const app = await buildApp(registry, "alice@kits.test"); + const getRes = await app.inject({ method: "GET", url: `/api/kits/${digest}` }); + expect(getRes.statusCode).toBe(500); + expect(getRes.json().error).toBe("kit_integrity_failure"); + await app.close(); + }); + + it("refuses another kit's valid, canonical manifest stored under this digest (kits K1, own review)", async () => { + const { registry, rootDir } = await mkRegistry(); + const a = await registry.publish(kit({ version: "7.0.0" }), "alice@kits.test"); + const b = await registry.publish(kit({ version: "7.0.1" }), "alice@kits.test"); + // B's bytes are a valid canonical manifest, just not the one A's digest names. + await fs.writeFile(manifestFilePath(rootDir, a.kitDigest), await fs.readFile(manifestFilePath(rootDir, b.kitDigest))); + await expect(registry.get(a.kitDigest)).rejects.toBeInstanceOf(KitIntegrityError); + const app = await buildApp(registry, "alice@kits.test"); + const res = await app.inject({ method: "GET", url: `/api/kits/${a.kitDigest}` }); + expect(res.statusCode).toBe(500); + expect(res.json().error).toBe("kit_integrity_failure"); + await app.close(); + }); + + it("refuses a publication record naming another digest", async () => { + const { registry, rootDir } = await mkRegistry(); + const app = await buildApp(registry, "alice@kits.test"); + const res = await app.inject({ method: "POST", url: "/api/kits", payload: kit({ version: "1.1.2" }) }); + const digest = res.json().kitDigest; + const otherDigest = H("9"); + + // Overwrite the publication record stored AT `digest`'s path so its own + // content claims a different digest entirely. + await writeRawPublication(rootDir, digest, { + schema: "pcc.kit-publication.v1", + kitDigest: otherDigest, + publisher: "alice@kits.test", + publishedAt: res.json().publishedAt, + }); + + const getRes = await app.inject({ method: "GET", url: `/api/kits/${digest}` }); + expect(getRes.statusCode).toBe(500); + expect(getRes.json().error).toBe("kit_integrity_failure"); + await app.close(); + }); + + it("refuses a publication record whose manifest bytes are missing", async () => { + const { registry, rootDir } = await mkRegistry(); + const digest = H("7"); + await writeRawPublication(rootDir, digest, { + schema: "pcc.kit-publication.v1", + kitDigest: digest, + publisher: "alice@kits.test", + publishedAt: new Date(0).toISOString(), + }); + + const app = await buildApp(registry, "alice@kits.test"); + const getRes = await app.inject({ method: "GET", url: `/api/kits/${digest}` }); + expect(getRes.statusCode).toBe(500); + expect(getRes.json().error).toBe("kit_integrity_failure"); + await app.close(); + }); +}); + +// ── Concurrency ─────────────────────────────────────────────────────────── + +describe("concurrency", () => { + it("same-instance: two concurrent first publishes of one digest yield exactly one created:true, one publication file, and publisherOf returns the winner", async () => { + const { registry, rootDir } = await mkRegistry(); + const manifest = kit({ version: "1.2.0" }); + + const [r1, r2] = await Promise.all([ + registry.publish(manifest, "alice@kits.test"), + registry.publish(manifest, "bob@kits.test"), + ]); + + expect([r1.created, r2.created].filter(Boolean)).toHaveLength(1); + expect(r1.kitDigest).toBe(r2.kitDigest); + expect(r1.publishedAt).toBe(r2.publishedAt); + + const digest = r1.kitDigest; + const hex = digest.slice("sha256:".length); + const files = await fs.readdir(path.join(rootDir, "publications", hex.slice(0, 2))); + expect(files).toEqual([`${hex}.json`]); + + const winner = await registry.publisherOf(digest); + expect(["alice@kits.test", "bob@kits.test"]).toContain(winner); + }); + + it("cross-instance: two separate KitRegistry instances on the same root concurrently publishing the same new digest still yield exactly one record", async () => { + const rootDir = await mkTempRoot(); + const regA = new KitRegistry({ rootDir, durable: () => true }); + const regB = new KitRegistry({ rootDir, durable: () => true }); + const manifest = kit({ version: "1.2.1" }); + + const [rA, rB] = await Promise.all([ + regA.publish(manifest, "alice@kits.test"), + regB.publish(manifest, "bob@kits.test"), + ]); + + expect([rA.created, rB.created].filter(Boolean)).toHaveLength(1); + expect(rA.kitDigest).toBe(rB.kitDigest); + expect(rA.publishedAt).toBe(rB.publishedAt); + + const digest = rA.kitDigest; + const hex = digest.slice("sha256:".length); + const files = await fs.readdir(path.join(rootDir, "publications", hex.slice(0, 2))); + expect(files).toEqual([`${hex}.json`]); + + const winnerPublisher = rA.created ? "alice@kits.test" : "bob@kits.test"; + expect(await regA.publisherOf(digest)).toBe(winnerPublisher); + expect(await regB.publisherOf(digest)).toBe(winnerPublisher); + }); +}); + +// ── Validation ──────────────────────────────────────────────────────────── + +describe("validation", () => { + it("400 invalid_manifest and never echoes the manifest's description text", async () => { + const { registry } = await mkRegistry(); + const app = await buildApp(registry, "alice@kits.test"); + const secretDescription = "TOP-SECRET-DESCRIPTION-MARKER-should-never-be-echoed"; + const bad = { ...kit({ version: "not-semver" }), description: secretDescription }; + + const res = await app.inject({ method: "POST", url: "/api/kits", payload: bad }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe("invalid_manifest"); + expect(res.body).not.toContain(secretDescription); + await app.close(); + }); + + it("413 manifest_too_large when the canonical manifest exceeds 256 KiB", async () => { + const { registry } = await mkRegistry(); + const app = await buildApp(registry, "alice@kits.test"); + const big = oversizedKit(); + + const canonicalBytes = Buffer.byteLength(canonicalize(normalizeKitManifest(big)), "utf8"); + // Sanity: confirm the fixture is genuinely oversized before trusting a 413. + expect(canonicalBytes).toBeGreaterThan(256 * 1024); + + const res = await app.inject({ method: "POST", url: "/api/kits", payload: big }); + expect(res.statusCode).toBe(413); + expect(res.json().error).toBe("manifest_too_large"); + await app.close(); + }); + + it("422 incomplete_kit with the missing requirements", async () => { + const { registry } = await mkRegistry(); + const app = await buildApp(registry, "alice@kits.test"); + const incomplete = kit({ version: "1.3.2", artifacts: [kit().artifacts[0]!] }); + + const res = await app.inject({ method: "POST", url: "/api/kits", payload: incomplete }); + expect(res.statusCode).toBe(422); + expect(res.json().error).toBe("incomplete_kit"); + expect(res.json().missing).toEqual( + expect.arrayContaining(["role:tests", "role:install-recipe", "role:provenance-recipe"]), + ); + await app.close(); + }); + + it("422 unknown_parent when parentKitDigest names no published kit", async () => { + const { registry } = await mkRegistry(); + const app = await buildApp(registry, "alice@kits.test"); + const res = await app.inject({ + method: "POST", + url: "/api/kits", + payload: kit({ version: "1.3.3", parentKitDigest: H("9") }), + }); + expect(res.statusCode).toBe(422); + expect(res.json().error).toBe("unknown_parent"); + await app.close(); + }); + + it("fork 400 parent_mismatch when the manifest's parentKitDigest differs from the URL digest", async () => { + const { registry } = await mkRegistry(); + const app = await buildApp(registry, "alice@kits.test"); + const resA = await app.inject({ method: "POST", url: "/api/kits", payload: kit({ version: "1.3.40" }) }); + const resB = await app.inject({ method: "POST", url: "/api/kits", payload: kit({ version: "1.3.41" }) }); + const digestA = resA.json().kitDigest; + const digestB = resB.json().kitDigest; + + const forkManifest = kit({ version: "1.3.5", parentKitDigest: digestB, name: "mismatched fork" }); + const res = await app.inject({ method: "POST", url: `/api/kits/${digestA}/fork`, payload: forkManifest }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe("parent_mismatch"); + await app.close(); + }); + + it("fork 404 when the URL digest names no published kit", async () => { + const { registry } = await mkRegistry(); + const app = await buildApp(registry, "alice@kits.test"); + const absentDigest = H("c"); + const forkManifest = kit({ version: "1.3.6", parentKitDigest: absentDigest, name: "fork of nothing" }); + + const res = await app.inject({ method: "POST", url: `/api/kits/${absentDigest}/fork`, payload: forkManifest }); + expect(res.statusCode).toBe(404); + await app.close(); + }); + + it("400 invalid_digest on GET with a malformed digest", async () => { + const { registry } = await mkRegistry(); + const app = await buildApp(registry, "alice@kits.test"); + const res = await app.inject({ method: "GET", url: "/api/kits/not-a-digest" }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe("invalid_digest"); + await app.close(); + }); + + it("400 invalid_digest on fork with a malformed URL digest", async () => { + const { registry } = await mkRegistry(); + const app = await buildApp(registry, "alice@kits.test"); + const res = await app.inject({ + method: "POST", + url: "/api/kits/not-a-digest/fork", + payload: kit({ version: "1.3.7" }), + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe("invalid_digest"); + await app.close(); + }); +}); + +// ── Auth ────────────────────────────────────────────────────────────────── + +describe("auth", () => { + it("401 missing_identity on publish without a principal", async () => { + const { registry } = await mkRegistry(); + const app = await buildApp(registry); + const res = await app.inject({ method: "POST", url: "/api/kits", payload: kit({ version: "1.4.0" }) }); + expect(res.statusCode).toBe(401); + expect(res.json().error).toBe("missing_identity"); + await app.close(); + }); + + it("401 missing_identity on fork without a principal", async () => { + const { registry } = await mkRegistry(); + const appAlice = await buildApp(registry, "alice@kits.test"); + const parentRes = await appAlice.inject({ method: "POST", url: "/api/kits", payload: kit({ version: "1.4.1" }) }); + const parentDigest = parentRes.json().kitDigest; + + const appAnon = await buildApp(registry); + const res = await appAnon.inject({ + method: "POST", + url: `/api/kits/${parentDigest}/fork`, + payload: kit({ version: "1.4.2", parentKitDigest: parentDigest, name: "anon fork attempt" }), + }); + expect(res.statusCode).toBe(401); + expect(res.json().error).toBe("missing_identity"); + + await appAlice.close(); + await appAnon.close(); + }); +}); + +// ── Privacy ─────────────────────────────────────────────────────────────── + +describe("privacy", () => { + const PUBLISHER = "alice@kits.test"; + + it("never leaks the publisher string in list, get, publish, fork, or error response bodies", async () => { + const { registry } = await mkRegistry(); + const app = await buildApp(registry, PUBLISHER); + + const publishRes = await app.inject({ method: "POST", url: "/api/kits", payload: kit({ version: "1.5.0" }) }); + expect(publishRes.body).not.toContain(PUBLISHER); + const digest = publishRes.json().kitDigest; + + const getRes = await app.inject({ method: "GET", url: `/api/kits/${digest}` }); + expect(getRes.body).not.toContain(PUBLISHER); + + const listRes = await app.inject({ method: "GET", url: "/api/kits" }); + expect(listRes.body).not.toContain(PUBLISHER); + + const forkRes = await app.inject({ + method: "POST", + url: `/api/kits/${digest}/fork`, + payload: kit({ version: "1.5.1", parentKitDigest: digest, name: "fork of dilution" }), + }); + expect(forkRes.body).not.toContain(PUBLISHER); + + // Re-publish (created:false path) must also stay silent. + const republishRes = await app.inject({ method: "POST", url: "/api/kits", payload: kit({ version: "1.5.0" }) }); + expect(republishRes.body).not.toContain(PUBLISHER); + + // Error paths (incomplete manifest) must also stay silent. + const badRes = await app.inject({ + method: "POST", + url: "/api/kits", + payload: { ...kit({ version: "1.5.2" }), artifacts: [kit().artifacts[0]!] }, + }); + expect(badRes.body).not.toContain(PUBLISHER); + + await app.close(); + }); +}); + +// ── Quota ───────────────────────────────────────────────────────────────── + +describe("quota", () => { + it("the 3rd new kit by one publisher in 24h gives 429; a re-publish doesn't count; another publisher is unaffected; the clock rolling 24h+1ms frees the window", async () => { + let clock = new Date("2026-01-01T00:00:00.000Z"); + const { registry } = await mkRegistry({ dailyLimit: 2, now: () => clock }); + const appAlice = await buildApp(registry, "alice@kits.test"); + const appBob = await buildApp(registry, "bob@kits.test"); + + const k1 = kit({ version: "2.0.0" }); + const k2 = kit({ version: "2.0.1" }); + const k3 = kit({ version: "2.0.2" }); + const k4 = kit({ version: "2.0.3" }); + + const r1 = await appAlice.inject({ method: "POST", url: "/api/kits", payload: k1 }); + expect(r1.statusCode).toBe(201); + const r2 = await appAlice.inject({ method: "POST", url: "/api/kits", payload: k2 }); + expect(r2.statusCode).toBe(201); + + // Re-publishing an existing digest must not count against the quota. + const rRepublish = await appAlice.inject({ method: "POST", url: "/api/kits", payload: k1 }); + expect(rRepublish.statusCode).toBe(200); + expect(rRepublish.json().created).toBe(false); + + const r3 = await appAlice.inject({ method: "POST", url: "/api/kits", payload: k3 }); + expect(r3.statusCode).toBe(429); + expect(r3.json().error).toBe("publish_quota"); + + // Another publisher is unaffected by alice's quota, and may publish the + // very digest alice was refused (it was never actually written). + const rBob = await appBob.inject({ method: "POST", url: "/api/kits", payload: k3 }); + expect(rBob.statusCode).toBe(201); + + // Re-publishing an existing digest never counts against the re-publisher either: + // bob is now at his cap of 2 (k3 above plus one more), yet re-publishing alice's k1 is 200. + const rBob2 = await appBob.inject({ method: "POST", url: "/api/kits", payload: kit({ version: "2.0.9" }) }); + expect(rBob2.statusCode).toBe(201); + const rBobRepublish = await appBob.inject({ method: "POST", url: "/api/kits", payload: k1 }); + expect(rBobRepublish.statusCode).toBe(200); + expect(rBobRepublish.json().created).toBe(false); + expect(await registry.publisherOf(r1.json().kitDigest)).toBe("alice@kits.test"); + + // Advance the injected clock 24h + 1ms: alice's first two publishes roll + // out of the rolling window, freeing her to publish again. + clock = new Date(clock.getTime() + 24 * 60 * 60 * 1000 + 1); + const r4 = await appAlice.inject({ method: "POST", url: "/api/kits", payload: k4 }); + expect(r4.statusCode).toBe(201); + + await appAlice.close(); + await appBob.close(); + }); +}); + +// ── Durability guard ─────────────────────────────────────────────────────── + +describe("durability guard", () => { + it("503 registry_not_durable on publish when the root is not durable; GET and list still work", async () => { + const { registry } = await mkRegistry({ durable: () => false }); + const app = await buildApp(registry, "alice@kits.test"); + + const res = await app.inject({ method: "POST", url: "/api/kits", payload: kit({ version: "3.0.0" }) }); + expect(res.statusCode).toBe(503); + expect(res.json().error).toBe("registry_not_durable"); + + const listRes = await app.inject({ method: "GET", url: "/api/kits" }); + expect(listRes.statusCode).toBe(200); + + const getRes = await app.inject({ method: "GET", url: `/api/kits/${H("d")}` }); + expect(getRes.statusCode).toBe(404); + + await app.close(); + }); +}); + +describe("isDurableKitRoot", () => { + const ENV_KEYS = ["NODE_ENV", "RAILWAY_VOLUME_MOUNT_PATH", "PCC_KIT_REGISTRY_DIR"] as const; + let saved: Record; + + function setEnv(k: (typeof ENV_KEYS)[number], v: string | undefined): void { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + + afterEach(() => { + for (const k of ENV_KEYS) setEnv(k, saved[k]); + }); + + function snapshot(): void { + saved = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); + } + + it("is false in production with neither a volume mount nor an explicit registry dir", () => { + snapshot(); + setEnv("NODE_ENV", "production"); + setEnv("RAILWAY_VOLUME_MOUNT_PATH", undefined); + setEnv("PCC_KIT_REGISTRY_DIR", undefined); + expect(isDurableKitRoot()).toBe(false); + }); + + it("is true in production with RAILWAY_VOLUME_MOUNT_PATH set", () => { + snapshot(); + setEnv("NODE_ENV", "production"); + setEnv("RAILWAY_VOLUME_MOUNT_PATH", "/data"); + setEnv("PCC_KIT_REGISTRY_DIR", undefined); + expect(isDurableKitRoot()).toBe(true); + }); + + it("is true in production with PCC_KIT_REGISTRY_DIR set", () => { + snapshot(); + setEnv("NODE_ENV", "production"); + setEnv("RAILWAY_VOLUME_MOUNT_PATH", undefined); + setEnv("PCC_KIT_REGISTRY_DIR", "/some/explicit/dir"); + expect(isDurableKitRoot()).toBe(true); + }); + + it("is true outside production regardless of volume/dir vars", () => { + snapshot(); + setEnv("NODE_ENV", "development"); + setEnv("RAILWAY_VOLUME_MOUNT_PATH", undefined); + setEnv("PCC_KIT_REGISTRY_DIR", undefined); + expect(isDurableKitRoot()).toBe(true); + }); +}); + +// ── Lists ───────────────────────────────────────────────────────────────── + +describe("list", () => { + async function seedCorpus() { + let clock = new Date("2026-02-01T00:00:00.000Z"); + const { registry } = await mkRegistry({ now: () => clock }); + const app = await buildApp(registry, "alice@kits.test"); + const specs = [ + { + version: "4.0.0", + name: "Liquid handling basics", + csdUrl: "pcc://capabilities/liquid-handling/v1", + deviceFamily: "opentrons-ot2", + iface: "opentrons", + }, + { + version: "4.0.1", + name: "CNC milling starter", + csdUrl: "pcc://capabilities/cnc-milling/v1", + deviceFamily: "shapeoko-pro", + iface: "generic-http", + }, + { + version: "4.0.2", + name: "Liquid handling advanced", + csdUrl: "pcc://capabilities/liquid-handling/v1", + deviceFamily: "hamilton-star", + iface: "hamilton", + }, + ]; + const published: Array<{ digest: string; publishedAt: string; name: string }> = []; + for (const s of specs) { + const manifest = kit({ + version: s.version, + name: s.name, + capabilities: [{ csdUrl: s.csdUrl, capabilityContractDigest: H("a") }], + compatibility: { deviceFamilies: [s.deviceFamily], interfaces: [s.iface] }, + }); + const res = await app.inject({ method: "POST", url: "/api/kits", payload: manifest }); + published.push({ digest: res.json().kitDigest, publishedAt: res.json().publishedAt, name: s.name }); + clock = new Date(clock.getTime() + 60_000); + } + return { registry, app, published }; + } + + it("filters by csdUrl exactly", async () => { + const { app } = await seedCorpus(); + const res = await app.inject({ + method: "GET", + url: `/api/kits?csdUrl=${encodeURIComponent("pcc://capabilities/liquid-handling/v1")}`, + }); + expect(res.statusCode).toBe(200); + const names = res.json().kits.map((k: { name: string }) => k.name).sort(); + expect(names).toEqual(["Liquid handling advanced", "Liquid handling basics"]); + await app.close(); + }); + + it("filters by deviceFamily exactly", async () => { + const { app } = await seedCorpus(); + const res = await app.inject({ method: "GET", url: "/api/kits?deviceFamily=hamilton-star" }); + expect(res.json().kits.map((k: { name: string }) => k.name)).toEqual(["Liquid handling advanced"]); + await app.close(); + }); + + it("filters by interface exactly", async () => { + const { app } = await seedCorpus(); + const res = await app.inject({ method: "GET", url: "/api/kits?interface=generic-http" }); + expect(res.json().kits.map((k: { name: string }) => k.name)).toEqual(["CNC milling starter"]); + await app.close(); + }); + + it("filters by q, a case-insensitive substring of the name", async () => { + const { app } = await seedCorpus(); + const res = await app.inject({ method: "GET", url: "/api/kits?q=LIQUID" }); + const names = res.json().kits.map((k: { name: string }) => k.name).sort(); + expect(names).toEqual(["Liquid handling advanced", "Liquid handling basics"]); + await app.close(); + }); + + it("filters by q, a case-insensitive substring of the description", async () => { + const { registry } = await mkRegistry(); + const app = await buildApp(registry, "alice@kits.test"); + await app.inject({ + method: "POST", + url: "/api/kits", + payload: kit({ version: "4.2.0", name: "zzz", description: "Supports PIPETTE calibration" }), + }); + const res = await app.inject({ method: "GET", url: "/api/kits?q=pipette" }); + expect(res.json().kits.map((k: { name: string }) => k.name)).toEqual(["zzz"]); + await app.close(); + }); + + it("orders by publishedAt descending, then kitDigest ascending; total reflects the full match count", async () => { + const { app, published } = await seedCorpus(); + const res = await app.inject({ method: "GET", url: "/api/kits" }); + const body = res.json(); + expect(body.total).toBe(3); + const expectedOrder = [...published].reverse().map((p) => p.digest); + expect(body.kits.map((k: { kitDigest: string }) => k.kitDigest)).toEqual(expectedOrder); + await app.close(); + }); + + it("breaks publishedAt ties by kitDigest ascending", async () => { + const fixedClock = new Date("2026-03-01T00:00:00.000Z"); + const { registry } = await mkRegistry({ now: () => fixedClock }); + const app = await buildApp(registry, "alice@kits.test"); + const resA = await app.inject({ method: "POST", url: "/api/kits", payload: kit({ version: "4.1.0", name: "tie A" }) }); + const resB = await app.inject({ method: "POST", url: "/api/kits", payload: kit({ version: "4.1.1", name: "tie B" }) }); + // Confirm this really is a tie before trusting the tie-break assertion. + expect(resA.json().publishedAt).toBe(resB.json().publishedAt); + + const [dA, dB] = [resA.json().kitDigest, resB.json().kitDigest].sort(); + const res = await app.inject({ method: "GET", url: "/api/kits" }); + expect(res.json().kits.map((k: { kitDigest: string }) => k.kitDigest)).toEqual([dA, dB]); + await app.close(); + }); + + it("limit/offset paginate; total still reflects the full match count", async () => { + const { app } = await seedCorpus(); + const page = await app.inject({ method: "GET", url: "/api/kits?limit=1&offset=1" }); + const full = await app.inject({ method: "GET", url: "/api/kits" }); + expect(page.json().total).toBe(3); + expect(page.json().kits).toHaveLength(1); + expect(page.json().kits[0].kitDigest).toBe(full.json().kits[1].kitDigest); + await app.close(); + }); + + it("400 on a bad query: limit 0, limit 101, offset -1, unknown param", async () => { + const { app } = await seedCorpus(); + for (const qs of ["limit=0", "limit=101", "offset=-1", "bogus=1"]) { + const res = await app.inject({ method: "GET", url: `/api/kits?${qs}` }); + expect(res.statusCode, `expected 400 for ?${qs}`).toBe(400); + } + await app.close(); + }); +}); + +// ── Reload ──────────────────────────────────────────────────────────────── + +describe("reload", () => { + it("a fresh KitRegistry on the same root lists the same kits in the same order", async () => { + let clock = new Date("2026-04-01T00:00:00.000Z"); + const rootDir = await mkTempRoot(); + const registry1 = new KitRegistry({ rootDir, durable: () => true, now: () => clock }); + const app1 = await buildApp(registry1, "alice@kits.test"); + for (const v of ["5.0.0", "5.0.1", "5.0.2"]) { + await app1.inject({ method: "POST", url: "/api/kits", payload: kit({ version: v }) }); + clock = new Date(clock.getTime() + 60_000); + } + const before = (await app1.inject({ method: "GET", url: "/api/kits" })).json(); + + const registry2 = new KitRegistry({ rootDir, durable: () => true }); + const app2 = await buildApp(registry2, "alice@kits.test"); + const after = (await app2.inject({ method: "GET", url: "/api/kits" })).json(); + + expect(after.total).toBe(before.total); + expect(after.kits.map((k: { kitDigest: string }) => k.kitDigest)).toEqual( + before.kits.map((k: { kitDigest: string }) => k.kitDigest), + ); + + await app1.close(); + await app2.close(); + }); +}); + +// ── Audit ───────────────────────────────────────────────────────────────── + +describe("audit", () => { + it("writes exactly one kit.published entry per created publish, actor=publisher, resourceId=digest", async () => { + const audit = vi.fn(); + const { registry } = await mkRegistry({ audit }); + const app = await buildApp(registry, "alice@kits.test"); + + const res = await app.inject({ method: "POST", url: "/api/kits", payload: kit({ version: "6.0.0" }) }); + const digest = res.json().kitDigest; + + expect(audit).toHaveBeenCalledTimes(1); + expect(audit).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: "kit.published", + actor: "alice@kits.test", + resourceType: "capability-kit", + resourceId: digest, + }), + ); + await app.close(); + }); + + it("writes kit.forked for a fork", async () => { + const audit = vi.fn(); + const { registry } = await mkRegistry({ audit }); + const app = await buildApp(registry, "alice@kits.test"); + + const parentRes = await app.inject({ method: "POST", url: "/api/kits", payload: kit({ version: "6.1.0" }) }); + const parentDigest = parentRes.json().kitDigest; + audit.mockClear(); + + const forkRes = await app.inject({ + method: "POST", + url: `/api/kits/${parentDigest}/fork`, + payload: kit({ version: "6.1.1", parentKitDigest: parentDigest, name: "forked kit" }), + }); + const forkDigest = forkRes.json().kitDigest; + + expect(audit).toHaveBeenCalledTimes(1); + expect(audit).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: "kit.forked", + actor: "alice@kits.test", + resourceType: "capability-kit", + resourceId: forkDigest, + }), + ); + await app.close(); + }); + + it("writes no audit entry for a re-publish of an existing digest", async () => { + const audit = vi.fn(); + const { registry } = await mkRegistry({ audit }); + const app = await buildApp(registry, "alice@kits.test"); + const manifest = kit({ version: "6.2.0" }); + + await app.inject({ method: "POST", url: "/api/kits", payload: manifest }); + audit.mockClear(); + const res = await app.inject({ method: "POST", url: "/api/kits", payload: manifest }); + + expect(res.json().created).toBe(false); + expect(audit).not.toHaveBeenCalled(); + await app.close(); + }); +}); + +// ── astra k1-511: durable reads, no symlinks, a hard-link probe, an exact cross-process quota ── + +describe("astra k1-511: the files are the only truth", () => { + it("MEDIUM: list() stops advertising a kit whose files stop verifying (no stale cache)", async () => { + const { registry, rootDir } = await mkRegistry(); + const a = await registry.publish(kit({ version: "8.0.0" }), "alice@kits.test"); + const b = await registry.publish(kit({ version: "8.0.1" }), "alice@kits.test"); + expect((await registry.list()).total).toBe(2); + await fs.rm(manifestFilePath(rootDir, a.kitDigest)); + await fs.writeFile(publicationFilePath(rootDir, b.kitDigest), "{}"); + const after = await registry.list(); + expect(after.total).toBe(0); + expect([...registry.skipped].sort()).toEqual([a.kitDigest, b.kitDigest].sort()); + }); + + it("MEDIUM: a kit another registry published on the same root is listed at once", async () => { + const rootDir = await mkTempRoot(); + const a = new KitRegistry({ rootDir, durable: () => true, audit: () => undefined }); + const b = new KitRegistry({ rootDir, durable: () => true, audit: () => undefined }); + expect((await a.list()).total).toBe(0); + const { kitDigest } = await b.publish(kit({ version: "8.1.0" }), "bob@kits.test"); + expect((await a.list()).kits.map((k) => k.kitDigest)).toEqual([kitDigest]); + // ...and A treats it as existing: re-publishing it there is created:false, publisher unchanged. + expect((await a.publish(kit({ version: "8.1.0" }), "alice@kits.test")).created).toBe(false); + expect(await a.publisherOf(kitDigest)).toBe("bob@kits.test"); + }); + + it("MEDIUM: a symlinked publication record is refused, never followed (get throws, list skips)", async () => { + const { registry, rootDir } = await mkRegistry(); + const { kitDigest } = await registry.publish(kit({ version: "8.2.0" }), "alice@kits.test"); + const outside = path.join(await mkTempRoot(), "planted.json"); + await fs.copyFile(publicationFilePath(rootDir, kitDigest), outside); + await fs.rm(publicationFilePath(rootDir, kitDigest)); + await fs.symlink(outside, publicationFilePath(rootDir, kitDigest)); + await expect(registry.get(kitDigest)).rejects.toBeInstanceOf(KitIntegrityError); + expect((await registry.list()).total).toBe(0); + expect(registry.skipped).toContain(kitDigest); + }); + + it("MEDIUM: a symlinked shard directory is refused for reads and writes (root confinement)", async () => { + const { registry, rootDir } = await mkRegistry(); + const m = kit({ version: "8.3.0" }); + const digest = await computeKitDigest(m); + const shard = digest.slice(7, 9); + const elsewhere = await mkTempRoot(); + await fs.mkdir(path.join(rootDir, "manifests"), { recursive: true }); + await fs.symlink(elsewhere, path.join(rootDir, "manifests", shard)); + await expect(registry.publish(m, "alice@kits.test")).rejects.toBeInstanceOf(KitIntegrityError); + expect(await fs.readdir(elsewhere)).toEqual([]); + }); + + it("the configured root itself may be a symlink (a volume mount): it is resolved once", async () => { + const real = await mkTempRoot(); + const link = path.join(await mkTempRoot(), "mount"); + await fs.symlink(real, link); + const registry = new KitRegistry({ rootDir: link, durable: () => true, audit: () => undefined }); + const { kitDigest } = await registry.publish(kit({ version: "8.4.0" }), "alice@kits.test"); + expect((await registry.get(kitDigest))?.kitDigest).toBe(kitDigest); + }); + + it("MEDIUM: on a volume without hard links, publish is refused (503) before anything is written", async () => { + const { registry, rootDir } = await mkRegistry(); + const spy = vi.spyOn(fs, "link").mockRejectedValue(Object.assign(new Error("no hard links"), { code: "EOPNOTSUPP" })); + try { + await expect(registry.publish(kit({ version: "8.5.0" }), "alice@kits.test")).rejects.toMatchObject({ code: "registry_unsupported_fs", status: 503 }); + expect(await fs.readdir(rootDir)).toEqual([]); + } finally { + spy.mockRestore(); + } + }); + + it("MEDIUM: the quota is exact across processes: two registries on one root, limit 1, one publisher", async () => { + const rootDir = await mkTempRoot(); + const a = new KitRegistry({ rootDir, durable: () => true, audit: () => undefined, dailyLimit: 1 }); + const b = new KitRegistry({ rootDir, durable: () => true, audit: () => undefined, dailyLimit: 1 }); + const results = await Promise.allSettled([a.publish(kit({ version: "8.6.0" }), "alice@kits.test"), b.publish(kit({ version: "8.6.1" }), "alice@kits.test")]); + const created = results.filter((r) => r.status === "fulfilled" && r.value.created); + const refused = results.filter((r) => r.status === "rejected" && (r.reason as KitRegistryError).code === "publish_quota"); + expect(created).toHaveLength(1); + expect(refused).toHaveLength(1); + }); + + it("a malformed quota claim counts as recent: the quota fails closed", async () => { + const { registry, rootDir } = await mkRegistry({ dailyLimit: 1 }); + await registry.publish(kit({ version: "8.7.0" }), "alice@kits.test"); + const owner = createHash("sha256").update("alice@kits.test", "utf8").digest("hex"); + await fs.rm(path.join(rootDir, "quota", owner, "0.json")); + await fs.writeFile(path.join(rootDir, "quota", owner, "0.json"), "not json"); + await expect(registry.publish(kit({ version: "8.7.1" }), "alice@kits.test")).rejects.toMatchObject({ code: "publish_quota" }); + }); +}); + +// ── astra k1b (SHIP-WITH-FIXES): root confinement ── + +describe("astra k1b: root confinement", () => { + it("(a) a symlinked publications area is refused before it is listed", async () => { + const rootDir = await mkTempRoot(); + const outside = await mkTempRoot(); + await fs.mkdir(path.join(outside, "ab")); + await fs.symlink(outside, path.join(rootDir, "publications")); + const registry = new KitRegistry({ rootDir, durable: () => true, audit: () => undefined }); + await expect(registry.list()).rejects.toBeInstanceOf(KitIntegrityError); + const app = await buildApp(registry, "alice@kits.test"); + const res = await app.inject({ method: "GET", url: "/api/kits" }); + expect(res.statusCode).toBe(500); + expect(res.json().error).toBe("kit_integrity_failure"); + await app.close(); + }); + + it.each([["manifests"], ["quota"]])( + "(b, not reproduced at fe745332; kept as a guard) a symlinked %s area: publish fails and nothing is created outside the root", + async (area) => { + const rootDir = await mkTempRoot(); + const outside = await mkTempRoot(); + await fs.symlink(outside, path.join(rootDir, area)); + const registry = new KitRegistry({ rootDir, durable: () => true, audit: () => undefined }); + await expect(registry.publish(kit({ version: "9.0.0" }), "alice@kits.test")).rejects.toThrow(); + expect(await fs.readdir(outside)).toEqual([]); + }, + ); + + it("(c) a runtime without O_NOFOLLOW refuses to publish (503, nothing written) and fails closed on reads", async () => { + const rootDir = await mkTempRoot(); + const normal = new KitRegistry({ rootDir, durable: () => true, audit: () => undefined }); + const { kitDigest } = await normal.publish(kit({ version: "9.1.0" }), "alice@kits.test"); + const before = await fs.readdir(rootDir); + const noFollowless = new KitRegistry({ rootDir, durable: () => true, audit: () => undefined, noFollowFlag: 0 }); + await expect(noFollowless.publish(kit({ version: "9.1.1" }), "alice@kits.test")).rejects.toMatchObject({ code: "registry_unsupported_fs", status: 503 }); + expect(await fs.readdir(rootDir)).toEqual(before); + await expect(noFollowless.get(kitDigest)).rejects.toBeInstanceOf(KitIntegrityError); + expect((await noFollowless.list()).total).toBe(0); + expect(noFollowless.skipped).toContain(kitDigest); + }); +}); diff --git a/packages/gateway/src/__tests__/kits-scope.test.ts b/packages/gateway/src/__tests__/kits-scope.test.ts new file mode 100644 index 000000000..41503e121 --- /dev/null +++ b/packages/gateway/src/__tests__/kits-scope.test.ts @@ -0,0 +1,216 @@ +/** + * Scope authorization tests for the Capability Kit routes (kits K1 slice 1). + * + * scope-checker.ts's DEFAULT_SCOPE_REQUIREMENTS adds: + * { method: "POST", pattern: "/api/kits", scopes: ["template_author","operator","admin"] } + * { method: "POST", pattern: "/api/kits/**", scopes: ["template_author","operator","admin"] } + * GET /api/kits has no requirement and is not on the money-path default-deny + * list, so it stays open to any authenticated caller regardless of scope. + * + * Mirrors scope-checker-money-path.test.ts's mock/build pattern exactly: the + * db module is mocked so the middleware falls back to + * DEFAULT_SCOPE_REQUIREMENTS (empty governance table) and reads scopes from a + * test-controlled `keyScopes` string via a stubbed apiKeys repo. + */ + +import { describe, it, expect, vi } from "vitest"; +import Fastify, { type FastifyInstance } from "fastify"; + +let keyScopes: string; + +vi.mock("../db.js", () => ({ + getRepos: () => ({ + // Empty governance table -> middleware falls back to DEFAULT_SCOPE_REQUIREMENTS. + governance: { findAllEndpointScopes: () => [] }, + apiKeys: { findById: () => ({ id: "key-1", scopes: keyScopes }) }, + }), +})); + +const { scopeChecker } = await import("../middleware/scope-checker.js"); + +/** Build an app with the scope-checker mounted and a key pre-attached, same as scope-checker-money-path.test.ts. */ +async function buildApp(): Promise { + const app = Fastify(); + // Stand in for api-gate, which sets req.apiKeyId on authenticated requests. + app.addHook("onRequest", async (req) => { + (req as unknown as { apiKeyId?: string }).apiKeyId = "key-1"; + }); + await app.register(scopeChecker); + + const ok = async () => ({ reached: true }); + app.get("/api/kits", ok); + app.post("/api/kits", ok); + app.post("/api/kits/:digest/fork", ok); + await app.ready(); + return app; +} + +const FORK_URL = `/api/kits/sha256:${"a".repeat(64)}/fork`; + +describe("scope-checker — Capability Kit routes", () => { + describe("publish and fork are gated", () => { + it("DENIES POST /api/kits to a key scoped only [\"contributor:read\"]", async () => { + keyScopes = JSON.stringify(["contributor:read"]); + const app = await buildApp(); + const res = await app.inject({ method: "POST", url: "/api/kits" }); + expect(res.statusCode).toBe(403); + expect(res.json().error).toBe("insufficient_scope"); + expect(res.json().reached).toBeUndefined(); + await app.close(); + }); + + it("DENIES POST /api/kits/:digest/fork to a key scoped only [\"contributor:read\"]", async () => { + keyScopes = JSON.stringify(["contributor:read"]); + const app = await buildApp(); + const res = await app.inject({ method: "POST", url: FORK_URL }); + expect(res.statusCode).toBe(403); + expect(res.json().error).toBe("insufficient_scope"); + expect(res.json().reached).toBeUndefined(); + await app.close(); + }); + }); + + describe("template_author reaches the handler", () => { + it("ALLOWS POST /api/kits to a key scoped [\"template_author\"]", async () => { + keyScopes = JSON.stringify(["template_author"]); + const app = await buildApp(); + const res = await app.inject({ method: "POST", url: "/api/kits" }); + expect(res.statusCode).toBe(200); + expect(res.json().reached).toBe(true); + await app.close(); + }); + + it("ALLOWS POST /api/kits/:digest/fork to a key scoped [\"template_author\"]", async () => { + keyScopes = JSON.stringify(["template_author"]); + const app = await buildApp(); + const res = await app.inject({ method: "POST", url: FORK_URL }); + expect(res.statusCode).toBe(200); + expect(res.json().reached).toBe(true); + await app.close(); + }); + }); + + describe("wildcard keys reach the handler", () => { + it("ALLOWS POST /api/kits to a key scoped [\"*\"]", async () => { + keyScopes = JSON.stringify(["*"]); + const app = await buildApp(); + const res = await app.inject({ method: "POST", url: "/api/kits" }); + expect(res.statusCode).toBe(200); + expect(res.json().reached).toBe(true); + await app.close(); + }); + + it("ALLOWS POST /api/kits/:digest/fork to a key scoped [\"*\"]", async () => { + keyScopes = JSON.stringify(["*"]); + const app = await buildApp(); + const res = await app.inject({ method: "POST", url: FORK_URL }); + expect(res.statusCode).toBe(200); + expect(res.json().reached).toBe(true); + await app.close(); + }); + }); + + describe("GET /api/kits stays open", () => { + it("ALLOWS GET /api/kits to an under-scoped key (no requirement matches a GET)", async () => { + keyScopes = JSON.stringify(["contributor:read"]); + const app = await buildApp(); + const res = await app.inject({ method: "GET", url: "/api/kits" }); + expect(res.statusCode).toBe(200); + expect(res.json().reached).toBe(true); + await app.close(); + }); + }); +}); + +// ── astra k1-511 HIGH 1 and HIGH 2: the kit routes check the publishing role themselves ── +// +// routes/kits.ts decides the publishing role itself, so it holds for any deployment +// configuration. These tests mount ONLY kitRoutes (no scope-checker at all), so no endpoint +// scope rule for /api/kits applies. The mocked apiKeys repo above supplies the key's scopes to +// getCallerScopes. + +const { kitRoutes } = await import("../routes/kits.js"); +const { KitRegistry } = await import("../services/kit-registry.js"); +const os = await import("node:os"); +const path = await import("node:path"); +const { promises: fsp } = await import("node:fs"); + +const H = (c: string) => `sha256:${c.repeat(64)}`; +const completeKit = (version = "1.0.0") => ({ + schema: "pcc.capability-kit/v1", + name: "OT-2 dye serial dilution", + version, + parentKitDigest: null, + capabilities: [{ csdUrl: "pcc://capabilities/liquid-handling/v1", capabilityContractDigest: H("a") }], + artifacts: [ + { role: "method", name: "serial-dilution.py", mediaType: "text/x-python", digest: H("1") }, + { role: "tests", name: "checks.json", mediaType: "application/json", digest: H("5") }, + { role: "install-recipe", name: "INSTALL.md", mediaType: "text/markdown", digest: H("6") }, + { role: "provenance-recipe", name: "provenance.json", mediaType: "application/json", digest: H("7") }, + ], + economics: { spdxLicense: "Apache-2.0" }, +}); + +/** kitRoutes alone, with a caller attached the way api-gate attaches one. */ +async function routesOnly(caller: { apiKeyId?: string; operatorId?: string; userId?: string }) { + const rootDir = await fsp.mkdtemp(path.join(process.env.TMPDIR ?? os.tmpdir(), "kits-scope-")); + const registry = new KitRegistry({ rootDir, durable: () => true, audit: () => undefined }); + const app = Fastify(); + app.addHook("onRequest", async (req) => Object.assign(req as unknown as Record, caller)); + await app.register(kitRoutes, { registry }); + await app.ready(); + return { app, registry, cleanup: () => fsp.rm(rootDir, { recursive: true, force: true }) }; +} + +describe("astra k1-511: the kit routes decide the publishing role themselves", () => { + it("HIGH 1: a contributor:read key cannot publish, even with no scope rule for /api/kits anywhere", async () => { + keyScopes = JSON.stringify(["contributor:read"]); + const { app, registry, cleanup } = await routesOnly({ apiKeyId: "key-1", operatorId: "op@kits.test" }); + const res = await app.inject({ method: "POST", url: "/api/kits", payload: completeKit() }); + expect(res.statusCode).toBe(403); + expect(res.json().error).toBe("insufficient_scope"); + expect((await registry.list()).total).toBe(0); + const fork = await app.inject({ method: "POST", url: FORK_URL, payload: completeKit("1.0.1") }); + expect(fork.statusCode).toBe(403); + await app.close(); + await cleanup(); + }); + + it("HIGH 2: a SIWE session (userId, no API key) cannot publish or fork", async () => { + keyScopes = JSON.stringify(["*"]); + const { app, registry, cleanup } = await routesOnly({ userId: "0xabc0000000000000000000000000000000000001" }); + const res = await app.inject({ method: "POST", url: "/api/kits", payload: completeKit() }); + expect(res.statusCode).toBe(403); + expect(res.json().error).toBe("kit_publish_requires_api_key"); + expect((await app.inject({ method: "POST", url: FORK_URL, payload: completeKit("1.0.1") })).statusCode).toBe(403); + expect((await registry.list()).total).toBe(0); + await app.close(); + await cleanup(); + }); + + it.each([["template_author"], ["operator"], ["admin"], ["*"]])("a key scoped [%s] may publish", async (scope) => { + keyScopes = JSON.stringify([scope]); + const { app, registry, cleanup } = await routesOnly({ apiKeyId: "key-1", operatorId: "op@kits.test" }); + const res = await app.inject({ method: "POST", url: "/api/kits", payload: completeKit() }); + expect(res.statusCode).toBe(201); + expect((await registry.list()).total).toBe(1); + await app.close(); + await cleanup(); + }); + + it("a key with a malformed scopes column grants nothing (getCallerScopes fails closed)", async () => { + keyScopes = ""; + const { app, cleanup } = await routesOnly({ apiKeyId: "key-1", operatorId: "op@kits.test" }); + expect((await app.inject({ method: "POST", url: "/api/kits", payload: completeKit() })).statusCode).toBe(403); + await app.close(); + await cleanup(); + }); + + it("reads stay open to any authenticated caller", async () => { + keyScopes = JSON.stringify(["contributor:read"]); + const { app, cleanup } = await routesOnly({ apiKeyId: "key-1", operatorId: "op@kits.test" }); + expect((await app.inject({ method: "GET", url: "/api/kits" })).statusCode).toBe(200); + await app.close(); + await cleanup(); + }); +}); diff --git a/packages/gateway/src/middleware/scope-checker.ts b/packages/gateway/src/middleware/scope-checker.ts index 2d972c34f..e310c4421 100644 --- a/packages/gateway/src/middleware/scope-checker.ts +++ b/packages/gateway/src/middleware/scope-checker.ts @@ -76,6 +76,9 @@ const DEFAULT_SCOPE_REQUIREMENTS: Array<{ // Template author endpoints — publish templates { method: "POST", pattern: "/api/templates/*", scopes: ["template_author", "operator", "admin"] }, { method: "PUT", pattern: "/api/templates/*", scopes: ["template_author", "operator", "admin"] }, + // Capability Kit authors (kits K1) — publish and fork kits + { method: "POST", pattern: "/api/kits", scopes: ["template_author", "operator", "admin"] }, + { method: "POST", pattern: "/api/kits/**", scopes: ["template_author", "operator", "admin"] }, // Auditor endpoints — read-only audit and compliance access { method: "GET", pattern: "/api/audit/*", scopes: ["auditor", "admin"] }, { method: "GET", pattern: "/api/compliance/*", scopes: ["auditor", "operator", "admin"] }, @@ -244,7 +247,7 @@ function firstMatch( * Extract scopes from the API key record. * Falls back to ["*"] for backwards compatibility when scopes are not set. */ -function getCallerScopes(req: FastifyRequest): string[] { +export function getCallerScopes(req: FastifyRequest): string[] { if (!req.apiKeyId) return []; try { diff --git a/packages/gateway/src/routes/kits.ts b/packages/gateway/src/routes/kits.ts new file mode 100644 index 000000000..f5bd22f6d --- /dev/null +++ b/packages/gateway/src/routes/kits.ts @@ -0,0 +1,132 @@ +/** + * Capability Kit registry routes (kits K1 slice 1). The registry, its + * write-once storage and its integrity rules live in services/kit-registry.ts. + * + * GET /api/kits list published kits (filters: csdUrl, deviceFamily, interface, q; limit, offset) + * GET /api/kits/:digest one verified kit: its manifest and publication time + * POST /api/kits publish a complete kit (an API key with a publishing scope; the principal is recorded, never shown) + * POST /api/kits/:digest/fork publish a kit whose parentKitDigest is :digest (same rule) + * + * The publishing role is checked HERE, not left to the scope-checker's + * defaults (astra k1-511): an API key holding template_author, operator, admin + * or *. A SIWE session carries no scopes and is refused. + * + * There is no update or delete: a published version never changes, and an edit + * is a new manifest. Deprecation needs publisher ownership under WP-A's identity + * rules, so it is slice 2. No response carries the publisher. + */ + +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import { getCallerScopes } from "../middleware/scope-checker.js"; +import { getKitRegistry, KitIntegrityError, KitRegistryError, type KitRegistry } from "../services/kit-registry.js"; + +/** The API-key scopes that may publish or fork a kit (as for template authoring). */ +export const KIT_PUBLISH_SCOPES: readonly string[] = ["*", "template_author", "operator", "admin"]; + +const ListQuerySchema = z + .object({ + csdUrl: z.string().min(1).max(200).optional(), + deviceFamily: z.string().min(1).max(120).optional(), + interface: z.string().min(1).max(120).optional(), + q: z.string().min(1).max(200).optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), + offset: z.coerce.number().int().min(0).max(1_000_000).optional(), + }) + .strict(); + +/** The authenticated principal, as the gateway's auth layer records it; null when there is none. */ +function principalOf(req: FastifyRequest): string | null { + const r = req as unknown as { operatorId?: unknown; userId?: unknown }; + const id = r.operatorId ?? r.userId; + return typeof id === "string" && id.trim() !== "" ? id : null; +} + +/** + * Whether this caller may publish: an API key holding one of KIT_PUBLISH_SCOPES, + * decided HERE (astra k1-511), so it holds for any deployment configuration. + * A SIWE session carries no scopes, so it cannot show a publishing role and is + * refused until sessions have one. + */ +function publishRefusal( + req: FastifyRequest, + callerScopes: (req: FastifyRequest) => string[], +): { status: number; error: string; message: string } | null { + const r = req as unknown as { apiKeyId?: unknown }; + if (typeof r.apiKeyId !== "string" || r.apiKeyId === "") { + return { status: 403, error: "kit_publish_requires_api_key", message: "publishing a kit needs an API key with a publishing scope; a wallet session has none" }; + } + const scopes = callerScopes(req); + if (!KIT_PUBLISH_SCOPES.some((s) => scopes.includes(s))) { + return { status: 403, error: "insufficient_scope", message: `publishing a kit needs one of: ${KIT_PUBLISH_SCOPES.join(", ")}` }; + } + return null; +} + +function sendError(reply: FastifyReply, err: unknown): FastifyReply { + if (err instanceof KitRegistryError) { + return reply.status(err.status).send({ error: err.code, message: err.message, ...err.details }); + } + if (err instanceof KitIntegrityError) { + reply.log?.error?.({ kitDigest: err.kitDigest }, "kit registry integrity failure"); + return reply.status(500).send({ error: err.code, message: "the stored kit failed verification and is not served" }); + } + throw err; +} + +export async function kitRoutes( + app: FastifyInstance, + opts: { registry?: KitRegistry; callerScopes?: (req: FastifyRequest) => string[] } = {}, +): Promise { + const registry = (): KitRegistry => opts.registry ?? getKitRegistry(); + // The API key's scopes; injectable so route tests need no database. + const callerScopes = opts.callerScopes ?? getCallerScopes; + + app.get("/api/kits", async (req, reply) => { + const parsed = ListQuerySchema.safeParse(req.query ?? {}); + if (!parsed.success) { + return reply.status(400).send({ error: "invalid_query", issues: parsed.error.issues.map((i) => ({ path: i.path.join("."), message: i.message })) }); + } + try { + return await registry().list(parsed.data); + } catch (err) { + return sendError(reply, err); + } + }); + + app.get<{ Params: { digest: string } }>("/api/kits/:digest", async (req, reply) => { + try { + const kit = await registry().get(req.params.digest); + if (!kit) return reply.status(404).send({ error: "not_found", message: "no published kit has that digest" }); + return { kit }; + } catch (err) { + return sendError(reply, err); + } + }); + + app.post("/api/kits", async (req, reply) => { + const principal = principalOf(req); + if (!principal) return reply.status(401).send({ error: "missing_identity", message: "publishing a kit needs an authenticated principal" }); + const refusal = publishRefusal(req, callerScopes); + if (refusal) return reply.status(refusal.status).send({ error: refusal.error, message: refusal.message }); + try { + const result = await registry().publish(req.body, principal); + return reply.status(result.created ? 201 : 200).send(result); + } catch (err) { + return sendError(reply, err); + } + }); + + app.post<{ Params: { digest: string } }>("/api/kits/:digest/fork", async (req, reply) => { + const principal = principalOf(req); + if (!principal) return reply.status(401).send({ error: "missing_identity", message: "forking a kit needs an authenticated principal" }); + const refusal = publishRefusal(req, callerScopes); + if (refusal) return reply.status(refusal.status).send({ error: refusal.error, message: refusal.message }); + try { + const result = await registry().publish(req.body, principal, { forkOf: req.params.digest }); + return reply.status(result.created ? 201 : 200).send(result); + } catch (err) { + return sendError(reply, err); + } + }); +} diff --git a/packages/gateway/src/server.ts b/packages/gateway/src/server.ts index 46bc1e291..0ce85b3ca 100644 --- a/packages/gateway/src/server.ts +++ b/packages/gateway/src/server.ts @@ -44,6 +44,7 @@ import { operatorStatusRoutes } from "./routes/operator-status.js"; import { capabilityAvailabilityRoutes } from "./routes/capability-availability.js"; import { captureRoutes } from "./routes/capture.js"; import { toolCatalogRoutes } from "./routes/tool-catalog.js"; +import { kitRoutes } from "./routes/kits.js"; import { composeRoutes } from "./routes/compose.js"; import { agentPlanRoutes } from "./routes/agent-plans.js"; import { registrySnapshotRoutes } from "./routes/registry-snapshot.js"; @@ -668,6 +669,8 @@ export async function createGateway(port = 3200) { await app.register(startRoutes); await app.register(marketplaceRoutes); await app.register(toolCatalogRoutes); + // Capability Kit registry (kits K1 slice 1): write-once, verified, durable-volume backed. + await app.register(kitRoutes); await app.register(composeRoutes); // R9: externally authored plans. Validate is a live read; accept is money-path (the scope checker // default-denies it) and answers 503 until the R13 store, #349, the evidence map, the fee policy and diff --git a/packages/gateway/src/services/kit-registry.ts b/packages/gateway/src/services/kit-registry.ts new file mode 100644 index 000000000..c6b23f70d --- /dev/null +++ b/packages/gateway/src/services/kit-registry.ts @@ -0,0 +1,618 @@ +/** + * Capability Kit registry (kits K1 slice 1; ledger R5/R6, row 6: "no + * in-memory-only production catalog"). + * + * A kit version is the EXACT canonical bytes of its normalized manifest + * (@pcc/spec capability-kit.ts), keyed by kitDigest = sha256 of those bytes. + * Who published it and when is registry metadata, stamped by the server and + * never part of the hashed bytes. + * + * Authority lives in write-once files on the durable volume, never in a mutable + * table or an in-memory cache. Every file is created EXCLUSIVELY (written to a + * unique temp file, then hard-linked to its final name; EEXIST means another + * writer got there first) and is never overwritten: + * - `/manifests//.json`: the manifest bytes. A second + * writer of the same digest must carry identical bytes. + * - `/publications//.json`: one publication record per + * digest, so the first publisher of a digest wins, across processes too. + * - `/quota//.json`: the publisher's n-th claim. + * Claim n needs claim n - dailyLimit to be at least 24 hours old, so the + * rolling quota is exact across processes without a lock (astra k1-511). + * Before its first write, a registry probes that the volume supports hard + * links, and refuses to publish (503) if it does not, so a failed link never + * leaves an orphan manifest (astra k1-511). + * + * The kit index is deliberately NOT a capability_template_store row: on master + * any key can rewrite any template row through the generic template routes + * (kits D5), and a kit row there would also surface in their list, fork and + * rate routes. + * + * Integrity: every read (get, list, the existence check and the quota count) + * reads the files afresh; nothing is cached, so another process's publish is + * seen and a file that stops verifying stops being listed (astra k1-511). A + * read requires that the bytes hash to their digest, that they are exactly the + * canonical form of a valid manifest, and that a well-formed publication record + * names the same digest; anything else is a KitIntegrityError, and a listing + * skips it. Files are opened with O_NOFOLLOW and every registry directory is + * checked with lstat, so a symlink planted under the root is refused, never + * followed (astra k1-511). The configured root itself may be a symlink (a + * volume mount): it is resolved once. + * + * Privacy: the publisher (an email or a wallet) stays in the server-side + * record; no kit route returns it. The audit log records it as `actor` for + * authorized audit queries. + */ + +import { constants as fsConstants, promises as fs } from "node:fs"; +import path from "node:path"; +import { createHash, randomUUID } from "node:crypto"; +import { z } from "zod"; +import { + canonicalize, + computeKitDigest, + normalizeKitManifest, + validateKitCompleteness, + type CapabilityKitManifestV1, +} from "@pcc/spec"; +import { auditService, type AuditEntry } from "./audit-service.js"; +import { isValidRegistryDigest } from "./registry-snapshot-store.js"; + +/** Largest canonical manifest the registry stores. */ +export const MAX_KIT_MANIFEST_BYTES = 256 * 1024; +/** Default number of NEW kits one publisher may create in a rolling 24 hours. */ +export const DEFAULT_KIT_PUBLISH_DAILY_LIMIT = 20; +const DAY_MS = 24 * 60 * 60 * 1000; +const PUBLICATION_SCHEMA = "pcc.kit-publication.v1" as const; + +/** The server-side publication record. Never returned by a route. */ +export interface KitPublicationRecord { + schema: typeof PUBLICATION_SCHEMA; + kitDigest: string; + publisher: string; + publishedAt: string; +} + +const PublicationRecordSchema = z + .object({ + schema: z.literal(PUBLICATION_SCHEMA), + kitDigest: z.string().refine(isValidRegistryDigest), + publisher: z.string().min(1).max(512), + publishedAt: z.string().refine((s) => !Number.isNaN(Date.parse(s)) && new Date(s).toISOString() === s), + }) + .strict(); + +/** A verified kit as the routes return it: content and publication time, no publisher. */ +export interface KitView { + kitDigest: string; + manifest: CapabilityKitManifestV1; + publishedAt: string; +} + +/** One row of the listing. */ +export interface KitSummary { + kitDigest: string; + name: string; + version: string; + parentKitDigest: string | null; + csdUrls: string[]; + compatibility?: CapabilityKitManifestV1["compatibility"]; + declaredAssuranceTiers?: number[]; + publishedAt: string; +} + +export interface KitListFilter { + csdUrl?: string; + deviceFamily?: string; + interface?: string; + q?: string; + limit?: number; + offset?: number; +} + +export interface PublishResult { + kitDigest: string; + /** True when this call created the publication; false when the digest was already published. */ + created: boolean; + publishedAt: string; +} + +/** A request the registry refuses, with the HTTP status a route should answer. */ +export class KitRegistryError extends Error { + constructor( + readonly code: string, + readonly status: number, + message: string, + readonly details: Record = {}, + ) { + super(message); + this.name = "KitRegistryError"; + } +} + +/** Stored bytes or records that fail verification. The registry fails closed on these. */ +export class KitIntegrityError extends Error { + readonly code = "kit_integrity_failure" as const; + constructor( + readonly kitDigest: string, + reason: string, + ) { + super(`kit ${kitDigest}: ${reason}`); + this.name = "KitIntegrityError"; + } +} + +export interface KitRegistryOptions { + /** Root directory; default resolveKitRegistryRoot(). */ + rootDir?: string; + /** Clock; default the system clock. */ + now?: () => Date; + /** New kits per publisher per rolling 24 h; default PCC_KIT_PUBLISH_DAILY_LIMIT or 20. */ + dailyLimit?: number; + /** Audit sink; default auditService.log. */ + audit?: (entry: AuditEntry) => void; + /** Whether the root is durable enough to publish to; default isDurableKitRoot(). */ + durable?: () => boolean; + /** The open flag that refuses a symlink; default fs.constants.O_NOFOLLOW. 0 or absent means the runtime has none (tests). */ + noFollowFlag?: number; +} + +/** + * The registry root, durable-volume first: + * 1. PCC_KIT_REGISTRY_DIR (explicit; tests point it at a temp dir) + * 2. $RAILWAY_VOLUME_MOUNT_PATH/kit-registry (the durable Railway volume) + * 3. ./data/kit-registry (local development) + */ +export function resolveKitRegistryRoot(): string { + if (process.env.PCC_KIT_REGISTRY_DIR) return process.env.PCC_KIT_REGISTRY_DIR; + if (process.env.RAILWAY_VOLUME_MOUNT_PATH) return path.join(process.env.RAILWAY_VOLUME_MOUNT_PATH, "kit-registry"); + return path.join("./data", "kit-registry"); +} + +/** In production, publishing needs a durable root: the Railway volume or an explicit directory. */ +export function isDurableKitRoot(): boolean { + if (process.env.NODE_ENV !== "production") return true; + return Boolean(process.env.RAILWAY_VOLUME_MOUNT_PATH || process.env.PCC_KIT_REGISTRY_DIR); +} + +function configuredDailyLimit(): number { + const raw = process.env.PCC_KIT_PUBLISH_DAILY_LIMIT; + const n = raw === undefined ? NaN : Number(raw); + return Number.isSafeInteger(n) && n >= 0 ? n : DEFAULT_KIT_PUBLISH_DAILY_LIMIT; +} + +const utf8 = new TextEncoder(); +const strictUtf8 = new TextDecoder("utf-8", { fatal: true }); + +function sha256Digest(bytes: Uint8Array): string { + return `sha256:${createHash("sha256").update(bytes).digest("hex")}`; +} + +interface IndexEntry { + record: KitPublicationRecord; + manifest: CapabilityKitManifestV1; +} + +const CLAIM_NAME = /^(0|[1-9][0-9]{0,15})\.json$/; +const ClaimSchema = z + .object({ claimedAt: z.string().refine((s) => !Number.isNaN(Date.parse(s))), kitDigest: z.string().refine(isValidRegistryDigest) }) + .strict(); + +function errnoOf(err: unknown): string | undefined { + return (err as NodeJS.ErrnoException | undefined)?.code; +} + +export class KitRegistry { + private readonly rootDir: string; + private readonly now: () => Date; + private readonly dailyLimit: number; + private readonly audit: (entry: AuditEntry) => void; + private readonly durable: () => boolean; + /** O_NOFOLLOW, or 0 when this runtime has none: then the registry refuses to read or publish (astra k1b). */ + private readonly noFollow: number; + private realRoot: Promise | null = null; + private linksChecked = false; + private queue: Promise = Promise.resolve(); + private lastSkipped: string[] = []; + + constructor(options: KitRegistryOptions = {}) { + this.rootDir = options.rootDir ?? resolveKitRegistryRoot(); + this.now = options.now ?? (() => new Date()); + this.dailyLimit = options.dailyLimit ?? configuredDailyLimit(); + this.audit = options.audit ?? ((entry) => auditService.log(entry)); + this.durable = options.durable ?? isDurableKitRoot; + this.noFollow = options.noFollowFlag ?? fsConstants.O_NOFOLLOW ?? 0; + } + + /** Digests the latest listing skipped because they failed verification. */ + get skipped(): readonly string[] { + return this.lastSkipped; + } + + // ── Reads ────────────────────────────────────────────────────────── + + /** The verified kit, or null when the digest is not published. Throws KitIntegrityError on bad bytes. */ + async get(kitDigest: string): Promise { + if (!isValidRegistryDigest(kitDigest)) { + throw new KitRegistryError("invalid_digest", 400, "a kit digest is sha256: followed by 64 lowercase hex digits"); + } + const entry = await this.readVerified(kitDigest); + return entry ? { kitDigest, manifest: entry.manifest, publishedAt: entry.record.publishedAt } : null; + } + + /** Published kits matching every given filter, newest first (then by digest), read from the files now. */ + async list(filter: KitListFilter = {}): Promise<{ kits: KitSummary[]; total: number }> { + const entries = await this.scan(); + const q = filter.q?.toLowerCase(); + const matching = [...entries.entries()] + .filter(([, { manifest: m }]) => { + if (filter.csdUrl !== undefined && !m.capabilities.some((c) => c.csdUrl === filter.csdUrl)) return false; + if (filter.deviceFamily !== undefined && !(m.compatibility?.deviceFamilies ?? []).includes(filter.deviceFamily)) return false; + if (filter.interface !== undefined && !(m.compatibility?.interfaces ?? []).includes(filter.interface)) return false; + if (q !== undefined && !m.name.toLowerCase().includes(q) && !(m.description ?? "").toLowerCase().includes(q)) return false; + return true; + }) + .sort(([da, a], [db, b]) => { + const byTime = Date.parse(b.record.publishedAt) - Date.parse(a.record.publishedAt); + return byTime !== 0 ? byTime : da < db ? -1 : da > db ? 1 : 0; + }); + const offset = filter.offset ?? 0; + const limit = filter.limit ?? 50; + return { kits: matching.slice(offset, offset + limit).map(([d, e]) => summarize(d, e)), total: matching.length }; + } + + /** The recorded publisher of a published digest. Server-side only (ownership checks, audits). */ + async publisherOf(kitDigest: string): Promise { + if (!isValidRegistryDigest(kitDigest)) return null; + return (await this.readVerified(kitDigest))?.record.publisher ?? null; + } + + // ── Publish ──────────────────────────────────────────────────────── + + /** + * Publish a complete kit for `publisher` (the authenticated principal). With + * `forkOf`, the manifest's parentKitDigest must equal it and that kit must + * exist. Re-publishing an existing digest is not an error: it returns the + * existing publication (created: false), never re-stamps it, and costs no + * quota. + */ + publish(input: unknown, publisher: string, options: { forkOf?: string } = {}): Promise { + const run = this.queue.then(() => this.publishNow(input, publisher, options)); + // Serialize this process's publishes; a failed one doesn't block the next. + this.queue = run.catch(() => undefined); + return run; + } + + private async publishNow(input: unknown, publisher: string, options: { forkOf?: string }): Promise { + if (typeof publisher !== "string" || publisher.trim() === "") { + throw new KitRegistryError("missing_identity", 401, "publishing a kit needs an authenticated principal"); + } + if (!this.durable()) { + throw new KitRegistryError("registry_not_durable", 503, "the kit registry has no durable storage in this environment"); + } + let manifest: CapabilityKitManifestV1; + try { + manifest = normalizeKitManifest(input); + } catch (err) { + const issues = + err instanceof z.ZodError ? err.issues.map((i) => ({ path: i.path.join("."), message: i.message })) : [{ path: "", message: "not a manifest" }]; + throw new KitRegistryError("invalid_manifest", 400, "the manifest is not a valid pcc.capability-kit/v1", { issues }); + } + const completeness = validateKitCompleteness(manifest); + if (!completeness.complete) { + throw new KitRegistryError("incomplete_kit", 422, "only a complete, reusable kit can be published", { missing: completeness.missing }); + } + const text = canonicalize(manifest); + const bytes = utf8.encode(text); + if (bytes.length > MAX_KIT_MANIFEST_BYTES) { + throw new KitRegistryError("manifest_too_large", 413, `a kit manifest may be at most ${MAX_KIT_MANIFEST_BYTES} bytes`); + } + const kitDigest = await computeKitDigest(manifest); + if (kitDigest !== sha256Digest(bytes)) throw new KitIntegrityError(kitDigest, "the digest does not cover the stored bytes"); + + if (options.forkOf !== undefined) { + if (!isValidRegistryDigest(options.forkOf)) { + throw new KitRegistryError("invalid_digest", 400, "a kit digest is sha256: followed by 64 lowercase hex digits"); + } + if (!(await this.readVerified(options.forkOf))) throw new KitRegistryError("not_found", 404, "no published kit has that digest"); + if (manifest.parentKitDigest !== options.forkOf) { + throw new KitRegistryError("parent_mismatch", 400, "a fork's parentKitDigest must name the kit it forks"); + } + } + if (manifest.parentKitDigest !== null && !(await this.readVerified(manifest.parentKitDigest))) { + throw new KitRegistryError("unknown_parent", 422, "parentKitDigest names no published kit"); + } + + // Read from the files, not a cache: another process may have published it. + const existing = await this.readVerified(kitDigest); + if (existing) return { kitDigest, created: false, publishedAt: existing.record.publishedAt }; + + await this.ensureLinksSupported(); + const now = this.now(); + await this.claimQuota(publisher, kitDigest, now); + await this.writeManifestOnce(kitDigest, bytes); + const { record, created } = await this.writePublicationOnce({ + schema: PUBLICATION_SCHEMA, + kitDigest, + publisher, + publishedAt: now.toISOString(), + }); + if (created) { + this.audit({ + eventType: options.forkOf !== undefined ? "kit.forked" : "kit.published", + actor: publisher, + resourceType: "capability-kit", + resourceId: kitDigest, + action: options.forkOf !== undefined ? "fork" : "publish", + metadata: { parentKitDigest: manifest.parentKitDigest }, + }); + } + return { kitDigest, created, publishedAt: record.publishedAt }; + } + + // ── Storage: exclusive creates, no symlinks ─────────────────────── + + /** The configured root, created if absent and resolved once (the root itself may be a mount symlink). */ + private root(): Promise { + if (!this.realRoot) { + this.realRoot = (async () => { + await fs.mkdir(this.rootDir, { recursive: true }); + return fs.realpath(this.rootDir); + })(); + } + return this.realRoot; + } + + /** `//`; every component below the root must be a real directory, never a symlink. */ + private async dirFor(area: string, shard: string, create: boolean): Promise { + const root = await this.root(); + let dir = root; + for (const part of [area, shard]) { + dir = path.join(dir, part); + if (create) await fs.mkdir(dir, { recursive: true }).catch((err) => (errnoOf(err) === "EEXIST" ? undefined : Promise.reject(err))); + let st; + try { + st = await fs.lstat(dir); + } catch (err) { + if (errnoOf(err) === "ENOENT" && !create) return null; + throw err; + } + if (st.isSymbolicLink() || !st.isDirectory()) throw new KitIntegrityError(`(${area}/${shard})`, "a registry directory is a symlink or not a directory"); + } + return dir; + } + + /** A file's bytes, opened without following a symlink; null when absent. Fails closed on a runtime without O_NOFOLLOW. */ + private async readNoFollow(dir: string, name: string, kitDigest: string): Promise { + if (!this.noFollow) throw new KitIntegrityError(kitDigest, "this runtime cannot open a file without following a symlink"); + let handle; + try { + handle = await fs.open(path.join(dir, name), fsConstants.O_RDONLY | this.noFollow); + } catch (err) { + const code = errnoOf(err); + if (code === "ENOENT") return null; + if (code === "ELOOP" || code === "EMLINK") throw new KitIntegrityError(kitDigest, "a registry file is a symlink"); + throw err; + } + try { + if (!(await handle.stat()).isFile()) throw new KitIntegrityError(kitDigest, "a registry file is not a regular file"); + return new Uint8Array(await handle.readFile()); + } finally { + await handle.close(); + } + } + + /** Create `/` exclusively with `bytes`; false when it already exists. Never overwrites. */ + private async createOnce(dir: string, name: string, bytes: Uint8Array | string): Promise { + const tmp = path.join(dir, `.${name}.tmp-${randomUUID()}`); + await fs.writeFile(tmp, bytes, { flag: "wx" }); + try { + await fs.link(tmp, path.join(dir, name)); + return true; + } catch (err) { + if (errnoOf(err) === "EEXIST") return false; + throw err; + } finally { + await fs.unlink(tmp).catch(() => undefined); + } + } + + /** + * Probe once, before anything is written, that this runtime and volume can keep the registry + * write-once and symlink-free: O_NOFOLLOW exists and hard links work. 503 if not (astra k1-511, k1b). + */ + private async ensureLinksSupported(): Promise { + if (this.linksChecked) return; + if (!this.noFollow) { + throw new KitRegistryError("registry_unsupported_fs", 503, "this runtime has no O_NOFOLLOW, so the registry cannot refuse symlinks"); + } + const root = await this.root(); + const probe = path.join(root, `.link-probe-${randomUUID()}`); + try { + await fs.writeFile(probe, "", { flag: "wx" }); + await fs.link(probe, `${probe}.link`); + } catch (err) { + throw new KitRegistryError("registry_unsupported_fs", 503, `the registry's volume cannot create hard links (${errnoOf(err) ?? "error"}), so it cannot publish write-once`); + } finally { + await fs.unlink(probe).catch(() => undefined); + await fs.unlink(`${probe}.link`).catch(() => undefined); + } + this.linksChecked = true; + } + + private static nameOf(kitDigest: string): { shard: string; name: string } { + const hex = kitDigest.slice("sha256:".length); + return { shard: hex.slice(0, 2), name: `${hex}.json` }; + } + + /** Store the manifest bytes once; an existing file for the digest must hold exactly these bytes. */ + private async writeManifestOnce(kitDigest: string, bytes: Uint8Array): Promise { + const { shard, name } = KitRegistry.nameOf(kitDigest); + const dir = (await this.dirFor("manifests", shard, true))!; + if (await this.createOnce(dir, name, bytes)) return; + const stored = await this.readNoFollow(dir, name, kitDigest); + if (!stored || stored.length !== bytes.length || stored.some((b, i) => b !== bytes[i])) { + throw new KitIntegrityError(kitDigest, "different bytes are already stored under this digest"); + } + } + + /** The first record for a digest wins; a later one never overwrites it. */ + private async writePublicationOnce(record: KitPublicationRecord): Promise<{ record: KitPublicationRecord; created: boolean }> { + const { shard, name } = KitRegistry.nameOf(record.kitDigest); + const dir = (await this.dirFor("publications", shard, true))!; + const created = await this.createOnce(dir, name, canonicalize(record)); + const winner = await this.readPublication(record.kitDigest); + if (!winner) throw new KitIntegrityError(record.kitDigest, "the publication record vanished after it was written"); + return { record: winner, created }; + } + + /** + * Claim the publisher's next quota slot, exactly across processes: claim n + * (an exclusive create) needs claim n - dailyLimit to be at least 24 hours + * old. A claim is spent even if the publish then fails (conservative). + */ + private async claimQuota(publisher: string, kitDigest: string, now: Date): Promise { + const owner = createHash("sha256").update(publisher, "utf8").digest("hex"); + const root = await this.root(); + const quotaDir = path.join(root, "quota"); + await fs.mkdir(quotaDir, { recursive: true }); + const dir = (await this.dirFor("quota", owner, true))!; + const refused = () => new KitRegistryError("publish_quota", 429, `at most ${this.dailyLimit} new kits per publisher per 24 hours`); + for (let attempt = 0; attempt < 16; attempt++) { + let next = 0; + for (const entry of await fs.readdir(dir)) { + const m = CLAIM_NAME.exec(entry); + if (m) next = Math.max(next, Number(m[1]) + 1); + } + const back = next - this.dailyLimit; + if (back >= 0) { + const old = await this.readNoFollow(dir, `${back}.json`, kitDigest); + let claimedAt = Number.POSITIVE_INFINITY; + try { + if (old) claimedAt = Date.parse(ClaimSchema.parse(JSON.parse(strictUtf8.decode(old))).claimedAt); + } catch { + // a malformed claim counts as recent: fail closed + } + if (!(claimedAt <= now.getTime() - DAY_MS)) throw refused(); + } + if (await this.createOnce(dir, `${next}.json`, canonicalize({ claimedAt: now.toISOString(), kitDigest }))) return; + // Another process took claim `next`; look again. + } + throw new KitRegistryError("publish_busy", 503, "too many concurrent publishes for this publisher; retry"); + } + + /** The publication record, or null when absent. Throws KitIntegrityError on a malformed record or a symlink. */ + private async readPublication(kitDigest: string): Promise { + const { shard, name } = KitRegistry.nameOf(kitDigest); + const dir = await this.dirFor("publications", shard, false); + if (!dir) return null; + const raw = await this.readNoFollow(dir, name, kitDigest); + if (!raw) return null; + let text: string; + let parsed: z.infer; + try { + text = strictUtf8.decode(raw); + parsed = PublicationRecordSchema.parse(JSON.parse(text)); + } catch { + throw new KitIntegrityError(kitDigest, "the publication record is malformed"); + } + if (parsed.kitDigest !== kitDigest || canonicalize(parsed) !== text) { + throw new KitIntegrityError(kitDigest, "the publication record does not match its digest"); + } + return parsed; + } + + /** Read and verify one published kit; null when it is not published. */ + private async readVerified(kitDigest: string): Promise { + const record = await this.readPublication(kitDigest); + if (!record) return null; + const { shard, name } = KitRegistry.nameOf(kitDigest); + const dir = await this.dirFor("manifests", shard, false); + const bytes = dir ? await this.readNoFollow(dir, name, kitDigest) : null; + if (!bytes) throw new KitIntegrityError(kitDigest, "the manifest bytes are missing"); + if (sha256Digest(bytes) !== kitDigest) throw new KitIntegrityError(kitDigest, "the manifest bytes do not hash to their digest"); + let manifest: CapabilityKitManifestV1; + let text: string; + try { + text = strictUtf8.decode(bytes); + manifest = normalizeKitManifest(JSON.parse(text)); + } catch { + throw new KitIntegrityError(kitDigest, "the stored manifest is not a valid kit"); + } + // With the hash check above, this also proves computeKitDigest(manifest) === kitDigest. + if (canonicalize(manifest) !== text) throw new KitIntegrityError(kitDigest, "the stored bytes are not the canonical manifest"); + return { record, manifest }; + } + + /** Every published kit that verifies, read from the files now; failures are skipped and remembered. */ + private async scan(): Promise> { + const entries = new Map(); + const skipped: string[] = []; + const root = await this.root(); + const area = path.join(root, "publications"); + // The area itself must be a real directory before it is listed: a symlink here is refused, never read (astra k1b). + let st; + try { + st = await fs.lstat(area); + } catch (err) { + if (errnoOf(err) === "ENOENT") { + this.lastSkipped = skipped; + return entries; + } + throw err; + } + if (st.isSymbolicLink() || !st.isDirectory()) throw new KitIntegrityError("(publications)", "a registry directory is a symlink or not a directory"); + const shards = await fs.readdir(area); + for (const shard of shards.sort()) { + if (!/^[0-9a-f]{2}$/.test(shard)) continue; + let names: string[]; + try { + const dir = await this.dirFor("publications", shard, false); + names = dir ? (await fs.readdir(dir)).sort() : []; + } catch { + skipped.push(`(publications/${shard})`); + continue; + } + for (const name of names) { + const match = /^([0-9a-f]{64})\.json$/.exec(name); + if (!match || !match[1]!.startsWith(shard)) continue; + const kitDigest = `sha256:${match[1]}`; + try { + const entry = await this.readVerified(kitDigest); + if (entry) entries.set(kitDigest, entry); + } catch { + skipped.push(kitDigest); + } + } + } + this.lastSkipped = skipped; + return entries; + } +} + +function summarize(kitDigest: string, { record, manifest: m }: IndexEntry): KitSummary { + return { + kitDigest, + name: m.name, + version: m.version, + parentKitDigest: m.parentKitDigest, + csdUrls: m.capabilities.map((c) => c.csdUrl), + ...(m.compatibility !== undefined ? { compatibility: m.compatibility } : {}), + ...(m.declaredAssuranceTiers !== undefined ? { declaredAssuranceTiers: m.declaredAssuranceTiers } : {}), + publishedAt: record.publishedAt, + }; +} + +// ── Process singleton ────────────────────────────────────────────── + +let registry: KitRegistry | null = null; + +/** The process-wide registry (root resolved from the environment on first use). */ +export function getKitRegistry(): KitRegistry { + if (!registry) registry = new KitRegistry(); + return registry; +} + +/** Test helper: reset the singleton, optionally to a replacement rooted in a temp dir. */ +export function _resetKitRegistryForTests(replacement?: KitRegistry): void { + registry = replacement ?? null; +}