diff --git a/src/runtime-entrypoint.ts b/src/runtime-entrypoint.ts index db559cc5a..60c02eca1 100644 --- a/src/runtime-entrypoint.ts +++ b/src/runtime-entrypoint.ts @@ -9,6 +9,7 @@ import { evaluateRuntimeReadiness } from "./runtime-readiness"; export { NoemaOidcReplayGuard, NoemaRateLimiter }; export { NoemaWorkflowState } from "./workflow-task-execution/workflow-state-durable-object"; +export { NoemaExternalExtensionLifecycle } from "./tool-capability/external-extension-lifecycle-durable-object"; /** * Runtime bindings required by Noema's production worker entrypoint. diff --git a/src/tool-capability/external-extension-lifecycle-durable-object.ts b/src/tool-capability/external-extension-lifecycle-durable-object.ts new file mode 100644 index 000000000..2d95b5c9f --- /dev/null +++ b/src/tool-capability/external-extension-lifecycle-durable-object.ts @@ -0,0 +1,302 @@ +import { + DurableExternalExtensionLifecycleRepository, + ExternalExtensionLifecycleConflictError, + ExternalExtensionLifecycleEvidenceError, + ExternalExtensionLifecycleValidationError, + type ExternalExtensionLifecycleAppend, + type ExternalExtensionLifecycleAppendResult, + type ExternalExtensionLifecycleEvent, + type ExternalExtensionLifecycleSnapshot, + type ExternalExtensionLifecycleStreamIdentity, +} from "./external-extension-lifecycle-store"; + +const LIFECYCLE_INTERNAL_ENDPOINT = "https://noema-external-extension-lifecycle.internal/command"; +const IDENTIFIER = /^[a-z][a-z0-9_]{2,127}$/u; +const REPOSITORY = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?\/[A-Za-z0-9._-]+$/u; +const HEX40_OR_64 = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u; +const SHA256 = /^[0-9a-f]{64}$/u; +const RELATIVE_PATH = /^(?!\/)[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/u; + +/** Cloudflare binding that routes each exact extension/artifact stream to one lifecycle authority. */ +export interface ExternalExtensionLifecycleDurableObjectEnv { + NOEMA_EXTERNAL_EXTENSION_LIFECYCLE: DurableObjectNamespace; +} + +/** Private command surface between Noema runtime adapters and the lifecycle Durable Object. */ +export type ExternalExtensionLifecycleCommand = + | { readonly operation: "append"; readonly request: ExternalExtensionLifecycleAppend } + | { readonly operation: "read_current"; readonly stream: ExternalExtensionLifecycleStreamIdentity } + | { readonly operation: "read_audit"; readonly stream: ExternalExtensionLifecycleStreamIdentity }; + +type ExternalExtensionLifecycleCommandData = + | ExternalExtensionLifecycleAppendResult + | ExternalExtensionLifecycleSnapshot + | readonly ExternalExtensionLifecycleEvent[] + | null; + +type ExternalExtensionLifecycleCommandResponse = + | { readonly ok: true; readonly data: ExternalExtensionLifecycleCommandData } + | { + readonly ok: false; + readonly error: "invalid_request" | "conflict" | "evidence_unavailable" | "internal_error"; + }; + +/** Reject null and array-shaped JSON before any untrusted property projection occurs. */ +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** Accept only the bounded JSON media type used by the private command protocol. */ +function isJsonMediaType(value: string | null): boolean { + return /^[ \t]*application\/json[ \t]*(?:;[ \t]*charset[ \t]*=[ \t]*utf-8[ \t]*)?$/iu.test(value ?? ""); +} + +/** Emit normalized non-cacheable responses without leaking repository or storage exception detail. */ +function jsonResponse(body: ExternalExtensionLifecycleCommandResponse, status: number): Response { + return new Response(JSON.stringify(body), { + status, + headers: { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + pragma: "no-cache", + "x-content-type-options": "nosniff", + }, + }); +} + +/** Preserve string type authority at the JSON boundary instead of relying on RegExp coercion downstream. */ +function stringField(value: Record, field: string): string { + const candidate = value[field]; + if (typeof candidate !== "string") { + throw new ExternalExtensionLifecycleValidationError(`lifecycle append ${field} must be a string`); + } + return candidate; +} + +/** Preserve numeric CAS-version type authority before repository safe-integer validation. */ +function numberField(value: Record, field: string): number { + const candidate = value[field]; + if (typeof candidate !== "number") { + throw new ExternalExtensionLifecycleValidationError(`lifecycle append ${field} must be a number`); + } + return candidate; +} + +/** Preserve the explicit null-or-state-string shape used for the first lifecycle transition. */ +function nullableStringField(value: Record, field: string): string | null { + const candidate = value[field]; + if (candidate !== null && typeof candidate !== "string") { + throw new ExternalExtensionLifecycleValidationError( + `lifecycle append ${field} must be a string or null`, + ); + } + return candidate; +} + +/** + * Project only stream coordinates needed for lifecycle routing. + * This is a transport ACL, not a second lifecycle domain model: transition legality and durable + * evidence invariants remain owned by DurableExternalExtensionLifecycleRepository. + */ +function projectStream(value: unknown): ExternalExtensionLifecycleStreamIdentity { + if (!isRecord(value)) { + throw new ExternalExtensionLifecycleValidationError("lifecycle stream must be an object"); + } + const stream = { + external_extension_id: value.external_extension_id, + upstream_repository: value.upstream_repository, + upstream_commit_sha: value.upstream_commit_sha, + upstream_path: value.upstream_path, + artifact_sha256: value.artifact_sha256, + marketplace_entry_sha256: value.marketplace_entry_sha256, + }; + if ( + typeof stream.external_extension_id !== "string" + || !IDENTIFIER.test(stream.external_extension_id) + || typeof stream.upstream_repository !== "string" + || !REPOSITORY.test(stream.upstream_repository) + || typeof stream.upstream_commit_sha !== "string" + || !HEX40_OR_64.test(stream.upstream_commit_sha) + || typeof stream.upstream_path !== "string" + || !RELATIVE_PATH.test(stream.upstream_path) + || typeof stream.artifact_sha256 !== "string" + || !SHA256.test(stream.artifact_sha256) + || typeof stream.marketplace_entry_sha256 !== "string" + || !SHA256.test(stream.marketplace_entry_sha256) + ) { + throw new ExternalExtensionLifecycleValidationError("lifecycle stream identity is not canonical"); + } + return stream as ExternalExtensionLifecycleStreamIdentity; +} + +/** Project the complete append contract while rejecting scalar type confusion before repository validation. */ +function projectAppend(value: unknown): ExternalExtensionLifecycleAppend { + if (!isRecord(value)) { + throw new ExternalExtensionLifecycleValidationError("lifecycle append must be an object"); + } + return { + transition_id: stringField(value, "transition_id"), + stream: projectStream(value.stream), + expected_version: numberField(value, "expected_version"), + prior_state: nullableStringField(value, "prior_state") as ExternalExtensionLifecycleAppend["prior_state"], + next_state: stringField(value, "next_state") as ExternalExtensionLifecycleAppend["next_state"], + policy_approval_reference: stringField(value, "policy_approval_reference"), + activation_policy_version: stringField(value, "activation_policy_version"), + effective_scope_reference: stringField(value, "effective_scope_reference"), + appguardrail_evidence_reference: stringField(value, "appguardrail_evidence_reference"), + appguardrail_profile_identity: stringField(value, "appguardrail_profile_identity"), + appguardrail_profile_sha256: stringField(value, "appguardrail_profile_sha256"), + quarantine_evidence_reference: stringField(value, "quarantine_evidence_reference"), + quarantine_profile_identity: stringField(value, "quarantine_profile_identity"), + quarantine_profile_sha256: stringField(value, "quarantine_profile_sha256"), + isolation_profile_reference: stringField(value, "isolation_profile_reference"), + egress_policy_reference: stringField(value, "egress_policy_reference"), + occurred_at: stringField(value, "occurred_at"), + causation_id: stringField(value, "causation_id"), + correlation_id: stringField(value, "correlation_id"), + actor_identity_handle: stringField(value, "actor_identity_handle"), + }; +} + +/** Hash canonical JSON material for privacy-preserving stream-scoped object names. */ +async function sha256Hex(value: unknown): Promise { + const bytes = new TextEncoder().encode(JSON.stringify(value)); + const digest = await crypto.subtle.digest("SHA-256", bytes); + return [...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +/** + * Derive the privacy-preserving Durable Object name for one canonical exact lifecycle stream. + * Exact source/artifact coordinates participate in the name so unrelated artifacts cannot share + * an object simply because their marketplace extension identifier is the same. + * @param streamInput Untrusted candidate coordinates projected into the canonical lifecycle stream identity. + * @returns A deterministic object name derived only from the canonical stream coordinates. + */ +export async function externalExtensionLifecycleObjectName( + streamInput: unknown, +): Promise { + const stream = projectStream(streamInput); + return `external-extension-lifecycle:${await sha256Hex(stream)}`; +} + +/** Project commands before serialization so caller-only properties never cross the persistence boundary. */ +function projectedTransportCommand(command: ExternalExtensionLifecycleCommand): ExternalExtensionLifecycleCommand { + if (command.operation === "append") { + return { operation: "append", request: projectAppend(command.request) }; + } + return { operation: command.operation, stream: projectStream(command.stream) }; +} + +/** + * Route one lifecycle command to the exact stream-scoped Durable Object. + * Caller-only properties are projected out before serialization, so secrets, product rows, hidden + * reasoning, and other structurally compatible extras cannot cross the persistence boundary. + * @param env Worker environment containing the canonical lifecycle Durable Object namespace. + * @param command Lifecycle append or read command projected before crossing the persistence boundary. + * @returns The response from the exact stream-scoped Durable Object command endpoint. + */ +export async function routeExternalExtensionLifecycleCommand( + env: ExternalExtensionLifecycleDurableObjectEnv, + command: ExternalExtensionLifecycleCommand, +): Promise { + const projected = projectedTransportCommand(command); + const stream = projected.operation === "append" ? projected.request.stream : projected.stream; + const objectName = await externalExtensionLifecycleObjectName(stream); + const objectId = env.NOEMA_EXTERNAL_EXTENSION_LIFECYCLE.idFromName(objectName); + const stub = env.NOEMA_EXTERNAL_EXTENSION_LIFECYCLE.get(objectId); + return stub.fetch(LIFECYCLE_INTERNAL_ENDPOINT, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(projected), + }); +} + +/** + * Cloudflare Durable Object adapter for one exact external-extension lifecycle stream. + * + * It owns only transport routing and storage binding. The repository owns transition/CAS/audit + * invariants. No production evidence verifier is injected yet, so a new `active` transition remains + * fail-closed until a reviewed Noema Policy/Approval + foreign-owner evidence adapter is available. + */ +export class NoemaExternalExtensionLifecycle { + private readonly repository: DurableExternalExtensionLifecycleRepository; + private readonly objectName: string | undefined; + + constructor(state: DurableObjectState) { + this.repository = new DurableExternalExtensionLifecycleRepository(state.storage); + this.objectName = state.id.name; + } + + /** Enforce the private command envelope, exact object authority, and normalized fail-closed errors. */ + async fetch(request: Request): Promise { + if (request.method !== "POST" || request.url !== LIFECYCLE_INTERNAL_ENDPOINT) { + return jsonResponse({ ok: false, error: "invalid_request" }, 404); + } + if (!isJsonMediaType(request.headers.get("content-type"))) { + return jsonResponse({ ok: false, error: "invalid_request" }, 415); + } + + let raw: unknown; + try { + raw = await request.json(); + } catch { + return jsonResponse({ ok: false, error: "invalid_request" }, 400); + } + if (!isRecord(raw) || typeof raw.operation !== "string") { + return jsonResponse({ ok: false, error: "invalid_request" }, 400); + } + + try { + let stream: ExternalExtensionLifecycleStreamIdentity; + let data: ExternalExtensionLifecycleCommandData; + switch (raw.operation) { + case "append": { + const append = projectAppend(raw.request); + stream = append.stream; + const expectedObjectName = await externalExtensionLifecycleObjectName(stream); + if (this.objectName !== expectedObjectName) { + throw new ExternalExtensionLifecycleConflictError( + "lifecycle command does not match this Durable Object stream authority", + ); + } + data = await this.repository.append(append); + break; + } + case "read_current": + stream = projectStream(raw.stream); + if (this.objectName !== await externalExtensionLifecycleObjectName(stream)) { + throw new ExternalExtensionLifecycleConflictError( + "lifecycle command does not match this Durable Object stream authority", + ); + } + data = await this.repository.readCurrent(stream); + break; + case "read_audit": + stream = projectStream(raw.stream); + if (this.objectName !== await externalExtensionLifecycleObjectName(stream)) { + throw new ExternalExtensionLifecycleConflictError( + "lifecycle command does not match this Durable Object stream authority", + ); + } + data = await this.repository.readAudit(stream); + break; + default: + return jsonResponse({ ok: false, error: "invalid_request" }, 400); + } + return jsonResponse({ ok: true, data }, 200); + } catch (error) { + if (error instanceof ExternalExtensionLifecycleValidationError) { + return jsonResponse({ ok: false, error: "invalid_request" }, 400); + } + if (error instanceof ExternalExtensionLifecycleConflictError) { + return jsonResponse({ ok: false, error: "conflict" }, 409); + } + if (error instanceof ExternalExtensionLifecycleEvidenceError) { + return jsonResponse({ ok: false, error: "evidence_unavailable" }, 412); + } + return jsonResponse({ ok: false, error: "internal_error" }, 500); + } + } +} diff --git a/test/external-extension-lifecycle-durable-object-request-edge.test.ts b/test/external-extension-lifecycle-durable-object-request-edge.test.ts new file mode 100644 index 000000000..5371710b4 --- /dev/null +++ b/test/external-extension-lifecycle-durable-object-request-edge.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { NoemaExternalExtensionLifecycle } from "../src/tool-capability/external-extension-lifecycle-durable-object"; + +const endpoint = "https://noema-external-extension-lifecycle.internal/command"; + +function object(): NoemaExternalExtensionLifecycle { + const state = { + storage: {} as DurableObjectStorage, + id: { name: undefined }, + } as unknown as DurableObjectState; + return new NoemaExternalExtensionLifecycle(state); +} + +function appendRequest(): Record { + return { + transition_id: "transition-0001", + stream: { + external_extension_id: "review_helper", + upstream_repository: "anthropics/claude-plugins-community", + upstream_commit_sha: "b".repeat(40), + upstream_path: "plugins/review-helper", + artifact_sha256: "a".repeat(64), + marketplace_entry_sha256: "c".repeat(64), + }, + expected_version: 0, + prior_state: null, + next_state: "discovered", + policy_approval_reference: "urn:cwl:noema:approval:review_helper:v1", + activation_policy_version: "urn:cwl:noema:external_extension_activation:developer-assist-v1", + effective_scope_reference: "urn:cwl:noema:scope:developer_assist:v1", + appguardrail_evidence_reference: "urn:cwl:appguardrail:receipt:scan-0001", + appguardrail_profile_identity: "urn:cwl:appguardrail:profile:static-v1", + appguardrail_profile_sha256: "d".repeat(64), + quarantine_evidence_reference: "urn:cwl:quarantine:receipt:analysis-0001", + quarantine_profile_identity: "urn:cwl:quarantine:profile:plugin-v1", + quarantine_profile_sha256: "e".repeat(64), + isolation_profile_reference: "urn:cwl:quarantine:isolation:plugin-v1", + egress_policy_reference: "urn:cwl:egressweave:policy:developer-assist-v1", + occurred_at: "2026-09-09T09:10:00.000Z", + causation_id: "cause-0001", + correlation_id: "correlation-0001", + actor_identity_handle: "service:noema", + }; +} + +async function appendCommand(request: Record): Promise { + return object().fetch(new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ operation: "append", request }), + })); +} + +describe("external-extension lifecycle Durable Object request envelope", () => { + it("rejects a request with no JSON media type before body parsing", async () => { + const response = await object().fetch(new Request(endpoint, { method: "POST" })); + expect(response.status).toBe(415); + await expect(response.json()).resolves.toEqual({ ok: false, error: "invalid_request" }); + }); + + it("rejects a JSON scalar before lifecycle command dispatch", async () => { + const response = await object().fetch(new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "null", + })); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ ok: false, error: "invalid_request" }); + }); + + it.each([ + ["transition_id", 7], + ["expected_version", "0"], + ["prior_state", 7], + ["policy_approval_reference", ["urn:cwl:noema:approval:review_helper:v1"]], + ])("rejects append scalar type confusion for %s", async (field, invalidValue) => { + const response = await appendCommand({ ...appendRequest(), [field]: invalidValue }); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ ok: false, error: "invalid_request" }); + }); +}); diff --git a/test/external-extension-lifecycle-durable-object.test.ts b/test/external-extension-lifecycle-durable-object.test.ts new file mode 100644 index 000000000..102c1b741 --- /dev/null +++ b/test/external-extension-lifecycle-durable-object.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, it } from "vitest"; +import { + NoemaExternalExtensionLifecycle, + externalExtensionLifecycleObjectName, + routeExternalExtensionLifecycleCommand, + type ExternalExtensionLifecycleDurableObjectEnv, +} from "../src/tool-capability/external-extension-lifecycle-durable-object"; +import type { + ExternalExtensionLifecycleAppend, + ExternalExtensionLifecycleStreamIdentity, +} from "../src/tool-capability/external-extension-lifecycle-store"; + +class Storage { + readonly records = new Map(); + private transactionTail: Promise = Promise.resolve(); + + async get(key: string): Promise { + return structuredClone(this.records.get(key)) as T | undefined; + } + + async put(key: string, value: T): Promise { + this.records.set(key, structuredClone(value)); + } + + async list(options: { prefix?: string; limit?: number } = {}): Promise> { + const prefix = options.prefix ?? ""; + const limit = options.limit ?? Number.POSITIVE_INFINITY; + return new Map( + [...this.records.entries()] + .filter(([key]) => key.startsWith(prefix)) + .sort(([left], [right]) => left.localeCompare(right)) + .slice(0, limit) + .map(([key, value]) => [key, structuredClone(value) as T] as const), + ); + } + + async transaction(callback: (txn: Storage) => Promise): Promise { + const predecessor = this.transactionTail; + let release!: () => void; + this.transactionTail = new Promise((resolve) => { release = resolve; }); + await predecessor; + try { + return await callback(this); + } finally { + release(); + } + } +} + +const stream = (): ExternalExtensionLifecycleStreamIdentity => ({ + external_extension_id: "review_helper", + upstream_repository: "anthropics/claude-plugins-community", + upstream_commit_sha: "b".repeat(40), + upstream_path: "plugins/review-helper", + artifact_sha256: "a".repeat(64), + marketplace_entry_sha256: "c".repeat(64), +}); + +const append = ( + overrides: Partial = {}, +): ExternalExtensionLifecycleAppend => ({ + transition_id: "transition-0001", + stream: stream(), + expected_version: 0, + prior_state: null, + next_state: "discovered", + policy_approval_reference: "urn:cwl:noema:approval:review_helper:v1", + activation_policy_version: "urn:cwl:noema:external_extension_activation:developer-assist-v1", + effective_scope_reference: "urn:cwl:noema:scope:developer_assist:v1", + appguardrail_evidence_reference: "urn:cwl:appguardrail:receipt:scan-0001", + appguardrail_profile_identity: "urn:cwl:appguardrail:profile:static-v1", + appguardrail_profile_sha256: "d".repeat(64), + quarantine_evidence_reference: "urn:cwl:quarantine:receipt:analysis-0001", + quarantine_profile_identity: "urn:cwl:quarantine:profile:plugin-v1", + quarantine_profile_sha256: "e".repeat(64), + isolation_profile_reference: "urn:cwl:quarantine:isolation:plugin-v1", + egress_policy_reference: "urn:cwl:egressweave:policy:developer-assist-v1", + occurred_at: "2026-09-09T09:10:00.000Z", + causation_id: "cause-0001", + correlation_id: "correlation-0001", + actor_identity_handle: "service:noema", + ...overrides, +}); + +async function durableObject(storage = new Storage(), objectName?: string) { + const name = objectName ?? await externalExtensionLifecycleObjectName(stream()); + const state = { + storage: storage as unknown as DurableObjectStorage, + id: { name }, + } as unknown as DurableObjectState; + return { object: new NoemaExternalExtensionLifecycle(state), storage }; +} + +async function command( + object: NoemaExternalExtensionLifecycle, + body: unknown, + contentType = "application/json", +): Promise { + return object.fetch(new Request("https://noema-external-extension-lifecycle.internal/command", { + method: "POST", + headers: { "content-type": contentType }, + body: JSON.stringify(body), + })); +} + +describe("external-extension lifecycle Durable Object adapter", () => { + it("routes one canonical stream to a deterministic private object and strips caller-only fields", async () => { + const calls: Array<{ name: string; url: string; init?: RequestInit }> = []; + const env = { + NOEMA_EXTERNAL_EXTENSION_LIFECYCLE: { + idFromName(name: string) { return { name } as unknown as DurableObjectId; }, + get(id: DurableObjectId) { + return { + async fetch(url: string, init?: RequestInit) { + calls.push({ name: (id as unknown as { name: string }).name, url, init }); + return new Response("ok"); + }, + } as unknown as DurableObjectStub; + }, + } as unknown as DurableObjectNamespace, + } satisfies ExternalExtensionLifecycleDurableObjectEnv; + const hostile = { + ...append(), + secret_material: "OPENAI_API_KEY=never-forward", + product_record: "customer payroll row", + } as ExternalExtensionLifecycleAppend; + + await routeExternalExtensionLifecycleCommand(env, { operation: "append", request: hostile }); + + expect(calls).toHaveLength(1); + expect(calls[0].name).toBe(await externalExtensionLifecycleObjectName(stream())); + expect(calls[0].url).toBe("https://noema-external-extension-lifecycle.internal/command"); + const body = String(calls[0].init?.body); + expect(body).not.toContain("OPENAI_API_KEY"); + expect(body).not.toContain("customer payroll row"); + + await routeExternalExtensionLifecycleCommand(env, { operation: "read_current", stream: stream() }); + expect(calls).toHaveLength(2); + expect(JSON.parse(String(calls[1].init?.body))).toEqual({ + operation: "read_current", + stream: stream(), + }); + }); + + it("persists, reads the compact projection, and reads the full audit through the private boundary", async () => { + const { object } = await durableObject(); + const accepted = await command(object, { operation: "append", request: append() }); + expect(accepted.status).toBe(200); + await expect(accepted.json()).resolves.toMatchObject({ ok: true, data: { kind: "accepted" } }); + + const current = await command(object, { operation: "read_current", stream: stream() }); + expect(current.status).toBe(200); + await expect(current.json()).resolves.toMatchObject({ + ok: true, + data: { version: 1, state: "discovered" }, + }); + + const audit = await command(object, { operation: "read_audit", stream: stream() }); + expect(audit.status).toBe(200); + const auditBody = await audit.json() as { data: unknown[] }; + expect(auditBody.data).toHaveLength(1); + }); + + it("fails closed for wrong transport, malformed commands, object substitution, and conflicting replay", async () => { + const { object } = await durableObject(); + await expect(object.fetch(new Request("https://example.invalid/command", { method: "POST" }))) + .resolves.toMatchObject({ status: 404 }); + await expect(object.fetch(new Request("https://noema-external-extension-lifecycle.internal/command", { + method: "GET", + }))).resolves.toMatchObject({ status: 404 }); + await expect(object.fetch(new Request("https://noema-external-extension-lifecycle.internal/command", { + method: "POST", + body: "{}", + }))).resolves.toMatchObject({ status: 415 }); + await expect(command(object, { operation: "read_current", stream: stream() }, "text/plain")) + .resolves.toMatchObject({ status: 415 }); + await expect(object.fetch(new Request("https://noema-external-extension-lifecycle.internal/command", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{", + }))).resolves.toMatchObject({ status: 400 }); + await expect(command(object, { operation: "unknown" })).resolves.toMatchObject({ status: 400 }); + await expect(command(object, { operation: "read_current", stream: null })) + .resolves.toMatchObject({ status: 400 }); + await expect(command(object, { operation: "append", request: null })) + .resolves.toMatchObject({ status: 400 }); + + const substituted = await durableObject(new Storage(), "external-extension-lifecycle:wrong"); + await expect(command(substituted.object, { operation: "append", request: append() })) + .resolves.toMatchObject({ status: 409 }); + await expect(command(substituted.object, { operation: "read_current", stream: stream() })) + .resolves.toMatchObject({ status: 409 }); + await expect(command(substituted.object, { operation: "read_audit", stream: stream() })) + .resolves.toMatchObject({ status: 409 }); + + await command(object, { operation: "append", request: append() }); + await expect(command(object, { + operation: "append", + request: append({ occurred_at: "2026-09-09T09:10:01.000Z" }), + })).resolves.toMatchObject({ status: 409 }); + }); + + it("does not permit a new active transition without a current authority verifier", async () => { + const { object } = await durableObject(); + const states = [ + "discovered", + "source_pinned", + "statically_scanned", + "quarantined", + "capability_reviewed", + "approved_for_pilot", + ] as const; + + for (let index = 0; index < states.length; index += 1) { + const response = await command(object, { + operation: "append", + request: append({ + transition_id: `transition-${String(index + 1).padStart(4, "0")}`, + expected_version: index, + prior_state: index === 0 ? null : states[index - 1], + next_state: states[index], + causation_id: `cause-${String(index + 1).padStart(4, "0")}`, + }), + }); + expect(response.status).toBe(200); + } + + const active = await command(object, { + operation: "append", + request: append({ + transition_id: "transition-0007", + expected_version: 6, + prior_state: "approved_for_pilot", + next_state: "active", + causation_id: "cause-0007", + }), + }); + expect(active.status).toBe(412); + await expect(active.json()).resolves.toEqual({ ok: false, error: "evidence_unavailable" }); + }); + + it("normalizes validation and unexpected storage failures without exposing raw exception detail", async () => { + const { object } = await durableObject(); + const invalid = await command(object, { + operation: "read_current", + stream: { ...stream(), artifact_sha256: "bad" }, + }); + expect(invalid.status).toBe(400); + await expect(invalid.json()).resolves.toEqual({ ok: false, error: "invalid_request" }); + + const storage = new Storage(); + storage.get = async () => { throw new Error("sensitive backend detail"); }; + const failing = await durableObject(storage); + const response = await command(failing.object, { operation: "read_current", stream: stream() }); + expect(response.status).toBe(500); + expect(await response.text()).not.toContain("sensitive backend detail"); + }); +}); diff --git a/test/external-extension-lifecycle-worker-binding.test.ts b/test/external-extension-lifecycle-worker-binding.test.ts new file mode 100644 index 000000000..db6613bbb --- /dev/null +++ b/test/external-extension-lifecycle-worker-binding.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { readNoemaWorkerConfig } from "../scripts/lib/cloudflare-worker-config.mjs"; +import { NoemaExternalExtensionLifecycle as RuntimeLifecycleDurableObject } from "../src/runtime-entrypoint"; +import { NoemaExternalExtensionLifecycle } from "../src/tool-capability/external-extension-lifecycle-durable-object"; + +describe("external-extension lifecycle Worker binding", () => { + it("declares one SQLite Durable Object binding/export for the canonical lifecycle authority", async () => { + const config = await readNoemaWorkerConfig(process.cwd()); + + expect(config.durableObjects).toContainEqual({ + name: "NOEMA_EXTERNAL_EXTENSION_LIFECYCLE", + class_name: "NoemaExternalExtensionLifecycle", + }); + expect(config.exports.NoemaExternalExtensionLifecycle).toEqual({ + type: "durable-object", + storage: "sqlite", + }); + expect(RuntimeLifecycleDurableObject).toBe(NoemaExternalExtensionLifecycle); + }); +}); diff --git a/wrangler.toml b/wrangler.toml index 144194757..eb5937904 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -14,6 +14,10 @@ class_name = "NoemaOidcReplayGuard" name = "NOEMA_WORKFLOW_STATE" class_name = "NoemaWorkflowState" +[[durable_objects.bindings]] +name = "NOEMA_EXTERNAL_EXTENSION_LIFECYCLE" +class_name = "NoemaExternalExtensionLifecycle" + [exports.NoemaRateLimiter] type = "durable-object" storage = "sqlite" @@ -26,6 +30,10 @@ storage = "sqlite" type = "durable-object" storage = "sqlite" +[exports.NoemaExternalExtensionLifecycle] +type = "durable-object" +storage = "sqlite" + [vars] ALLOWED_ISSUER = "https://token.actions.githubusercontent.com" ALLOWED_AUDIENCE = "cwl-noema-review"