Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/verifier-fetch-entry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@emdash-cms/registry-verification": minor
---

Adds a Worker-safe safe-fetch entry point for isolated resource verification.
2 changes: 1 addition & 1 deletion apps/release-service/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"deploy": "vite build && wrangler deploy",
"typecheck": "tsgo --noEmit",
"test": "vitest run && vitest run --config vitest.node.config.ts",
"types": "wrangler types"
"types": "wrangler types -c wrangler.jsonc -c ../release-verifier/wrangler.jsonc"
},
"devDependencies": {
"@cloudflare/vite-plugin": "catalog:",
Expand Down
66 changes: 66 additions & 0 deletions apps/release-service/src/verifier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
type VerifierMethod = "fetchArtifact" | "fetchProvenance";

export type ReleaseVerifierResult =
| { success: true; value: Uint8Array }
| { success: false; error: { code: string; message: string } };

export interface ReleaseVerifierBinding {
fetchArtifact(url: string): Promise<unknown>;
fetchProvenance(url: string): Promise<unknown>;
}

export class VerifierUnavailableError extends Error {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The catch in callVerifier replaces every binding/validation failure with a fresh VerifierUnavailableError, throwing away the original cause. That matches the "fail closed" design, but when this bubbles up in production logs there will be no trace of why the verifier was considered unavailable — RPC transport error, malformed response, version-skewed error code, etc.

Consider stashing the original error as cause:

export class VerifierUnavailableError extends Error {
	readonly retryable = true;

	constructor(cause?: unknown) {
		super("Release verifier is unavailable", { cause });
		this.name = "VerifierUnavailableError";
	}
}

and then rethrowing new VerifierUnavailableError(error). This keeps the client response unchanged while giving operators something actionable in the logs.

readonly retryable = true;

constructor(options?: ErrorOptions) {
super("Release verifier is unavailable", options);
this.name = "VerifierUnavailableError";
}
}

export async function fetchArtifact(
binding: ReleaseVerifierBinding,
url: string,
): Promise<ReleaseVerifierResult> {
return callVerifier(binding, "fetchArtifact", url);
}

export async function fetchProvenance(
binding: ReleaseVerifierBinding,
url: string,
): Promise<ReleaseVerifierResult> {
return callVerifier(binding, "fetchProvenance", url);
}

async function callVerifier(
binding: ReleaseVerifierBinding,
method: VerifierMethod,
url: string,
): Promise<ReleaseVerifierResult> {
try {
const result = await binding[method](url);
if (!isVerificationResult(result)) {
throw new TypeError("Release verifier returned an invalid response");
}
return result;
} catch (error) {
if (error instanceof VerifierUnavailableError) throw error;
throw new VerifierUnavailableError({ cause: error });
}
}

function isVerificationResult(value: unknown): value is ReleaseVerifierResult {
if (!isRecord(value) || typeof value["success"] !== "boolean") return false;
if (value["success"] === true) return value["value"] instanceof Uint8Array;
return isVerificationError(value["error"]);
}

function isVerificationError(value: unknown): value is { code: string; message: string } {
return (
isRecord(value) && typeof value["code"] === "string" && typeof value["message"] === "string"
);
}

function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
12 changes: 12 additions & 0 deletions apps/release-service/test/fixtures/release-verifier.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { WorkerEntrypoint } from "cloudflare:workers";

export default class TestReleaseVerifier extends WorkerEntrypoint {
fetchArtifact(url) {
if (url.endsWith("/unavailable")) throw new Error("internal service address");
return { success: true, value: new Uint8Array([7]) };
}

fetchProvenance() {
return { success: true, value: "not bytes" };
}
}
108 changes: 108 additions & 0 deletions apps/release-service/test/verifier.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { env } from "cloudflare:workers";
import { describe, expect, it, vi } from "vitest";

import {
fetchArtifact,
fetchProvenance,
type ReleaseVerifierBinding,
VerifierUnavailableError,
} from "../src/verifier.js";

function verifierBinding(overrides: Partial<ReleaseVerifierBinding> = {}): ReleaseVerifierBinding {
return {
fetchArtifact: async () => ({ success: true, value: new Uint8Array([1]) }),
fetchProvenance: async () => ({ success: true, value: new Uint8Array([2]) }),
...overrides,
};
}

describe("release verifier binding adapter", () => {
it("validates results across the configured RPC service binding", async () => {
await expect(
fetchArtifact(env.RELEASE_VERIFIER, "https://example.test/plugin.tgz"),
).resolves.toEqual({ success: true, value: new Uint8Array([7]) });
await expect(
fetchProvenance(env.RELEASE_VERIFIER, "https://example.test/provenance.json"),
).rejects.toBeInstanceOf(VerifierUnavailableError);
});

it("normalizes configured service-binding failures", async () => {
await expect(
fetchArtifact(env.RELEASE_VERIFIER, "https://example.test/unavailable"),
).rejects.toMatchObject({
message: "Release verifier is unavailable",
retryable: true,
});
});

it("returns successful bytes and stable verification failures", async () => {
await expect(
fetchArtifact(verifierBinding(), "https://example.test/plugin.tgz"),
).resolves.toEqual({
success: true,
value: new Uint8Array([1]),
});
const failure = {
success: false,
error: { code: "RESOURCE_STATUS_ERROR", message: "The resource returned HTTP 404." },
};
await expect(
fetchProvenance(
verifierBinding({ fetchProvenance: async () => failure }),
"https://example.test/provenance.json",
),
).resolves.toEqual(failure);
});

it("preserves definitive failures from a newer verifier", async () => {
const failure = {
success: false as const,
error: { code: "NEW_VERSION_CODE", message: "new definitive failure" },
};

await expect(
fetchArtifact(
verifierBinding({ fetchArtifact: async () => failure }),
"https://example.test/plugin.tgz",
),
).resolves.toEqual(failure);
});

it("fails closed with a retryable generic error when the binding rejects", async () => {
const cause = new Error("internal service address");
const binding = verifierBinding({
fetchArtifact: vi.fn().mockRejectedValue(cause),
});
const result = fetchArtifact(binding, "https://example.test/plugin.tgz");
await expect(result).rejects.toBeInstanceOf(VerifierUnavailableError);
await expect(result).rejects.toMatchObject({
message: "Release verifier is unavailable",
retryable: true,
cause,
});
});

it("preserves malformed response details as a diagnostic cause", async () => {
const binding = verifierBinding({ fetchArtifact: async () => null });

await expect(fetchArtifact(binding, "https://example.test/plugin.tgz")).rejects.toMatchObject({
name: "VerifierUnavailableError",
cause: expect.objectContaining({
name: "TypeError",
message: "Release verifier returned an invalid response",
}),
});
});

it.each([
null,
{ success: true, value: "not bytes" },
{ success: false, error: { code: "FETCH_FAILED" } },
])("rejects malformed RPC result %#", async (rpcResult) => {
const binding = verifierBinding({ fetchArtifact: async () => rpcResult });
await expect(fetchArtifact(binding, "https://example.test/plugin.tgz")).rejects.toMatchObject({
name: "VerifierUnavailableError",
retryable: true,
});
});
});
12 changes: 12 additions & 0 deletions apps/release-service/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,27 @@
import { fileURLToPath } from "node:url";

import { cloudflareTest } from "@cloudflare/vitest-pool-workers";
import { defineConfig } from "vitest/config";

const TEST_ENCRYPTION_KEYRING =
'{"current":1,"keys":[{"version":1,"key":"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"}]}';
process.env["ENCRYPTION_KEYRING"] ??= TEST_ENCRYPTION_KEYRING;
const verifierScriptPath = fileURLToPath(
new URL("./test/fixtures/release-verifier.js", import.meta.url),
);

export default defineConfig({
plugins: [
cloudflareTest({
wrangler: { configPath: "./wrangler.jsonc" },
miniflare: {
workers: [
{
name: "emdash-release-verifier",
modules: true,
scriptPath: verifierScriptPath,
},
],
bindings: {
PUBLIC_ORIGIN: "https://release.example.invalid",
ALLOWED_ORIGINS: '["https://release.example.invalid"]',
Expand Down
3 changes: 2 additions & 1 deletion apps/release-service/worker-configuration.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable */
// Generated by Wrangler by running `wrangler types` (hash: f7fd66ac7aa6339944d0e40bc7b3b5c1)
// Generated by Wrangler by running `wrangler types --config=wrangler.jsonc --config=../release-verifier/wrangler.jsonc` (hash: f16c80582f5d22ad180e2539412d622e)
// Runtime types generated with workerd@1.20260611.1 2026-05-14 nodejs_compat
interface __BaseEnv_Env {
DB: D1Database;
Expand All @@ -11,6 +11,7 @@ interface __BaseEnv_Env {
ALLOWED_PUBLISHERS: "{\"mode\":\"allowlist\",\"dids\":[]}";
DEPLOYMENT_POLICY: "";
ENCRYPTION_KEYRING: string;
RELEASE_VERIFIER: Service<typeof import("../release-verifier/src/index").default>;
}
declare namespace Cloudflare {
interface GlobalProps {
Expand Down
6 changes: 6 additions & 0 deletions apps/release-service/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@
"triggers": {
"crons": ["*/5 * * * *"],
},
"services": [
{
"binding": "RELEASE_VERIFIER",
"service": "emdash-release-verifier",
},
],
"assets": {
"binding": "ASSETS",
"directory": "./public",
Expand Down
25 changes: 25 additions & 0 deletions apps/release-verifier/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "@emdash-cms/release-verifier",
"version": "0.0.0",
"private": true,
"description": "Isolated egress Worker for delegated release verification.",
"type": "module",
"scripts": {
"build": "wrangler deploy --dry-run --outdir dist",
"deploy": "wrangler deploy",
"typecheck": "tsgo --noEmit",
"test": "vitest run",
"types": "wrangler types"
},
"dependencies": {
"@emdash-cms/registry-verification": "workspace:*"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "catalog:",
"@types/node": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vitest": "catalog:",
"wrangler": "catalog:"
}
}
Loading
Loading