diff --git a/.changeset/verified-artifact-inventory.md b/.changeset/verified-artifact-inventory.md new file mode 100644 index 0000000000..425cd01020 --- /dev/null +++ b/.changeset/verified-artifact-inventory.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/registry-verification": minor +--- + +Adds a validated file inventory to `validatePluginBundle`: the result now carries `files`, every regular archive file (path and bytes) in tar order, so consumers can extract analysis inputs without re-parsing the bundle. diff --git a/apps/labeler/package.json b/apps/labeler/package.json index cfd400b460..e63b4ed135 100644 --- a/apps/labeler/package.json +++ b/apps/labeler/package.json @@ -32,6 +32,7 @@ "@atcute/xrpc-server": "catalog:", "@emdash-cms/registry-lexicons": "workspace:*", "@emdash-cms/registry-moderation": "workspace:*", + "@emdash-cms/registry-verification": "workspace:*", "jose": "^6.1.3", "ulidx": "^2.4.1" }, @@ -53,6 +54,7 @@ "@types/react-dom": "catalog:", "@vitejs/plugin-react": "^5.2.0", "jsdom": "^26.1.0", + "modern-tar": "^0.7.6", "react": "catalog:", "react-dom": "catalog:", "tailwindcss": "^4.3.2", diff --git a/apps/labeler/src/artifact-acquisition.ts b/apps/labeler/src/artifact-acquisition.ts new file mode 100644 index 0000000000..0d830270fe --- /dev/null +++ b/apps/labeler/src/artifact-acquisition.ts @@ -0,0 +1,517 @@ +/** + * Verified artifact acquisition (plan W7.2, spec §9.3/§9.4). Resolves a + * verified release's package artifact from the preferred source, fetches it + * under the shared SSRF-hardened controls, verifies its bytes against the + * signed checksum, and unpacks the canonical bundle into the analysis file + * set. Every failure is classified into the four ratified acquisition + * categories — mirror miss, transient fetch failure, permanent checksum + * mismatch, and policy rejection — each carrying the disposition the + * orchestrator applies (retry as a transient stage error, or a permanent + * deterministic finding). + * + * Composes `@emdash-cms/registry-verification` (W7.1): its guarded fetch, + * multihash verification, and canonical bundle validation are the shared + * foundation. This module adds the labeler's mirror-first source preference, + * declared-URL fallback, and the acquisition-category classification on top — + * it does not reimplement any of them. + * + * Mirror-ready seam: the source preference defaults to mirror-first, but v1 + * ships no mirror binding (`deps.mirror` absent), so the mirror source always + * misses and every acquisition resolves via the publisher's declared URL + * (plan W0.6). Injecting a mirror binding activates mirror-first ordering with + * no change to this contract. + */ + +import { + decodeMultihash, + fetchVerifiedResource, + MAX_BUNDLE_COMPRESSED_BYTES, + validatePluginBundle, + verifyMultihash, + type FetchImplementation, + type HostnameResolver, + type ValidatedPluginBundle, + type VerificationErrorCode, + type VerificationResult, +} from "@emdash-cms/registry-verification"; + +import type { StageAdapter, StageContext } from "./assessment-orchestrator.js"; +import { StageTransientError } from "./assessment-orchestrator.js"; +import type { Assessment } from "./assessment-store.js"; +import type { CodeAnalysisFile } from "./code-ai-adapter.js"; +import type { NormalizedFinding } from "./findings.js"; + +/** Where a release artifact is fetched from. `mirror` prefers the aggregator's + * durable copy; `declared-url` fetches the publisher's signed artifact URL. */ +export type ArtifactSource = "mirror" | "declared-url"; + +/** + * Mirror-ready preference order. v1 has no mirror binding, so the mirror + * source misses and acquisition falls through to the declared URL. Landing a + * mirror binding activates mirror-first without touching this default. + */ +export const DEFAULT_ARTIFACT_SOURCES: readonly ArtifactSource[] = ["mirror", "declared-url"]; + +/** + * Fetch budgets for a declared-URL artifact download. The byte budget equals + * the compressed-bundle cap, so an artifact larger than any valid bundle is + * rejected during streaming (the earliest point), before any checksum. Those + * unverified bytes are classified as a transient failure (retry → + * assessment-error), never a public block label (spec §9.4). Timeouts bound a + * slow or stalled origin. + */ +export const ACQUISITION_FETCH_LIMITS = { + headerTimeoutMs: 10_000, + totalTimeoutMs: 30_000, + maxBytes: MAX_BUNDLE_COMPRESSED_BYTES, + maxRedirects: 3, +} as const; + +/** Tool identifier recorded on the deterministic findings this stage emits. */ +export const ACQUISITION_TOOL = "artifact-acquisition"; +export const ACQUISITION_TOOL_VERSION = "1"; + +/** The four ratified acquisition categories (plan W7.2). */ +export type AcquisitionFailureKind = + | "mirror-miss" + | "transient" + | "permanent-mismatch" + | "policy-rejection"; + +/** Deterministic finding a permanent acquisition failure maps onto (spec §9.4). */ +export type AcquisitionFinding = "artifact-integrity-failure" | "invalid-bundle"; + +/** + * How the orchestrator adapter finalizes a failure: + * - `retry`: a transient stage error — network, timeout, an unfetchable + * declared URL, or an exhausted source. Retries, then finalizes as + * `assessment-error`. Never a public block label: a transport failure is + * not evidence the plugin is malicious (spec §9.4). + * - a `finding`: a permanent, blocking deterministic finding. + */ +export type AcquisitionDisposition = + | { readonly retry: true } + | { readonly retry: false; readonly finding: AcquisitionFinding }; + +export interface AcquisitionFailure { + readonly kind: AcquisitionFailureKind; + /** Underlying verification code, or an acquisition-specific code. */ + readonly code: VerificationErrorCode | AcquisitionCode; + readonly message: string; + /** The source whose attempt produced this terminal failure. */ + readonly source: ArtifactSource; + readonly disposition: AcquisitionDisposition; +} + +/** Acquisition-specific codes with no registry-verification equivalent. */ +export type AcquisitionCode = "MIRROR_MISS" | "COORDINATE_MISMATCH" | "NON_UTF8_CODE_FILE"; + +/** The verified release's package-artifact declaration plus the pinned + * coordinates acquisition cross-checks it against. */ +export interface AcquisitionTarget { + /** Package-artifact download URL from the signed release. */ + readonly url: string; + /** Multibase-multihash checksum from the signed release. */ + readonly checksum: string; + /** Package slug (release.package) — the expected manifest id. */ + readonly slug: string; + /** Release version — the expected manifest version. */ + readonly version: string; + /** Artifact id from the signed release (artifacts.package.id), when present. */ + readonly artifactId?: string; + /** Artifact coordinates pinned on the assessment; cross-checked against the + * declaration when present. */ + readonly pinnedChecksum?: string | null; + readonly pinnedArtifactId?: string | null; +} + +export interface AcquiredArtifact { + readonly source: ArtifactSource; + readonly bundle: ValidatedPluginBundle; + /** UTF-8-decodable bundle files as the code stages' input. Binary files + * (images) are excluded here and remain available on `bundle.files`. */ + readonly files: readonly CodeAnalysisFile[]; + /** The checksum-verified compressed bundle bytes. */ + readonly bytes: Uint8Array; +} + +export type AcquisitionResult = + | { readonly success: true; readonly value: AcquiredArtifact } + | { readonly success: false; readonly error: AcquisitionFailure }; + +/** + * The aggregator artifact mirror. v1 ships no implementation; a future binding + * resolves a deterministic R2 object (see `mirrorObjectKey`) through the + * aggregator service binding (plan W0.6). `fetch` returns the raw compressed + * bundle bytes, or `null` when the mirror holds no object for the release. + */ +export interface ArtifactMirror { + fetch(key: string, target: AcquisitionTarget): Promise; +} + +export interface AcquisitionDeps { + readonly fetch: FetchImplementation; + readonly resolveHostname: HostnameResolver; + /** Future aggregator mirror binding. Absent in v1 — the mirror source misses. */ + readonly mirror?: ArtifactMirror; + /** Source preference order. Defaults to `DEFAULT_ARTIFACT_SOURCES`. */ + readonly sources?: readonly ArtifactSource[]; + /** Fetch budget overrides; defaults to `ACQUISITION_FETCH_LIMITS`. */ + readonly limits?: Partial; +} + +/** + * Deterministic mirror object key derived from release coordinates. The mirror + * stores the exact signed artifact, so the key is stable across acquisitions + * and independent of the declared URL. + */ +export function mirrorObjectKey(target: AcquisitionTarget): string { + return `${target.slug}/${target.version}/${target.artifactId ?? "package"}`; +} + +export async function acquireArtifact( + deps: AcquisitionDeps, + target: AcquisitionTarget, +): Promise { + const coordinate = crossCheckCoordinates(target); + if (coordinate) return { success: false, error: coordinate }; + + const sources = deps.sources ?? DEFAULT_ARTIFACT_SOURCES; + let lastFailure: AcquisitionFailure | undefined; + + for (const source of sources) { + if (source === "mirror") { + const bytes = await loadFromMirror(deps, target); + if (bytes === null) { + lastFailure = mirrorMiss(); + continue; + } + const result = await verifyAndUnpack(bytes, target, "mirror"); + // The mirror is an untrusted transport cache (spec §9.3): bytes that + // fail the signed checksum mean a stale or corrupt copy, so fall + // through to the authoritative declared URL rather than failing. Any + // other outcome is inherent to the checksum-pinned artifact and is + // terminal — the declared URL would yield byte-identical content. + if (!result.success && result.error.code === "CHECKSUM_MISMATCH") { + lastFailure = mirrorMiss(); + continue; + } + return result; + } + + const fetched = await fetchDeclared(deps, target); + if (!fetched.success) { + lastFailure = classifyFailure(fetched.error.code, fetched.error.message, "declared-url"); + continue; + } + return verifyAndUnpack(fetched.value, target, "declared-url"); + } + + return { success: false, error: lastFailure ?? mirrorMiss() }; +} + +/** Cross-checks the pinned coordinates and the declared checksum before any + * network work. A drift between what discovery pinned and what the release + * declares is a permanent integrity fault (`COORDINATE_MISMATCH`). A malformed + * or unsupported declared checksum is not tampering — it is a bad record or a + * labeler capability gap — so it classifies as transient (retry → + * assessment-error), never a public block label. */ +function crossCheckCoordinates(target: AcquisitionTarget): AcquisitionFailure | null { + if ( + typeof target.pinnedChecksum === "string" && + target.pinnedChecksum.length > 0 && + target.pinnedChecksum !== target.checksum + ) { + return classifyFailure( + "COORDINATE_MISMATCH", + "The pinned artifact checksum does not match the signed release declaration.", + "declared-url", + ); + } + if ( + typeof target.pinnedArtifactId === "string" && + target.pinnedArtifactId.length > 0 && + target.pinnedArtifactId !== (target.artifactId ?? null) + ) { + return classifyFailure( + "COORDINATE_MISMATCH", + "The pinned artifact id does not match the signed release declaration.", + "declared-url", + ); + } + const decoded = decodeMultihash(target.checksum); + if (!decoded.success) { + return classifyFailure(decoded.error.code, decoded.error.message, "declared-url"); + } + return null; +} + +async function loadFromMirror( + deps: AcquisitionDeps, + target: AcquisitionTarget, +): Promise { + if (!deps.mirror) return null; + try { + return await deps.mirror.fetch(mirrorObjectKey(target), target); + } catch { + // A mirror binding fault is not authoritative — treat it as a miss and + // fall through to the declared URL. + return null; + } +} + +async function fetchDeclared( + deps: AcquisitionDeps, + target: AcquisitionTarget, +): Promise> { + const limits = { ...ACQUISITION_FETCH_LIMITS, ...deps.limits }; + const result = await fetchVerifiedResource(target.url, { + fetch: deps.fetch, + resolveHostname: deps.resolveHostname, + headerTimeoutMs: limits.headerTimeoutMs, + totalTimeoutMs: limits.totalTimeoutMs, + maxBytes: limits.maxBytes, + maxRedirects: limits.maxRedirects, + }); + if (!result.success) return result; + return { success: true, value: result.value.bytes }; +} + +async function verifyAndUnpack( + bytes: Uint8Array, + target: AcquisitionTarget, + source: ArtifactSource, +): Promise { + const checksum = await verifyMultihash(bytes, target.checksum); + if (!checksum.success) { + return { + success: false, + error: classifyFailure(checksum.error.code, checksum.error.message, source), + }; + } + // The bytes are the exact signed artifact; validation ignores the declared + // content-type and proves the archive shape itself (spec §9.3). + const bundle = await validatePluginBundle(bytes, { + expectedSlug: target.slug, + expectedVersion: target.version, + }); + if (!bundle.success) { + return { + success: false, + error: classifyFailure(bundle.error.code, bundle.error.message, source), + }; + } + const fileSet = buildCodeFileSet(bundle.value); + if (!fileSet.ok) { + return { success: false, error: classifyFailure(fileSet.code, fileSet.message, source) }; + } + return { + success: true, + value: { + source, + bundle: bundle.value, + files: fileSet.files, + bytes, + }, + }; +} + +const utf8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }); + +const CODE_FILE_EXTENSIONS = [ + ".js", + ".mjs", + ".cjs", + ".jsx", + ".ts", + ".mts", + ".cts", + ".tsx", + ".json", +] as const; + +function isCodeFile(path: string): boolean { + const lower = path.toLowerCase(); + return CODE_FILE_EXTENSIONS.some((extension) => lower.endsWith(extension)); +} + +type FileSetResult = + | { readonly ok: true; readonly files: readonly CodeAnalysisFile[] } + | { readonly ok: false; readonly code: AcquisitionCode; readonly message: string }; + +/** + * Decodes the validated inventory into the code stages' file set, in tar order. + * A code file (JS/TS/JSON, including the `backend.js`/`admin.js` entrypoints) + * that is not valid UTF-8 is rejected as `invalid-bundle`: valid source is by + * definition valid Unicode, and a raw invalid byte in an executable is an + * evasion — a lenient runtime still runs it while the code AI, handed only the + * decodable files, would never see it. The analyzed set must be a superset of + * the executable set, so such a file may not be silently dropped. Genuine + * binary assets (images) are the only files that legitimately fail to decode; + * they are skipped here and remain on `bundle.files` for the image path. + */ +function buildCodeFileSet(bundle: ValidatedPluginBundle): FileSetResult { + const files: CodeAnalysisFile[] = []; + for (const file of bundle.files) { + let content: string; + try { + content = utf8.decode(file.bytes); + } catch { + if (isCodeFile(file.path)) { + return { + ok: false, + code: "NON_UTF8_CODE_FILE", + message: `The plugin bundle code file ${file.path} is not valid UTF-8.`, + }; + } + continue; + } + files.push({ path: file.path, content }); + } + return { ok: true, files }; +} + +function mirrorMiss(): AcquisitionFailure { + return classifyFailure( + "MIRROR_MISS", + "The artifact mirror has no object for this release.", + "mirror", + ); +} + +/** + * Retry → `assessment-error`, never a public block. Covers three faults that + * are all "not evidence the artifact is bad": genuine network/origin failures; + * a transport-size cap tripped mid-fetch, before any checksum, so the aborted + * bytes were never verified (mapping them to a public block would let a MITM or + * a misbehaving CDN mislabel a legitimate plugin — spec §9.4); and a + * malformed/unsupported *declared* checksum, which is a bad record or a labeler + * capability gap (e.g. a forward-compat hash), not tampering. A genuine + * decompression bomb is still a permanent block via + * `BUNDLE_DECOMPRESSED_SIZE_EXCEEDED`, which fires on checksum-verified bytes. + */ +const TRANSIENT_CODES: ReadonlySet = new Set([ + "FETCH_FAILED", + "RESOURCE_TIMEOUT", + "RESOURCE_STATUS_ERROR", + "RESOURCE_SIZE_EXCEEDED", + "INVALID_MULTIHASH", + "UNSUPPORTED_MULTIHASH", +]); + +/** URL-safety and redirect refusals: we will not fetch the declared URL. No + * bytes, so no malicious signal — retried, then `assessment-error`. */ +const URL_POLICY_CODES: ReadonlySet = new Set([ + "INVALID_URL", + "HOST_REJECTED", + "REDIRECT_LIMIT_EXCEEDED", + "REDIRECT_LOCATION_MISSING", +]); + +/** Genuine integrity faults on verified/authoritative inputs: the signed bytes + * do not match the pin (`CHECKSUM_MISMATCH`), or the pinned coordinates drift + * from the signed declaration (`COORDINATE_MISMATCH`). A malformed declared + * checksum is NOT here — see `TRANSIENT_CODES` — because it is proven decodable + * pre-fetch, so `verifyMultihash` can only ever return `CHECKSUM_MISMATCH` on + * fetched bytes. */ +const INTEGRITY_CODES: ReadonlySet = new Set([ + "CHECKSUM_MISMATCH", + "COORDINATE_MISMATCH", +]); + +function classifyFailure( + code: VerificationErrorCode | AcquisitionCode, + message: string, + source: ArtifactSource, +): AcquisitionFailure { + if (code === "MIRROR_MISS") { + return { kind: "mirror-miss", code, message, source, disposition: { retry: true } }; + } + if (TRANSIENT_CODES.has(code)) { + return { kind: "transient", code, message, source, disposition: { retry: true } }; + } + if (URL_POLICY_CODES.has(code)) { + return { kind: "policy-rejection", code, message, source, disposition: { retry: true } }; + } + if (INTEGRITY_CODES.has(code)) { + return { + kind: "permanent-mismatch", + code, + message, + source, + disposition: { retry: false, finding: "artifact-integrity-failure" }, + }; + } + // Every remaining code is a checksum-verified bundle rejected on its own + // content: a structurally-invalid archive, or a non-UTF-8 code file. The + // bytes match the signed pin, but the bundle itself is rejected by policy + // (spec §9.4 → invalid-bundle). + return { + kind: "policy-rejection", + code, + message, + source, + disposition: { retry: false, finding: "invalid-bundle" }, + }; +} + +/** Populated by the acquire stage so downstream stages read the acquired + * bundle and file set. The orchestrator's `StageContext` carries only the + * assessment, so acquisition publishes its output through this shared handle — + * the seam the production Workflow wiring will read. */ +export interface AcquisitionHolder { + result?: AcquiredArtifact; +} + +export interface AcquireStageOptions { + readonly deps: AcquisitionDeps; + /** Resolves the acquisition target from the assessment. Future wiring loads + * the verified release record and its pinned coordinates here. */ + readonly resolveTarget: (assessment: Assessment) => Promise; + /** Populated on success for downstream stages to consume. */ + readonly holder: AcquisitionHolder; +} + +/** + * Builds the orchestrator's `acquire` stage adapter. On success it publishes + * the acquired artifact to the holder and reports no findings; a permanent + * failure becomes the mapped deterministic finding; a transient failure throws + * `StageTransientError` for the orchestrator to retry. + */ +export function createAcquireStage(options: AcquireStageOptions): StageAdapter { + return async (ctx: StageContext) => { + const target = await options.resolveTarget(ctx.assessment); + const result = await acquireArtifact(options.deps, target); + if (result.success) { + options.holder.result = result.value; + return []; + } + const { error } = result; + if (error.disposition.retry) { + throw new StageTransientError( + `artifact acquisition failed (${error.kind}/${error.code}): ${error.message}`, + ); + } + return [acquisitionFinding(error, error.disposition.finding)]; + }; +} + +function acquisitionFinding( + error: AcquisitionFailure, + category: AcquisitionFinding, +): NormalizedFinding { + const isIntegrity = category === "artifact-integrity-failure"; + return { + source: "deterministic", + category, + severity: "critical", + confidence: 1, + title: isIntegrity ? "Artifact integrity check failed" : "Plugin bundle rejected", + publicSummary: isIntegrity + ? "The plugin artifact does not match the checksum in its signed release record." + : "The plugin bundle is malformed, unsafe, or missing required installable content.", + privateDetail: `Acquisition failed (${error.kind}/${error.code}) from ${error.source}: ${error.message}`, + evidenceRefs: [], + sourceMetadata: { kind: "tool", tool: ACQUISITION_TOOL, version: ACQUISITION_TOOL_VERSION }, + }; +} diff --git a/apps/labeler/test/artifact-acquisition.test.ts b/apps/labeler/test/artifact-acquisition.test.ts new file mode 100644 index 0000000000..2025ea2db3 --- /dev/null +++ b/apps/labeler/test/artifact-acquisition.test.ts @@ -0,0 +1,373 @@ +import type { FetchImplementation, HostnameResolver } from "@emdash-cms/registry-verification"; +import { describe, expect, it, vi } from "vitest"; + +import { + acquireArtifact, + ACQUISITION_FETCH_LIMITS, + mirrorObjectKey, + type AcquisitionDeps, + type AcquisitionTarget, + type ArtifactMirror, +} from "../src/artifact-acquisition.js"; +import { + bundleBytes, + canonicalBundle, + checksumOf, + file, + FIXTURE_MANIFEST, + gzipBytes, +} from "./bundle-fixture.js"; + +const PUBLIC_ADDRESS = "203.0.113.5"; +const PRIVATE_ADDRESS = "10.0.0.5"; + +const resolvePublic: HostnameResolver = async () => [PUBLIC_ADDRESS]; + +function respondWith(bytes: Uint8Array): FetchImplementation { + return async () => new Response(bytes); +} + +async function target(overrides: Partial = {}): Promise { + const bytes = await canonicalBundle(); + return { + url: "https://cdn.example.test/plugin.tgz", + checksum: await checksumOf(bytes), + slug: "test-plugin", + version: "1.0.0", + ...overrides, + }; +} + +function deps(overrides: Partial): AcquisitionDeps { + return { + fetch: overrides.fetch ?? vi.fn(), + resolveHostname: overrides.resolveHostname ?? resolvePublic, + ...(overrides.mirror ? { mirror: overrides.mirror } : {}), + ...(overrides.sources ? { sources: overrides.sources } : {}), + ...(overrides.limits ? { limits: overrides.limits } : {}), + }; +} + +describe("acquireArtifact: declared-URL success", () => { + it("fetches, checksum-verifies, and unpacks the bundle into the code file set", async () => { + const bytes = await canonicalBundle(); + const result = await acquireArtifact( + deps({ fetch: respondWith(bytes) }), + await target({ checksum: await checksumOf(bytes) }), + ); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.value.source).toBe("declared-url"); + expect(result.value.bundle.manifest.id).toBe("test-plugin"); + expect(result.value.files.map((f) => f.path)).toEqual(["manifest.json", "backend.js"]); + expect(result.value.files.find((f) => f.path === "backend.js")?.content).toBe( + "export default {};", + ); + }); + + it("excludes binary files from the code set but keeps them on the inventory", async () => { + const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x00, 0xff, 0xfe, 0x01]); + const bytes = await canonicalBundle([file("icon.png", png)]); + const result = await acquireArtifact( + deps({ fetch: respondWith(bytes) }), + await target({ checksum: await checksumOf(bytes) }), + ); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.value.files.map((f) => f.path)).toEqual(["manifest.json", "backend.js"]); + expect(result.value.bundle.files.map((f) => f.path)).toContain("icon.png"); + }); + + it("defaults to mirror-first sources but resolves the declared URL when no mirror is bound", async () => { + const bytes = await canonicalBundle(); + const fetch = vi.fn(respondWith(bytes)); + const result = await acquireArtifact( + deps({ fetch }), + await target({ checksum: await checksumOf(bytes) }), + ); + + expect(result.success).toBe(true); + if (result.success) expect(result.value.source).toBe("declared-url"); + expect(fetch).toHaveBeenCalledOnce(); + }); +}); + +describe("acquireArtifact: integrity failures (permanent-mismatch)", () => { + it("classifies a declared-URL checksum mismatch as artifact-integrity-failure", async () => { + const served = await canonicalBundle([file("extra.js", "console.log(1);")]); + const result = await acquireArtifact(deps({ fetch: respondWith(served) }), await target()); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toMatchObject({ + kind: "permanent-mismatch", + code: "CHECKSUM_MISMATCH", + source: "declared-url", + disposition: { retry: false, finding: "artifact-integrity-failure" }, + }); + }); + + it("rejects a pinned-coordinate drift before fetching", async () => { + const fetch = vi.fn(); + const result = await acquireArtifact( + deps({ fetch }), + await target({ pinnedChecksum: "bdifferentchecksum" }), + ); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toMatchObject({ + kind: "permanent-mismatch", + code: "COORDINATE_MISMATCH", + disposition: { retry: false, finding: "artifact-integrity-failure" }, + }); + expect(fetch).not.toHaveBeenCalled(); + }); +}); + +describe("acquireArtifact: bundle rejections (policy-rejection → invalid-bundle)", () => { + it("classifies a checksum-valid but malformed archive as invalid-bundle", async () => { + // A valid gzip envelope whose payload is not a tar archive: it decodes, + // then fails the (synchronous) tar parse — exercising the invalid-archive + // classification without a streaming gzip-decode error. + const notTar = await gzipBytes(new TextEncoder().encode("this is not a tar archive")); + const result = await acquireArtifact( + deps({ fetch: respondWith(notTar) }), + await target({ checksum: await checksumOf(notTar) }), + ); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toMatchObject({ + kind: "policy-rejection", + code: "BUNDLE_INVALID_ARCHIVE", + disposition: { retry: false, finding: "invalid-bundle" }, + }); + }); + + it("classifies a bundle missing its backend entrypoint as invalid-bundle", async () => { + const noBackend = await bundleBytes([file("manifest.json", JSON.stringify({ id: "x" }))]); + const result = await acquireArtifact( + deps({ fetch: respondWith(noBackend) }), + await target({ checksum: await checksumOf(noBackend) }), + ); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toMatchObject({ + kind: "policy-rejection", + code: "BUNDLE_MISSING_BACKEND", + disposition: { retry: false, finding: "invalid-bundle" }, + }); + }); + + it("rejects a non-UTF-8 executable rather than silently dropping it from analysis", async () => { + // A valid-JS backend with one invalid UTF-8 byte: structurally accepted by + // the validator (which stores backend.js raw), but it must not vanish from + // the analyzed file set — a lenient runtime still executes it. + const encoder = new TextEncoder(); + const tainted = new Uint8Array([ + ...encoder.encode("export default {"), + 0xff, + ...encoder.encode("};"), + ]); + const bytes = await bundleBytes([ + file("manifest.json", JSON.stringify(FIXTURE_MANIFEST)), + file("backend.js", tainted), + ]); + const result = await acquireArtifact( + deps({ fetch: respondWith(bytes) }), + await target({ checksum: await checksumOf(bytes) }), + ); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toMatchObject({ + kind: "policy-rejection", + code: "NON_UTF8_CODE_FILE", + disposition: { retry: false, finding: "invalid-bundle" }, + }); + }); +}); + +describe("acquireArtifact: transient failures", () => { + it("classifies a network fault as transient (retryable)", async () => { + // Real fetch rejects asynchronously after I/O; mirror that so the + // rejection is never momentarily unhandled. + const fetch: FetchImplementation = async () => { + await Promise.resolve(); + throw new TypeError("connection reset"); + }; + const result = await acquireArtifact(deps({ fetch }), await target()); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toMatchObject({ + kind: "transient", + code: "FETCH_FAILED", + disposition: { retry: true }, + }); + }); + + it("classifies an origin error status as transient", async () => { + const fetch: FetchImplementation = async () => new Response("nope", { status: 503 }); + const result = await acquireArtifact(deps({ fetch }), await target()); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toMatchObject({ kind: "transient", code: "RESOURCE_STATUS_ERROR" }); + expect(result.error.disposition).toEqual({ retry: true }); + }); + + it("treats a transport-oversized response as transient, never a public block", async () => { + // The byte cap aborts the fetch before any checksum runs, so these bytes + // are unverified — a MITM or misbehaving CDN must not turn a legitimate + // plugin into a public invalid-bundle block (spec §9.4). The served bytes + // deliberately do not match the target checksum, proving the abort is + // pre-verification (a wrong checksum still yields RESOURCE_SIZE_EXCEEDED, + // not CHECKSUM_MISMATCH). + const oversized = await canonicalBundle([file("padding.js", "x".repeat(64))]); + const result = await acquireArtifact( + deps({ fetch: respondWith(oversized), limits: { maxBytes: 8 } }), + await target(), + ); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toMatchObject({ + kind: "transient", + code: "RESOURCE_SIZE_EXCEEDED", + disposition: { retry: true }, + }); + }); + + it("treats a malformed declared checksum as transient, never a public block", async () => { + // An undecodable declared checksum is a bad record or a labeler hash-support + // gap, not tampering — no artifact was fetched or compared, so it must not + // produce a public artifact-integrity-failure. It fails the pre-fetch + // coordinate check. + const fetch = vi.fn(); + const result = await acquireArtifact( + deps({ fetch }), + await target({ checksum: "not-a-multibase-multihash" }), + ); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toMatchObject({ + kind: "transient", + code: "INVALID_MULTIHASH", + disposition: { retry: true }, + }); + expect(fetch).not.toHaveBeenCalled(); + }); +}); + +describe("acquireArtifact: SSRF/policy rejections", () => { + it("refuses a non-HTTPS declared URL without fetching", async () => { + const fetch = vi.fn(); + const result = await acquireArtifact( + deps({ fetch }), + await target({ url: "http://cdn.example.test/plugin.tgz" }), + ); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toMatchObject({ + kind: "policy-rejection", + code: "INVALID_URL", + disposition: { retry: true }, + }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("refuses an IP-literal declared URL", async () => { + const fetch = vi.fn(); + const result = await acquireArtifact( + deps({ fetch }), + await target({ url: "https://10.0.0.1/plugin.tgz" }), + ); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toMatchObject({ kind: "policy-rejection", code: "HOST_REJECTED" }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("refuses a redirect into a private address range", async () => { + const fetch: FetchImplementation = async (url) => + url.hostname === "cdn.example.test" + ? new Response(null, { status: 302, headers: { location: "https://internal.test/x" } }) + : new Response(await canonicalBundle()); + const resolveHostname: HostnameResolver = async (hostname) => + hostname === "internal.test" ? [PRIVATE_ADDRESS] : [PUBLIC_ADDRESS]; + + const result = await acquireArtifact(deps({ fetch, resolveHostname }), await target()); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toMatchObject({ + kind: "policy-rejection", + code: "HOST_REJECTED", + disposition: { retry: true }, + }); + }); +}); + +describe("acquireArtifact: mirror source", () => { + it("misses when no mirror binding is present and only the mirror is preferred", async () => { + const fetch = vi.fn(); + const result = await acquireArtifact(deps({ fetch, sources: ["mirror"] }), await target()); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toMatchObject({ + kind: "mirror-miss", + code: "MIRROR_MISS", + source: "mirror", + disposition: { retry: true }, + }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("serves from the mirror when it holds the object, without fetching the declared URL", async () => { + const bytes = await canonicalBundle(); + const fetch = vi.fn(); + const mirror: ArtifactMirror = { fetch: vi.fn(async () => bytes) }; + const result = await acquireArtifact( + deps({ fetch, mirror }), + await target({ checksum: await checksumOf(bytes) }), + ); + + expect(result.success).toBe(true); + if (result.success) expect(result.value.source).toBe("mirror"); + expect(mirror.fetch).toHaveBeenCalledWith(mirrorObjectKey(await target()), expect.anything()); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("falls back to the declared URL when the mirror serves non-matching bytes", async () => { + const good = await canonicalBundle(); + const stale = await canonicalBundle([file("stale.js", "old();")]); + const mirror: ArtifactMirror = { fetch: async () => stale }; + const result = await acquireArtifact( + deps({ fetch: respondWith(good), mirror }), + await target({ checksum: await checksumOf(good) }), + ); + + expect(result.success).toBe(true); + if (result.success) expect(result.value.source).toBe("declared-url"); + }); +}); + +describe("mirrorObjectKey", () => { + it("derives a deterministic key from release coordinates", async () => { + expect(mirrorObjectKey(await target({ artifactId: "pkg-1" }))).toBe("test-plugin/1.0.0/pkg-1"); + expect(mirrorObjectKey(await target())).toBe("test-plugin/1.0.0/package"); + }); + + it("uses the configured fetch limits by default", () => { + expect(ACQUISITION_FETCH_LIMITS.maxRedirects).toBe(3); + }); +}); diff --git a/apps/labeler/test/assessment-orchestrator.test.ts b/apps/labeler/test/assessment-orchestrator.test.ts index 4c6955c2bd..c38d2c25dd 100644 --- a/apps/labeler/test/assessment-orchestrator.test.ts +++ b/apps/labeler/test/assessment-orchestrator.test.ts @@ -7,12 +7,18 @@ import { import { applyD1Migrations, env } from "cloudflare:test"; import { beforeAll, describe, expect, it } from "vitest"; +import { + createAcquireStage, + type AcquisitionHolder, + type AcquisitionTarget, +} from "../src/artifact-acquisition.js"; import { computeRunKey, initialTriggerId, operatorTriggerId } from "../src/assessment-lifecycle.js"; import { AssessmentOrchestrator, StageTransientError, stubStages, type OrchestratorStages, + type StageAdapter, type StageFinding, } from "../src/assessment-orchestrator.js"; import { @@ -28,6 +34,7 @@ import { analyzeHistory } from "../src/history-context.js"; import { MODERATION_POLICY } from "../src/policy.js"; import { issueManualLabel } from "../src/service.js"; import { initializeSigningState } from "../src/signing-rotation.js"; +import { canonicalBundle, checksumOf, file } from "./bundle-fixture.js"; interface TestEnv { DB: D1Database; @@ -874,3 +881,104 @@ describe("AssessmentOrchestrator: supersession negation provenance (decision 6)" expect(pointer?.assessmentId).toBe(second.id); }); }); + +describe("AssessmentOrchestrator: real acquire stage (W7.2)", () => { + const resolveHostname = async () => ["203.0.113.5"]; + + function targetFor( + checksum: string, + ): (assessment: { uri: string }) => Promise { + return async () => ({ + url: "https://cdn.example.test/plugin.tgz", + checksum, + slug: "test-plugin", + version: "1.0.0", + }); + } + + it("acquires the bundle, feeds its file set to a downstream stage, and finalizes passed", async () => { + const run = await pendingRun({ name: "acquire-ok", cidValue: await cid("acquire-ok") }); + const bytes = await canonicalBundle(); + const holder: AcquisitionHolder = {}; + let downstreamFiles: readonly string[] = []; + + const acquire = createAcquireStage({ + deps: { fetch: async () => new Response(bytes), resolveHostname }, + resolveTarget: targetFor(await checksumOf(bytes)), + holder, + }); + // Stand-in for the code stage: reads the acquired file set the way the + // real codeAi wiring will. + const codeAi: StageAdapter = () => { + downstreamFiles = (holder.result?.files ?? []).map((f) => f.path); + return Promise.resolve([]); + }; + const orchestrator = await buildOrchestrator({ ...stubStages, acquire, codeAi }); + + const result = await orchestrator.runAssessment(run.id); + + expect(result.state).toBe("passed"); + expect(holder.result?.source).toBe("declared-url"); + expect(downstreamFiles).toEqual(["manifest.json", "backend.js"]); + }); + + it("blocks with artifact-integrity-failure when the fetched bytes miss the signed checksum", async () => { + const run = await pendingRun({ + name: "acquire-integrity", + cidValue: await cid("acquire-integrity"), + }); + const served = await canonicalBundle([file("tampered.js", "steal();")]); + const pinned = await checksumOf(await canonicalBundle()); + const acquire = createAcquireStage({ + deps: { fetch: async () => new Response(served), resolveHostname }, + resolveTarget: targetFor(pinned), + holder: {}, + }); + const orchestrator = await buildOrchestrator({ ...stubStages, acquire }); + + const result = await orchestrator.runAssessment(run.id); + + expect(result.state).toBe("blocked"); + const label = await testEnv.DB.prepare( + `SELECT neg FROM issued_labels WHERE uri = ? AND cid = ? AND val = 'artifact-integrity-failure'`, + ) + .bind(run.uri, run.cid) + .first<{ neg: number }>(); + expect(label?.neg).toBe(0); + }); + + it("retries then finalizes error when the declared URL is unfetchable", async () => { + const run = await pendingRun({ + name: "acquire-transient", + cidValue: await cid("acquire-transient"), + }); + let attempts = 0; + const acquire = createAcquireStage({ + deps: { + fetch: async () => { + attempts += 1; + await Promise.resolve(); + throw new TypeError("origin unreachable"); + }, + resolveHostname, + }, + resolveTarget: targetFor(await checksumOf(await canonicalBundle())), + holder: {}, + }); + const orchestrator = await buildOrchestrator( + { ...stubStages, acquire }, + { maxStageRetries: 1 }, + ); + + const result = await orchestrator.runAssessment(run.id); + + expect(result.state).toBe("error"); + expect(attempts).toBe(2); + const error = await testEnv.DB.prepare( + `SELECT neg FROM issued_labels WHERE uri = ? AND cid = ? AND val = 'assessment-error'`, + ) + .bind(run.uri, run.cid) + .first<{ neg: number }>(); + expect(error?.neg).toBe(0); + }); +}); diff --git a/apps/labeler/test/bundle-fixture.ts b/apps/labeler/test/bundle-fixture.ts new file mode 100644 index 0000000000..4535251a24 --- /dev/null +++ b/apps/labeler/test/bundle-fixture.ts @@ -0,0 +1,51 @@ +/** + * Test helpers that build canonical plugin bundles (gzipped USTAR tar) and + * their multibase checksums, entirely in-runtime (workerd `CompressionStream`, + * no `node:zlib`). Shared by the acquisition unit tests and the orchestrator + * acquire-stage test. + */ + +import { computeMultihash } from "@emdash-cms/registry-verification"; +import { packTar, type TarEntry } from "modern-tar"; + +const encoder = new TextEncoder(); + +export const FIXTURE_MANIFEST = { + id: "test-plugin", + version: "1.0.0", + capabilities: ["write:content"], + allowedHosts: [], + storage: {}, + hooks: [], + routes: [], + admin: {}, +} as const; + +export function file(name: string, body: string | Uint8Array): TarEntry { + const bytes = typeof body === "string" ? encoder.encode(body) : body; + return { header: { name, size: bytes.byteLength, type: "file" }, body: bytes }; +} + +export async function gzipBytes(bytes: Uint8Array): Promise { + const compressed = new Response(bytes).body!.pipeThrough(new CompressionStream("gzip")); + return new Uint8Array(await new Response(compressed).arrayBuffer()); +} + +export async function bundleBytes(entries: TarEntry[]): Promise { + return gzipBytes(await packTar(entries)); +} + +/** A valid bundle: manifest.json + backend.js, plus any extra entries. */ +export async function canonicalBundle(extra: TarEntry[] = []): Promise { + return bundleBytes([ + file("manifest.json", JSON.stringify(FIXTURE_MANIFEST)), + file("backend.js", "export default {};"), + ...extra, + ]); +} + +export async function checksumOf(bytes: Uint8Array): Promise { + const result = await computeMultihash(bytes); + if (!result.success) throw new Error(`could not hash fixture: ${result.error.code}`); + return result.value; +} diff --git a/packages/registry-verification/src/bundle.ts b/packages/registry-verification/src/bundle.ts index cd0e737d4b..d60acbd8e0 100644 --- a/packages/registry-verification/src/bundle.ts +++ b/packages/registry-verification/src/bundle.ts @@ -24,11 +24,19 @@ export interface ValidatePluginBundleOptions { expectedVersion?: string; } +export interface ValidatedBundleFile { + path: string; + bytes: Uint8Array; +} + export interface ValidatedPluginBundle { manifest: PluginManifest; declaredAccess: DeclaredAccess; backend: Uint8Array; admin?: Uint8Array; + /** Every regular file in the archive, in tar order — the validated file + * inventory later stages extract their analysis inputs from. */ + files: readonly ValidatedBundleFile[]; } interface ParsedFile { @@ -98,6 +106,7 @@ export async function validatePluginBundle( manifest, declaredAccess: manifest.declaredAccess ?? {}, backend: backend.data, + files: [...files.value.values()].map((file) => ({ path: file.name, bytes: file.data })), }; const admin = files.value.get("admin.js"); if (admin) result.admin = admin.data; diff --git a/packages/registry-verification/src/index.ts b/packages/registry-verification/src/index.ts index 7e1c09151a..53b03ad61e 100644 --- a/packages/registry-verification/src/index.ts +++ b/packages/registry-verification/src/index.ts @@ -26,7 +26,11 @@ export type { VerifiedResource, } from "./fetch.js"; export type { VerificationError, VerificationErrorCode, VerificationResult } from "./errors.js"; -export type { ValidatePluginBundleOptions, ValidatedPluginBundle } from "./bundle.js"; +export type { + ValidatedBundleFile, + ValidatePluginBundleOptions, + ValidatedPluginBundle, +} from "./bundle.js"; export type { ProvenanceVerificationInput, ProvenanceVerifier, diff --git a/packages/registry-verification/tests/bundle.test.ts b/packages/registry-verification/tests/bundle.test.ts index ecd17412de..ac1a80d308 100644 --- a/packages/registry-verification/tests/bundle.test.ts +++ b/packages/registry-verification/tests/bundle.test.ts @@ -75,6 +75,22 @@ describe("validatePluginBundle", () => { } }); + it("exposes every regular file in the archive as the validated inventory", async () => { + const result = await validatePluginBundle( + await canonicalBundle([file("admin.js", "export const a = 1;"), file("README.md", "hi")]), + ); + expect(result.success).toBe(true); + if (!result.success) return; + const decoder = new TextDecoder(); + const inventory = result.value.files.map((f) => [f.path, decoder.decode(f.bytes)]); + expect(inventory).toEqual([ + ["manifest.json", JSON.stringify(manifest)], + ["backend.js", "export default {};"], + ["admin.js", "export const a = 1;"], + ["README.md", "hi"], + ]); + }); + it("rejects expected slug and version mismatches", async () => { const bytes = await canonicalBundle(); expect(await validatePluginBundle(bytes, { expectedSlug: "other" })).toMatchObject({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2706fa09b7..354987e5bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -450,6 +450,9 @@ importers: '@emdash-cms/registry-moderation': specifier: workspace:* version: link:../../packages/registry-moderation + '@emdash-cms/registry-verification': + specifier: workspace:* + version: link:../../packages/registry-verification jose: specifier: ^6.1.3 version: 6.1.3 @@ -508,6 +511,9 @@ importers: jsdom: specifier: ^26.1.0 version: 26.1.0 + modern-tar: + specifier: ^0.7.6 + version: 0.7.6 react: specifier: 'catalog:' version: 19.2.4 @@ -792,7 +798,7 @@ importers: dependencies: '@astrojs/cloudflare': specifier: ^13.1.7 - version: 13.1.7(@types/node@25.9.1)(astro@6.1.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.55.2)(typescript@6.0.3)(yaml@2.9.0))(jiti@2.7.0)(lightningcss@1.32.0)(wrangler@4.110.0)(yaml@2.9.0) + version: 13.1.7(@types/node@25.9.1)(astro@6.1.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.55.2)(typescript@6.0.3)(yaml@2.9.0))(jiti@2.7.0)(lightningcss@1.32.0)(wrangler@4.110.0(@cloudflare/workers-types@5.20260712.1))(yaml@2.9.0) '@astrojs/starlight': specifier: ^0.38.2 version: 0.38.2(astro@6.1.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.55.2)(typescript@6.0.3)(yaml@2.9.0)) @@ -1147,7 +1153,7 @@ importers: devDependencies: '@flue/cli': specifier: 1.0.0-beta.9 - version: 1.0.0-beta.9(@cfworker/json-schema@4.1.1)(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@types/node@25.9.1)(esbuild@0.28.1)(hono@4.12.23)(jiti@2.7.0)(typebox@1.1.38)(typescript@6.0.3)(wrangler@4.110.0)(ws@8.21.0)(yaml@2.9.0)(zod-to-json-schema@3.25.1(zod@4.4.1))(zod@4.4.1) + version: 1.0.0-beta.9(@cfworker/json-schema@4.1.1)(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@types/node@25.9.1)(esbuild@0.28.1)(hono@4.12.23)(jiti@2.7.0)(typebox@1.1.38)(typescript@6.0.3)(wrangler@4.110.0(@cloudflare/workers-types@5.20260712.1))(ws@8.21.0)(yaml@2.9.0)(zod-to-json-schema@3.25.1(zod@4.4.1))(zod@4.4.1) typescript: specifier: 'catalog:' version: 6.0.3 @@ -12179,11 +12185,11 @@ snapshots: - prettier - prettier-plugin-astro - '@astrojs/cloudflare@13.1.7(@types/node@25.9.1)(astro@6.1.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.55.2)(typescript@6.0.3)(yaml@2.9.0))(jiti@2.7.0)(lightningcss@1.32.0)(wrangler@4.110.0)(yaml@2.9.0)': + '@astrojs/cloudflare@13.1.7(@types/node@25.9.1)(astro@6.1.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.55.2)(typescript@6.0.3)(yaml@2.9.0))(jiti@2.7.0)(lightningcss@1.32.0)(wrangler@4.110.0(@cloudflare/workers-types@5.20260712.1))(yaml@2.9.0)': dependencies: '@astrojs/internal-helpers': 0.8.0 '@astrojs/underscore-redirects': 1.0.3 - '@cloudflare/vite-plugin': 1.44.0(vite@7.3.1(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))(wrangler@4.110.0) + '@cloudflare/vite-plugin': 1.44.0(vite@7.3.1(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))(wrangler@4.110.0(@cloudflare/workers-types@5.20260712.1)) astro: 6.1.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.55.2)(typescript@6.0.3)(yaml@2.9.0) piccolore: 0.1.3 tinyglobby: 0.2.15 @@ -13855,7 +13861,7 @@ snapshots: - utf-8-validate - workerd - '@cloudflare/vite-plugin@1.44.0(vite@7.3.1(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))(wrangler@4.110.0)': + '@cloudflare/vite-plugin@1.44.0(vite@7.3.1(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))(wrangler@4.110.0(@cloudflare/workers-types@5.20260712.1))': dependencies: '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260708.1) miniflare: 4.20260708.1 @@ -13907,19 +13913,6 @@ snapshots: - utf-8-validate - workerd - '@cloudflare/vite-plugin@1.44.0(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))(wrangler@4.110.0)': - dependencies: - '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260708.1) - miniflare: 4.20260708.1 - unenv: 2.0.0-rc.24 - vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) - wrangler: 4.110.0(@cloudflare/workers-types@5.20260712.1) - ws: 8.21.0 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - workerd - '@cloudflare/vitest-pool-workers@0.18.4(@cloudflare/workers-types@5.20260712.1)(@vitest/runner@4.1.5)(@vitest/snapshot@4.1.5)(vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))': dependencies: '@vitest/runner': 4.1.5 @@ -14451,9 +14444,9 @@ snapshots: '@floating-ui/utils@0.2.11': {} - '@flue/cli@1.0.0-beta.9(@cfworker/json-schema@4.1.1)(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@types/node@25.9.1)(esbuild@0.28.1)(hono@4.12.23)(jiti@2.7.0)(typebox@1.1.38)(typescript@6.0.3)(wrangler@4.110.0)(ws@8.21.0)(yaml@2.9.0)(zod-to-json-schema@3.25.1(zod@4.4.1))(zod@4.4.1)': + '@flue/cli@1.0.0-beta.9(@cfworker/json-schema@4.1.1)(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@types/node@25.9.1)(esbuild@0.28.1)(hono@4.12.23)(jiti@2.7.0)(typebox@1.1.38)(typescript@6.0.3)(wrangler@4.110.0(@cloudflare/workers-types@5.20260712.1))(ws@8.21.0)(yaml@2.9.0)(zod-to-json-schema@3.25.1(zod@4.4.1))(zod@4.4.1)': dependencies: - '@cloudflare/vite-plugin': 1.44.0(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))(wrangler@4.110.0) + '@cloudflare/vite-plugin': 1.44.0(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))(wrangler@4.110.0(@cloudflare/workers-types@5.20260712.1)) '@flue/runtime': 1.0.0-beta.9(@cfworker/json-schema@4.1.1)(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(typebox@1.1.38)(typescript@6.0.3)(ws@8.21.0)(zod-to-json-schema@3.25.1(zod@4.4.1))(zod@4.4.1) '@flue/sdk': 1.0.0-beta.9 '@hono/node-server': 2.0.4(hono@4.12.23)