Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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.
3 changes: 2 additions & 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 All @@ -23,6 +23,7 @@
"wrangler": "catalog:"
},
"dependencies": {
"@emdash-cms/registry-verification": "workspace:*",
"jose": "^6.1.3"

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 added @emdash-cms/registry-verification dependency is not imported anywhere in apps/release-service (the verifier app imports it, and the service-binding types are generated from ../release-verifier/wrangler.jsonc). It adds unused weight to the lockfile and is out of scope for this package.

Suggested change
"jose": "^6.1.3"
"dependencies": {
"jose": "^6.1.3"
}

}
}
70 changes: 70 additions & 0 deletions apps/release-service/src/verifier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type {
VerificationError,
VerificationResult,
} from "@emdash-cms/registry-verification/fetch";
import { VERIFICATION_ERROR_CODES } from "@emdash-cms/registry-verification/fetch";

type VerifierMethod = "fetchArtifact" | "fetchProvenance";
const VERIFICATION_ERROR_CODE_SET = new Set<string>(VERIFICATION_ERROR_CODES);

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() {
super("Release verifier is unavailable");
this.name = "VerifierUnavailableError";
}
}

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

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

async function callVerifier(
binding: ReleaseVerifierBinding,
method: VerifierMethod,
url: string,
): Promise<VerificationResult<Uint8Array>> {
try {
const result = await binding[method](url);
if (!isVerificationResult(result)) throw new VerifierUnavailableError();
return result;
} catch (error) {
if (error instanceof VerifierUnavailableError) throw error;
throw new VerifierUnavailableError();
}
}

function isVerificationResult(value: unknown): value is VerificationResult<Uint8Array> {
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 VerificationError {
return (
isRecord(value) &&
typeof value["code"] === "string" &&
VERIFICATION_ERROR_CODE_SET.has(value["code"]) &&

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.

[needs fixing] The adapter treats a well-formed { success: false, error: { code, message } } result as malformed whenever the verifier uses an error code not in the client’s hardcoded VERIFICATION_ERROR_CODE_SET. That turns forward-compatible new failure codes (version skew, new policy checks, etc.) into a retryable VerifierUnavailableError, masking a definitive verification failure as transient verifier unavailability and defeating the purpose of a separated service boundary.

The typed service binding already guarantees the result shape, so validate only the shape here, not the specific code.

Suggested change
function isVerificationError(value: unknown): value is VerificationError {
return (
isRecord(value) &&
typeof value["code"] === "string" &&
VERIFICATION_ERROR_CODE_SET.has(value["code"]) &&
function isVerificationError(value: unknown): value is VerificationError {
return isRecord(value) && typeof value["code"] === "string" && typeof value["message"] === "string";
}

Then remove the now-dead VERIFICATION_ERROR_CODE_SET constant (line 8) and the VERIFICATION_ERROR_CODES import (line 5).

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" };
}
}
81 changes: 81 additions & 0 deletions apps/release-service/test/verifier.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
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("fails closed with a retryable generic error when the binding rejects", async () => {
const binding = verifierBinding({
fetchArtifact: vi.fn().mockRejectedValue(new Error("internal service address")),
});
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,
});
});

it.each([
null,
{ success: true, value: "not bytes" },
{ success: false, error: { code: "NEW_VERSION_CODE", message: "version skew" } },

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.

[needs fixing] This test case labels a valid version-skew error result as a “malformed RPC result.” A { success: false, error: { code: "NEW_VERSION_CODE", message: "version skew" } } response has exactly the shape the adapter should pass through; it is not malformed and should not become a VerifierUnavailableError.

Move it out of the malformed list and assert that the adapter returns the error result unchanged, instead of normalizing it to a retryable verifier-unavailable error.

{ 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