From c9dd205c17266708e33c8b7614d5b274ba817d0f Mon Sep 17 00:00:00 2001 From: Nathan Beddoe Date: Wed, 9 Sep 2026 20:34:07 +0200 Subject: [PATCH] Migrate control plane to Effect workflows and Cloudflare SDK - Add Effect-based control-plane workflows, persistence, transport, and lifecycle boundaries - Integrate the Distilled Cloudflare SDK with bounded OAuth and account API access - Expand control-plane effect and bridge-server test coverage in CI --- .github/workflows/ci.yml | 2 + README.md | 3 + control-plane/artifact.ts | 63 +- control-plane/bridge.ts | 527 +++++------ control-plane/catalog.ts | 51 +- control-plane/cloudflare-account.ts | 104 +++ control-plane/cloudflare-sdk.ts | 75 ++ control-plane/cloudflare.ts | 369 ++++---- control-plane/config.ts | 89 +- control-plane/container-api.ts | 284 ++++++ control-plane/crypto.ts | 79 +- control-plane/deployment-api.ts | 1034 --------------------- control-plane/deployment-config.ts | 95 +- control-plane/deployment-errors.ts | 101 ++- control-plane/deployment-values.ts | 34 + control-plane/deployment.ts | 230 +++++ control-plane/domain-api.ts | 367 ++++---- control-plane/domain-errors.ts | 78 ++ control-plane/domain-lifecycle.ts | 107 +++ control-plane/domain-metadata.ts | 11 +- control-plane/domain-operations.ts | 81 ++ control-plane/domain-setup.ts | 361 ++++---- control-plane/domain-workflow.ts | 255 +++--- control-plane/errors.ts | 12 +- control-plane/http-response.ts | 55 ++ control-plane/http.ts | 452 ++++------ control-plane/index.ts | 84 +- control-plane/installation-access.ts | 19 + control-plane/installation-errors.ts | 43 + control-plane/installation-lifecycle.ts | 140 +++ control-plane/installation-metadata.ts | 164 ++-- control-plane/installation-registry.ts | 1045 ++++++++++------------ control-plane/installation-workflow.ts | 692 ++++++-------- control-plane/installations.ts | 244 +++-- control-plane/model-gateway.ts | 45 + control-plane/oauth-authorization.ts | 57 ++ control-plane/operation.ts | 14 +- control-plane/provider-setup.ts | 173 ++-- control-plane/registry-client.ts | 109 +++ control-plane/registry-result.ts | 30 + control-plane/session.ts | 74 +- control-plane/start-installation.ts | 514 ++++++----- control-plane/storage.ts | 30 + control-plane/transport.ts | 100 +++ control-plane/vault-client.ts | 73 ++ control-plane/vault.ts | 535 ++++++----- control-plane/worker-api.ts | 577 ++++++++++++ control-plane/worker-deployment.ts | 158 ++++ control-plane/workflow-boundary.ts | 64 ++ control-plane/workflow-instance.ts | 50 ++ docs/effect-control-plane-review.md | 119 +++ docs/effect-control-plane.md | 156 ++++ package.json | 13 +- pnpm-lock.yaml | 241 +++-- pnpm-workspace.yaml | 2 + tests/bridge-server.test.mjs | 378 ++++++++ tests/control-plane-effect.test.mjs | 200 +++++ tests/control-plane-effect.types.ts | 110 +++ tests/deployment-effect.test.mjs | 418 +++++++++ tests/deployment-network.test.mjs | 9 +- tests/deployment.test.mjs | 43 +- tests/domain-effect.test.mjs | 311 +++++++ tests/fixtures/bridge-control-worker.ts | 132 +-- tests/fixtures/domain-worker.ts | 13 +- tests/fixtures/domain-workflow-worker.ts | 37 +- tests/fixtures/golden-control-worker.ts | 49 +- tests/fixtures/oauth-worker.ts | 44 +- tests/fixtures/orchestrator-worker.ts | 114 ++- tests/fixtures/ownership-worker.ts | 35 +- tests/gateway-provisioning.test.mjs | 44 +- tests/installation-metadata.test.mjs | 440 +++++++++ tests/oauth.test.mjs | 8 +- tests/orchestrator.test.mjs | 31 +- tests/registry-client.types.ts | 98 ++ tsconfig.worker.json | 2 + 75 files changed, 8503 insertions(+), 4497 deletions(-) create mode 100644 control-plane/cloudflare-account.ts create mode 100644 control-plane/cloudflare-sdk.ts create mode 100644 control-plane/container-api.ts delete mode 100644 control-plane/deployment-api.ts create mode 100644 control-plane/deployment-values.ts create mode 100644 control-plane/deployment.ts create mode 100644 control-plane/domain-errors.ts create mode 100644 control-plane/domain-lifecycle.ts create mode 100644 control-plane/domain-operations.ts create mode 100644 control-plane/http-response.ts create mode 100644 control-plane/installation-access.ts create mode 100644 control-plane/installation-errors.ts create mode 100644 control-plane/installation-lifecycle.ts create mode 100644 control-plane/model-gateway.ts create mode 100644 control-plane/oauth-authorization.ts create mode 100644 control-plane/registry-client.ts create mode 100644 control-plane/registry-result.ts create mode 100644 control-plane/storage.ts create mode 100644 control-plane/transport.ts create mode 100644 control-plane/vault-client.ts create mode 100644 control-plane/worker-api.ts create mode 100644 control-plane/worker-deployment.ts create mode 100644 control-plane/workflow-boundary.ts create mode 100644 control-plane/workflow-instance.ts create mode 100644 docs/effect-control-plane-review.md create mode 100644 docs/effect-control-plane.md create mode 100644 tests/bridge-server.test.mjs create mode 100644 tests/control-plane-effect.test.mjs create mode 100644 tests/control-plane-effect.types.ts create mode 100644 tests/deployment-effect.test.mjs create mode 100644 tests/domain-effect.test.mjs create mode 100644 tests/installation-metadata.test.mjs create mode 100644 tests/registry-client.types.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f017b12..3cbb5a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,7 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm typecheck + - run: pnpm test:control-plane-effect - run: pnpm test:config - run: pnpm test:providers - run: pnpm build:release @@ -42,6 +43,7 @@ jobs: - run: pnpm exec playwright install --with-deps chromium - run: pnpm build:control-plane - run: pnpm test:oauth + - run: pnpm test:bridge-server - run: pnpm test:ownership - run: pnpm test:orchestrator - run: pnpm test:installation-status diff --git a/README.md b/README.md index 07ac28d..76cba66 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,9 @@ source, Vite environment variables or `wrangler.jsonc`. The [customer deployment contract](docs/deployment.md) documents required resources, stable identities, account boundaries and the versioned `dist/release` artifact. +The [Effect control plane](docs/effect-control-plane.md) describes backend execution, +native persistence boundaries, and the Distilled Cloudflare SDK's coverage and gaps. + The [v0.1 golden-path gate](docs/golden-path.md) connects native installation, owner login, memory, research, shell execution and unattended scheduled work. diff --git a/control-plane/artifact.ts b/control-plane/artifact.ts index c660a9e..fd43316 100644 --- a/control-plane/artifact.ts +++ b/control-plane/artifact.ts @@ -1,11 +1,12 @@ +import * as Effect from "effect/Effect"; import { z } from "zod"; +import type { Artifact, CatalogEntry } from "./artifact-types.ts"; +import { ArtifactUnavailable } from "./deployment-errors.ts"; import { releaseIdentity, sameRelease, type ReleaseIdentity, } from "./installation-metadata.ts"; -import { fail } from "./deployment-errors.ts"; -import type { Artifact, CatalogEntry } from "./artifact-types.ts"; export const compatibilitySchema = z.strictObject({ applicationSchema: z.literal(1), @@ -13,11 +14,13 @@ export const compatibilitySchema = z.strictObject({ fromArtifacts: z.array(z.string().regex(/^[a-f0-9]{64}$/)).max(32), }); export function verifyUpgradeIdentity(from: ReleaseIdentity, to: Artifact) { - if ( - sameRelease(from, to.identity) || - !to.compatibility.fromArtifacts.includes(from.artifactDigest) - ) - fail("artifact_unavailable"); + return Effect.gen(function* () { + if ( + sameRelease(from, to.identity) || + !to.compatibility.fromArtifacts.includes(from.artifactDigest) + ) + return yield* new ArtifactUnavailable(); + }); } const fileSchema = z.strictObject({ path: z @@ -94,33 +97,39 @@ const deploymentSchema = z.strictObject({ observability: z.strictObject({ enabled: z.literal(false) }), keep_vars: z.literal(true), }); -export async function digest(bytes: ArrayBuffer) { - return [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))] - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); +export function digest(bytes: ArrayBuffer) { + return Effect.gen(function* () { + return [ + ...new Uint8Array( + yield* Effect.promise(() => crypto.subtle.digest("SHA-256", bytes)), + ), + ] + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + }); } const json = (bytes: ArrayBuffer) => JSON.parse(new TextDecoder().decode(bytes)); // Byte verification precedes every external effect. Data modules retain the // original buffers; verification does not accumulate another copy of the release. -export async function loadArtifact( +export function loadArtifact( entry: CatalogEntry, pinned?: ReleaseIdentity, development = false, -): Promise { - try { +) { + return Effect.gen(function* () { const identity = releaseIdentity.parse(entry.identity); if ( (entry.development && !development) || (pinned && !sameRelease(identity, pinned)) ) - fail("artifact_unavailable"); + return yield* new ArtifactUnavailable(); const manifestBytes = entry.files["manifest.json"]; if ( !manifestBytes || - (await digest(manifestBytes)) !== identity.artifactDigest + (yield* digest(manifestBytes)) !== identity.artifactDigest ) - fail("artifact_unavailable"); + return yield* new ArtifactUnavailable(); const manifest = json(manifestBytes); if ( manifest.schemaVersion !== 1 || @@ -132,7 +141,7 @@ export async function loadArtifact( typeof manifest.sourceDirty !== "boolean" || manifest.deployment !== "deployment.json" ) - fail("artifact_unavailable"); + return yield* new ArtifactUnavailable(); const files = z.array(fileSchema).min(3).max(256).parse(manifest.files); const paths = new Set(); let total = 0; @@ -142,22 +151,22 @@ export async function loadArtifact( file.path.split("/").some((p) => !p || p === "." || p === "..") || file.path.endsWith(".map") ) - fail("artifact_unavailable"); + return yield* new ArtifactUnavailable(); paths.add(file.path); total += file.size; const bytes = entry.files[file.path]; if ( !bytes || bytes.byteLength !== file.size || - (await digest(bytes)) !== file.sha256 || + (yield* digest(bytes)) !== file.sha256 || file.path.startsWith("assets/") !== !!file.assetHash ) - fail("artifact_unavailable"); + return yield* new ArtifactUnavailable(); if ( file.path.startsWith("worker/") && (!file.path.endsWith(".js") || file.mime !== "application/javascript") ) - fail("artifact_unavailable"); + return yield* new ArtifactUnavailable(); } if ( files @@ -169,7 +178,7 @@ export async function loadArtifact( !paths.has("worker/index.js") || !paths.has("deployment.json") ) - fail("artifact_unavailable"); + return yield* new ArtifactUnavailable(); const deployment = deploymentSchema.parse( json(entry.files["deployment.json"]), ); @@ -180,9 +189,9 @@ export async function loadArtifact( ) || !bindings.some((b) => b.name === "Sandbox" && b.class_name === "Sandbox") ) - fail("artifact_unavailable"); + return yield* new ArtifactUnavailable(); if (deployment.containers[0].image !== manifest.shell?.image) - fail("artifact_unavailable"); + return yield* new ArtifactUnavailable(); return { identity, files, @@ -190,7 +199,5 @@ export async function loadArtifact( deployment, compatibility: compatibilitySchema.parse(manifest.compatibility), }; - } catch { - return fail("artifact_unavailable"); - } + }).pipe(Effect.catchDefect(() => new ArtifactUnavailable())); } diff --git a/control-plane/bridge.ts b/control-plane/bridge.ts index 9bbd3cf..324476e 100644 --- a/control-plane/bridge.ts +++ b/control-plane/bridge.ts @@ -1,3 +1,4 @@ +import * as Effect from "effect/Effect"; import { loadControlPlaneConfig, loadControlPlaneOrigin, @@ -5,184 +6,202 @@ import { import { requiredSecret } from "../configuration/secrets.ts"; import type { Bindings } from "../configuration/validation.ts"; import { - encode, decode, - hash, - random, - opaque, - LOGIN_PURPOSE, - HEALTH_PURPOSE, DOMAIN_HEALTH_PURPOSE, + encode, + HEALTH_PURPOSE, + LOGIN_PURPOSE, + opaque, + random, type BridgeClaims, } from "../shared/bridge.ts"; import { customDomainOrigin } from "../shared/domain-origin.ts"; import type { Env } from "./config.ts"; +import { hash } from "./crypto.ts"; import { OAuthError } from "./errors.ts"; +import { form, privateResponse } from "./http-response.ts"; +import { type Installation } from "./installation-metadata.ts"; +import { beginOAuth } from "./oauth-authorization.ts"; +import { installationRegistry as registry } from "./registry-client.ts"; import { authenticatedPrincipal, vault } from "./session.ts"; -import { registryName } from "./installation-registry.ts"; -import { unwrap, type Installation } from "./installation-metadata.ts"; +import { bodyJson, withResponse } from "./transport.ts"; import type { BridgeRequest } from "./vault.ts"; -import { beginOAuth, form, privateResponse } from "./http.ts"; -import { domainJson } from "./domain-api.ts"; const denied = () => new OAuthError("forbidden"); -const registry = (env: Env, subject: string) => - env.INSTALLATIONS.get(env.INSTALLATIONS.idFromName(registryName(subject))); -async function owned(env: Env, subject: string, id: string) { - const record = unwrap(await registry(env, subject).get(subject, id)); - // Identity remains useful after deployment-grant expiry, including failed upgrades. - if (!record || !record.installedRelease || !record.resources.runtimeOrigin) - throw denied(); - return record; +function owned(env: Env, subject: string, id: string) { + return Effect.gen(function* () { + const record = yield* registry(env, subject).get(subject, id); + // Identity remains useful after deployment-grant expiry, including failed upgrades. + if (!record || !record.installedRelease || !record.resources.runtimeOrigin) + return yield* Effect.fail(denied()); + return record; + }); } -async function allowedAudience( +function allowedAudience( env: Env, subject: string, id: string, requested?: string, ) { - const record = await owned(env, subject, id); - const management = record.resources.runtimeOrigin!; - const audience = requested ?? management; - if (audience === management) return { record, audience }; - if (customDomainOrigin(audience) !== audience) throw denied(); - const domain = unwrap(await registry(env, subject).getDomain(subject, id)); - if (!domain || domain.status !== "active" || domain.origin !== audience) - throw denied(); - return { record, audience }; + return Effect.gen(function* () { + const record = yield* owned(env, subject, id); + const management = record.resources.runtimeOrigin!; + const audience = requested ?? management; + if (audience === management) return { record, audience }; + if (customDomainOrigin(audience) !== audience) + return yield* Effect.fail(denied()); + const domain = yield* registry(env, subject).getDomain(subject, id); + if (!domain || domain.status !== "active" || domain.origin !== audience) + return yield* Effect.fail(denied()); + return { record, audience }; + }); } -export async function healthDomain( +export function healthDomain( env: Env, record: Installation, origin: string, network: typeof fetch = fetch, ) { - if (customDomainOrigin(origin) !== origin) throw denied(); - const state = random(); - const challenge = random(); - const assertion = await signBridgeAssertion(env, { - aud: origin, - sub: record.ownerSubject, - installationId: record.installationId, - purpose: DOMAIN_HEALTH_PURPOSE, - state, - challenge, + return Effect.gen(function* () { + if (customDomainOrigin(origin) !== origin) + return yield* Effect.fail(denied()); + const state = random(); + const challenge = random(); + const assertion = yield* signBridgeAssertion(env, { + aud: origin, + sub: record.ownerSubject, + installationId: record.installationId, + purpose: DOMAIN_HEALTH_PURPOSE, + state, + challenge, + }); + const result = yield* runtimeJson( + network, + new URL("/auth/domain-health", origin), + assertion, + 10_000, + ); + const expected = { + installationId: record.installationId, + origin, + state, + challenge, + }; + if ( + Object.keys(result).sort().join() !== + Object.keys(expected).sort().join() || + Object.entries(expected).some(([key, value]) => result[key] !== value) + ) + return yield* Effect.fail(denied()); }); - const response = await network( - new Request(new URL("/auth/domain-health", origin), { - method: "POST", - redirect: "manual", - signal: AbortSignal.timeout(10_000), - headers: { Authorization: `Bearer ${assertion}` }, - }), - ); - if (!response.ok || Number(response.headers.get("Content-Length")) > 4096) - throw denied(); - const result = (await domainJson(response)) as Record; - const expected = { - installationId: record.installationId, - origin, - state, - challenge, - }; - if ( - Object.keys(result).sort().join() !== Object.keys(expected).sort().join() || - Object.entries(expected).some(([key, value]) => result[key] !== value) - ) - throw denied(); } -export async function signBridgeAssertion( +export function signBridgeAssertion( env: Env, claims: Omit, ) { - const config = loadControlPlaneConfig(env).config; - if (!config.bridge) throw denied(); - const privateKey = requiredSecret( - env as unknown as Bindings, - "FLAREBOT_BRIDGE_SIGNING_KEY", - 1, - ).reveal(); - const key = await crypto.subtle.importKey( - "pkcs8", - decode(privateKey), - { name: "Ed25519" }, - false, - ["sign"], - ); - const now = Math.floor(Date.now() / 1000); - const header = encode( - new TextEncoder().encode( - JSON.stringify({ alg: "EdDSA", kid: config.bridge.keyId, typ: "JWT" }), - ), - ); - const body = encode( - new TextEncoder().encode( - JSON.stringify({ - ...claims, - iss: config.publicOrigin, - iat: now, - exp: now + 60, - jti: random(), - }), - ), - ); - const input = `${header}.${body}`; - const signature = new Uint8Array( - await crypto.subtle.sign("Ed25519", key, new TextEncoder().encode(input)), - ); - // Fail setup when the deployed secret does not match its deliberately pinned key. - const publicKey = await crypto.subtle.importKey( - "raw", - decode(config.bridge.publicKey), - { name: "Ed25519" }, - false, - ["verify"], - ); - if ( - !(await crypto.subtle.verify( - "Ed25519", - publicKey, - signature, - new TextEncoder().encode(input), - )) - ) - throw denied(); - return `${input}.${encode(signature)}`; + return Effect.gen(function* () { + const config = loadControlPlaneConfig(env).config; + if (!config.bridge) return yield* Effect.fail(denied()); + const bridge = config.bridge; + const privateKey = requiredSecret( + env as unknown as Bindings, + "FLAREBOT_BRIDGE_SIGNING_KEY", + 1, + ).reveal(); + const key = yield* Effect.promise(() => + crypto.subtle.importKey( + "pkcs8", + decode(privateKey), + { name: "Ed25519" }, + false, + ["sign"], + ), + ); + const now = Math.floor(Date.now() / 1000); + const header = encode( + new TextEncoder().encode( + JSON.stringify({ alg: "EdDSA", kid: bridge.keyId, typ: "JWT" }), + ), + ); + const body = encode( + new TextEncoder().encode( + JSON.stringify({ + ...claims, + iss: config.publicOrigin, + iat: now, + exp: now + 60, + jti: random(), + }), + ), + ); + const input = `${header}.${body}`; + const signature = new Uint8Array( + yield* Effect.promise(() => + crypto.subtle.sign("Ed25519", key, new TextEncoder().encode(input)), + ), + ); + // Fail setup when the deployed secret does not match its deliberately pinned key. + const publicKey = yield* Effect.promise(() => + crypto.subtle.importKey( + "raw", + decode(bridge.publicKey), + { name: "Ed25519" }, + false, + ["verify"], + ), + ); + if ( + !(yield* Effect.promise(() => + crypto.subtle.verify( + "Ed25519", + publicKey, + signature, + new TextEncoder().encode(input), + ), + )) + ) + return yield* Effect.fail(denied()); + return `${input}.${encode(signature)}`; + }).pipe(Effect.catchDefect(() => denied())); } -async function issueCode(env: Env, subject: string, input: BridgeRequest) { - if (input.expiresAt <= Date.now()) throw denied(); - const { audience } = await allowedAudience( - env, - subject, - input.installationId, - input.audience, - ); - const code = random(); - await vault(env, "code", await hash(code)).createCode({ - ...input, - subject, - audience, - expiresAt: Date.now() + 60_000, +function issueCode(env: Env, subject: string, input: BridgeRequest) { + return Effect.gen(function* () { + if (input.expiresAt <= Date.now()) return yield* Effect.fail(denied()); + const { audience } = yield* allowedAudience( + env, + subject, + input.installationId, + input.audience, + ); + const code = random(); + yield* vault(env, "code", yield* hash(code)).createCode({ + ...input, + subject, + audience, + expiresAt: Date.now() + 60_000, + }); + const callback = new URL("/auth/callback", audience); + callback.search = new URLSearchParams({ + code, + state: input.state, + }).toString(); + return callback.href; }); - const callback = new URL("/auth/callback", audience); - callback.search = new URLSearchParams({ - code, - state: input.state, - }).toString(); - return callback.href; } -export async function resumeBridge( +export function resumeBridge( env: Env, subject: string, ref: string, bindingHash: string, ) { - const input = await vault(env, "continuation", ref).claimContinuation( - bindingHash, - ); - if (!input) throw denied(); - return issueCode(env, subject, input); + return Effect.gen(function* () { + const input = yield* vault(env, "continuation", ref).claimContinuation( + bindingHash, + ); + if (!input) return yield* Effect.fail(denied()); + return yield* issueCode(env, subject, input); + }); } function exact(params: URLSearchParams, names: string[]) { if ( @@ -192,23 +211,21 @@ function exact(params: URLSearchParams, names: string[]) { throw denied(); return Object.fromEntries(names.map((name) => [name, params.get(name)!])); } -export async function handleBridge( - request: Request, - env: Env, -): Promise { - const url = new URL(request.url); - if (!["/auth/bridge", "/auth/bridge/exchange"].includes(url.pathname)) - return null; - try { +export function handleBridge(request: Request, env: Env) { + return Effect.gen(function* () { + const url = new URL(request.url); + if (!["/auth/bridge", "/auth/bridge/exchange"].includes(url.pathname)) + return null; + if (url.origin !== loadControlPlaneOrigin(env) || request.url.length > 2048) - throw denied(); + return yield* Effect.fail(denied()); if (url.pathname === "/auth/bridge" && request.method === "GET") { if ( request.headers.has("Upgrade") || (request.headers.has("Sec-Fetch-Mode") && request.headers.get("Sec-Fetch-Mode") !== "navigate") ) - throw denied(); + return yield* Effect.fail(denied()); const names = url.searchParams.has("audience") ? ["installationId", "state", "challenge", "audience"] : ["installationId", "state", "challenge"]; @@ -218,27 +235,24 @@ export async function handleBridge( !opaque(input.state) || !opaque(input.challenge) ) - throw denied(); + return yield* Effect.fail(denied()); const pending = { ...input, expiresAt: Date.now() + 600_000, } as BridgeRequest; - let principal; - try { - principal = await authenticatedPrincipal(request, env); - } catch (error) { - if ( - !(error instanceof OAuthError) || - error.code !== "reauthorization_required" - ) - throw error; - return beginOAuth(request, env, pending); - } + const principal = yield* authenticatedPrincipal(request, env).pipe( + Effect.catch((error) => + error.code === "reauthorization_required" + ? Effect.succeed(null) + : Effect.fail(error), + ), + ); + if (!principal) return yield* beginOAuth(request, env, pending); return privateResponse( new Response(null, { status: 303, headers: { - Location: await issueCode(env, principal.subject, pending), + Location: yield* issueCode(env, principal.subject, pending), }, }), ); @@ -254,8 +268,8 @@ export async function handleBridge( request.headers.has("Cookie") || request.headers.has("Sec-Fetch-Site") ) - throw denied(); - const input = exact(await form(request), [ + return yield* Effect.fail(denied()); + const input = exact(yield* form(request), [ "code", "verifier", "installationId", @@ -268,22 +282,24 @@ export async function handleBridge( !opaque(input.state) || !/^[a-f0-9]{32}$/.test(input.installationId) ) - throw denied(); - const challenge = await hash(input.verifier); - const code = await vault(env, "code", await hash(input.code)).claimCode({ - installationId: input.installationId, - audience: input.audience, - state: input.state, - challenge, - }); - if (!code) throw denied(); - await allowedAudience( + return yield* Effect.fail(denied()); + const challenge = yield* hash(input.verifier); + const code = yield* vault(env, "code", yield* hash(input.code)).claimCode( + { + installationId: input.installationId, + audience: input.audience, + state: input.state, + challenge, + }, + ); + if (!code) return yield* Effect.fail(denied()); + yield* allowedAudience( env, code.subject, code.installationId, code.audience, ); - const assertion = await signBridgeAssertion(env, { + const assertion = yield* signBridgeAssertion(env, { aud: code.audience, sub: code.subject, installationId: code.installationId, @@ -293,89 +309,94 @@ export async function handleBridge( }); return privateResponse(Response.json({ assertion })); } - throw denied(); - } catch { - return privateResponse( - Response.json({ error: "bridge_denied" }, { status: 403 }), - ); - } + return yield* Effect.fail(denied()); + }).pipe( + Effect.catch(() => bridgeDenied()), + Effect.catchDefect(() => bridgeDenied()), + ); } // Tokens stay inside this callback; callers may persist only successful metadata. -export async function healthInstallation( +export function healthInstallation( env: Env, record: Installation, network: typeof fetch = fetch, deployedOperationId: string | null = record.operationId, -): Promise { - if ( - !record.resources.runtimeOrigin || - !record.desiredRelease || - !deployedOperationId - ) - throw denied(); - const state = random(); - const challenge = random(); - const release = record.desiredRelease; - const assertion = await signBridgeAssertion(env, { - aud: record.resources.runtimeOrigin, - sub: record.ownerSubject, - installationId: record.installationId, - purpose: HEALTH_PURPOSE, - state, - challenge, - operationId: deployedOperationId, - artifactDigest: release.artifactDigest, - version: release.version, - }); - const response = await network( - new Request( +) { + return Effect.gen(function* () { + if ( + !record.resources.runtimeOrigin || + !record.desiredRelease || + !deployedOperationId + ) + return yield* Effect.fail(denied()); + const state = random(); + const challenge = random(); + const release = record.desiredRelease; + const assertion = yield* signBridgeAssertion(env, { + aud: record.resources.runtimeOrigin, + sub: record.ownerSubject, + installationId: record.installationId, + purpose: HEALTH_PURPOSE, + state, + challenge, + operationId: deployedOperationId, + artifactDigest: release.artifactDigest, + version: release.version, + }); + const result = yield* runtimeJson( + network, new URL("/auth/bootstrap-health", record.resources.runtimeOrigin), - { - method: "POST", - redirect: "manual", - signal: AbortSignal.timeout(60_000), - headers: { Authorization: `Bearer ${assertion}` }, - }, - ), + assertion, + 60_000, + ); + const expected = { + installationId: record.installationId, + operationId: deployedOperationId, + artifactDigest: release.artifactDigest, + version: release.version, + state, + challenge, + identity: "ready", + nativeParent: "ready", + sandbox: "booted-and-destroyed", + bindings: "present", + assets: "ready", + authentication: "required", + }; + if ( + JSON.stringify(Object.keys(result).sort()) !== + JSON.stringify(Object.keys(expected).sort()) || + Object.entries(expected).some(([key, value]) => result[key] !== value) + ) + return yield* Effect.fail(denied()); + }); +} + +function runtimeJson( + network: typeof fetch, + url: URL, + assertion: string, + timeout: number, +) { + return withResponse( + network, + url, + { method: "POST", headers: { Authorization: `Bearer ${assertion}` } }, + timeout, + denied(), + (response) => + Effect.gen(function* () { + if (!response.ok) return yield* denied(); + const value = yield* bodyJson(response, 4096, denied()); + if (!value || typeof value !== "object" || Array.isArray(value)) + return yield* denied(); + return value as Record; + }), ); - if (!response.ok || Number(response.headers.get("Content-Length")) > 4096) - throw denied(); - const reader = response.body?.getReader(); - if (!reader) throw denied(); - let body = ""; - let size = 0; - const decoder = new TextDecoder(); - while (true) { - const chunk = await reader.read(); - if (chunk.done) break; - size += chunk.value.byteLength; - if (size > 4096) { - await reader.cancel(); - throw denied(); - } - body += decoder.decode(chunk.value, { stream: true }); - } - body += decoder.decode(); - const result = JSON.parse(body); - const expected = { - installationId: record.installationId, - operationId: deployedOperationId, - artifactDigest: release.artifactDigest, - version: release.version, - state, - challenge, - identity: "ready", - nativeParent: "ready", - sandbox: "booted-and-destroyed", - bindings: "present", - assets: "ready", - authentication: "required", - }; - if ( - JSON.stringify(Object.keys(result).sort()) !== - JSON.stringify(Object.keys(expected).sort()) || - Object.entries(expected).some(([key, value]) => result[key] !== value) - ) - throw denied(); } + +const bridgeDenied = () => + Effect.succeed( + privateResponse(Response.json({ error: "bridge_denied" }, { status: 403 })), + ); diff --git a/control-plane/catalog.ts b/control-plane/catalog.ts index 4fbd23f..3a0bb73 100644 --- a/control-plane/catalog.ts +++ b/control-plane/catalog.ts @@ -1,27 +1,34 @@ +import * as Effect from "effect/Effect"; import entries from "../dist/catalog/catalog.ts"; -import { fail } from "./deployment-errors.ts"; +import type { Artifact } from "./artifact-types.ts"; import { loadArtifact } from "./artifact.ts"; +import { ArtifactUnavailable } from "./deployment-errors.ts"; import { sameRelease, type ReleaseIdentity } from "./installation-metadata.ts"; -const validated = new Map>(); -export const customerArtifact = ( + +export type ArtifactLoader = ( + pinned?: ReleaseIdentity, +) => Effect.Effect; + +// Cache only successfully verified immutable artifacts, never a running fiber or +// rejected Promise owned by another request's lifetime. +const validated = new Map(); +export function customerArtifact( pinned?: ReleaseIdentity, development = false, -) => { - const entry = entries.at(-1); - if ( - entries.length !== 1 || - !entry || - (pinned && !sameRelease(entry.identity, pinned)) - ) - fail("artifact_unavailable"); - const key = `${entry.identity.artifactDigest}:${development}`; - let value = validated.get(key); - if (!value) { - value = loadArtifact(entry, pinned, development).catch((error) => { - validated.delete(key); - throw error; - }); - validated.set(key, value); - } - return value; -}; +) { + return Effect.gen(function* () { + const entry = entries.at(-1); + if ( + entries.length !== 1 || + !entry || + (pinned && !sameRelease(entry.identity, pinned)) + ) + return yield* new ArtifactUnavailable(); + const key = entry.identity.artifactDigest + ":" + development; + const cached = validated.get(key); + if (cached) return cached; + const artifact = yield* loadArtifact(entry, pinned, development); + validated.set(key, artifact); + return artifact; + }); +} diff --git a/control-plane/cloudflare-account.ts b/control-plane/cloudflare-account.ts new file mode 100644 index 0000000..d299443 --- /dev/null +++ b/control-plane/cloudflare-account.ts @@ -0,0 +1,104 @@ +import type { CloudflareOpContext } from "@distilled.cloud/cloudflare"; +import { Forbidden, Unauthorized } from "@distilled.cloud/cloudflare/Errors"; +import * as Effect from "effect/Effect"; +import { cloudflare } from "./cloudflare-sdk.ts"; +import { + AccountDenied, + ReauthorizationRequired, + TemporarilyUnavailable, +} from "./deployment-errors.ts"; +import { object } from "./deployment-values.ts"; +import { bodyBytes, withResponse } from "./transport.ts"; + +export class CloudflareAccount { + constructor( + private readonly accessToken: string, + readonly accountId: string, + private readonly network: typeof fetch = fetch, + ) {} + sdk(operation: Effect.Effect) { + return cloudflare(operation, this.accessToken, this.network).pipe( + Effect.mapError((error) => + error instanceof Unauthorized + ? new ReauthorizationRequired() + : error instanceof Forbidden + ? new AccountDenied() + : new TemporarilyUnavailable(), + ), + ); + } + response( + path: string, + init: RequestInit, + read: (response: Response) => Effect.Effect, + ) { + const url = `https://api.cloudflare.com/client/v4/accounts/${this.accountId}/${path}`; + const headers = new Headers(init.headers); + headers.set("Authorization", `Bearer ${this.accessToken}`); + return withResponse( + this.network, + url, + { + ...init, + headers, + }, + 60_000, + new TemporarilyUnavailable(), + (response) => + Effect.gen(function* () { + if (response.status === 401) + return yield* new ReauthorizationRequired(); + if (response.status === 403) return yield* new AccountDenied(); + return yield* read(response); + }), + ); + } + request( + path: string, + { + method = "GET", + body, + allowNotFound = false, + }: { + method?: "GET" | "POST" | "PUT" | "PATCH"; + body?: unknown; + allowNotFound?: boolean; + } = {}, + ) { + return this.response( + path, + { + method, + ...(body === undefined + ? {} + : { + body: body instanceof FormData ? body : JSON.stringify(body), + ...(body instanceof FormData + ? {} + : { headers: { "Content-Type": "application/json" } }), + }), + }, + (response) => + Effect.gen(function* () { + if (response.status === 404 && allowNotFound) return null; + const bytes = yield* bodyBytes( + response, + 2 * 1024 * 1024, + new TemporarilyUnavailable(), + ); + const envelope: unknown = yield* Effect.try({ + try: () => JSON.parse(new TextDecoder().decode(bytes)), + catch: () => new TemporarilyUnavailable(), + }); + if ( + !response.ok || + !object(envelope) || + envelope.success !== true || + !("result" in envelope) + ) + return yield* new TemporarilyUnavailable(); + return envelope.result; + }), + ); + } +} diff --git a/control-plane/cloudflare-sdk.ts b/control-plane/cloudflare-sdk.ts new file mode 100644 index 0000000..bb886de --- /dev/null +++ b/control-plane/cloudflare-sdk.ts @@ -0,0 +1,75 @@ +import { + Credentials, + oauthCredentials, + Retry, + type CloudflareOpContext, +} from "@distilled.cloud/cloudflare"; +import * as Effect from "effect/Effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { bodyBytes, withResponse } from "./transport.ts"; + +// No process credentials, refresh cache or nested retries. The calling request +// or native Workflow attempt has already resolved its own authorized grant. +export function cloudflare( + operation: Effect.Effect, + accessToken: string, + network: typeof fetch, + timeout = 60_000, + limit = 2 * 1024 * 1024, +) { + const boundedFetch: typeof fetch = (input, init) => + Effect.runPromise( + withResponse( + network, + input, + init ?? {}, + timeout, + new Error("Cloudflare transport unavailable"), + (response) => + Effect.gen(function* () { + if (response.status >= 300 && response.status < 400) + return yield* Effect.die( + new Error("Cloudflare redirect rejected"), + ); + if (!response.body) return response; + const bytes = yield* bodyBytes( + response, + limit, + new Error("Cloudflare response unavailable"), + ); + // The SDK also accepts bare JSON for other services. These control-plane + // operations require Cloudflare's successful v4 envelope explicitly. + if (response.ok) { + yield* Effect.try({ + try: () => { + const envelope: unknown = JSON.parse( + new TextDecoder().decode(bytes), + ); + if ( + !envelope || + typeof envelope !== "object" || + !("success" in envelope) || + envelope.success !== true || + !("result" in envelope) + ) + throw new Error("Invalid Cloudflare envelope"); + }, + catch: () => new Error("Cloudflare response unavailable"), + }); + } + return new Response(bytes, response); + }), + ), + { signal: init?.signal ?? undefined }, + ); + return operation.pipe( + Retry.none, + Effect.provideService( + Credentials, + Effect.succeed(oauthCredentials({ accessToken })), + ), + Effect.provide(FetchHttpClient.layer), + Effect.provideService(FetchHttpClient.Fetch, boundedFetch), + Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }), + ); +} diff --git a/control-plane/cloudflare.ts b/control-plane/cloudflare.ts index 4cdc620..2751980 100644 --- a/control-plane/cloudflare.ts +++ b/control-plane/cloudflare.ts @@ -1,5 +1,10 @@ +import { Forbidden, Unauthorized } from "@distilled.cloud/cloudflare/Errors"; +import { listAccounts } from "@distilled.cloud/cloudflare/accounts"; +import * as Effect from "effect/Effect"; +import { cloudflare } from "./cloudflare-sdk.ts"; import type { OAuthConfiguration } from "./config.ts"; import { OAuthError } from "./errors.ts"; +import { bodyJson, withResponse } from "./transport.ts"; export const endpoints = Object.freeze({ authorization: "https://dash.cloudflare.com/oauth2/auth", token: "https://dash.cloudflare.com/oauth2/token", @@ -10,78 +15,45 @@ export const endpoints = Object.freeze({ // Test fixtures replace only this network boundary, never production URLs/config. export type CloudflareFetch = typeof fetch; const MAX_RESPONSE = 256 * 1024; -async function requestJson( +function requestJson( network: CloudflareFetch, url: string, init: RequestInit, - kind: "token" | "identity" | "accounts", + kind: "token" | "identity", ) { - let response: Response; - let value: unknown; - try { - response = await network(url, { - ...init, - redirect: "manual", - signal: AbortSignal.timeout(10_000), - }); - if (!response.body) { - if (response.ok) throw new Error(); - value = {}; - } else { - const reader = response.body.getReader(); - const chunks: Uint8Array[] = []; - let size = 0; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - size += value.byteLength; - if (size > MAX_RESPONSE) { - await reader.cancel(); - throw new Error(); - } - chunks.push(value); - } - const bytes = new Uint8Array(size); - let offset = 0; - for (const chunk of chunks) { - bytes.set(chunk, offset); - offset += chunk.byteLength; - } - try { - value = JSON.parse(new TextDecoder().decode(bytes)); - } catch { - if (response.ok) throw new Error(); - value = {}; - } - } - } catch { - throw new OAuthError("temporarily_unavailable"); - } - const body = - value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; - if (!response.ok || body.success === false || body.error) { - if ( - kind === "token" && - ["invalid_client", "invalid_scope", "unauthorized_client"].includes( - String(body.error), - ) - ) - throw new OAuthError("oauth_capability_unavailable"); - if ( - response.status === 401 || - body.error === "invalid_grant" || - body.error === "invalid_token" - ) - throw new OAuthError("reauthorization_required"); - if (response.status === 403) - throw new OAuthError( - kind === "accounts" ? "account_denied" : "oauth_capability_unavailable", + const unavailable = new OAuthError("temporarily_unavailable"); + return withResponse(network, url, init, 10_000, unavailable, (response) => + Effect.gen(function* () { + const value = yield* bodyJson(response, MAX_RESPONSE, unavailable).pipe( + Effect.catch((error) => + response.ok ? Effect.fail(error) : Effect.succeed({}), + ), ); - throw new OAuthError("temporarily_unavailable"); - } - return body; + const body = + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; + if (!response.ok || body.success === false || body.error) { + if ( + kind === "token" && + ["invalid_client", "invalid_scope", "unauthorized_client"].includes( + String(body.error), + ) + ) + return yield* new OAuthError("oauth_capability_unavailable"); + if ( + response.status === 401 || + body.error === "invalid_grant" || + body.error === "invalid_token" + ) + return yield* new OAuthError("reauthorization_required"); + if (response.status === 403) + return yield* new OAuthError("oauth_capability_unavailable"); + return yield* unavailable; + } + return body; + }), + ); } function clientAuthentication( config: OAuthConfiguration, @@ -104,160 +76,163 @@ function clientAuthentication( } return headers; } -export async function exchange( +export function exchange( config: OAuthConfiguration, code: string, verifier: string, network: CloudflareFetch = fetch, ) { - const form = new URLSearchParams({ - grant_type: "authorization_code", - code, - redirect_uri: config.oauthRedirectUri, - code_verifier: verifier, - }); - const result = await requestJson( - network, - endpoints.token, - { - method: "POST", - headers: clientAuthentication(config, form), - body: form.toString(), - }, - "token", - ); - if ( - typeof result.access_token !== "string" || - !result.access_token || - result.access_token.length > 16_384 || - /[\s\u0000-\u001f]/.test(result.access_token) || - String(result.token_type).toLowerCase() !== "bearer" || - typeof result.expires_in !== "number" || - !Number.isFinite(result.expires_in) || - result.expires_in < 1 - ) - throw new OAuthError("oauth_capability_unavailable"); - // OAuth permits omitted scope only when identical to the requested scope. - const scopes = - result.scope === undefined - ? config.oauthScopes - : typeof result.scope === "string" - ? result.scope.split(" ").filter(Boolean) - : []; - if (config.oauthScopes.some((scope) => !scopes.includes(scope))) - throw new OAuthError("oauth_capability_unavailable"); - return { - accessToken: result.access_token, - expiresAt: Date.now() + Math.min(result.expires_in * 1000, 60 * 60_000), - scopes: [...scopes], - }; -} -export async function subject( - accessToken: string, - network: CloudflareFetch = fetch, -) { - const result = await requestJson( - network, - endpoints.userinfo, - { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: "application/json", + return Effect.gen(function* () { + const form = new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: config.oauthRedirectUri, + code_verifier: verifier, + }); + const result = yield* requestJson( + network, + endpoints.token, + { + method: "POST", + headers: clientAuthentication(config, form), + body: form.toString(), }, - }, - "identity", - ); - if ( - typeof result.sub !== "string" || - !result.sub.trim() || - result.sub.length > 512 || - /[\u0000-\u001f\u007f]/.test(result.sub) - ) - throw new OAuthError("oauth_capability_unavailable"); - // ID tokens are never consumed. Identity is the HTTPS UserInfo subject only. - return result.sub; -} -export interface Account { - id: string; - name: string; + "token", + ); + if ( + typeof result.access_token !== "string" || + !result.access_token || + result.access_token.length > 16_384 || + /[\s\u0000-\u001f]/.test(result.access_token) || + String(result.token_type).toLowerCase() !== "bearer" || + typeof result.expires_in !== "number" || + !Number.isFinite(result.expires_in) || + result.expires_in < 1 + ) + return yield* new OAuthError("oauth_capability_unavailable"); + // OAuth permits omitted scope only when identical to the requested scope. + const scopes = + result.scope === undefined + ? config.oauthScopes + : typeof result.scope === "string" + ? result.scope.split(" ").filter(Boolean) + : []; + if (config.oauthScopes.some((scope) => !scopes.includes(scope))) + return yield* new OAuthError("oauth_capability_unavailable"); + return { + accessToken: result.access_token, + expiresAt: Date.now() + Math.min(result.expires_in * 1000, 60 * 60_000), + scopes: [...scopes], + }; + }); } -export async function accounts( - accessToken: string, - network: CloudflareFetch = fetch, -): Promise { - const found = new Map(); - for (let page = 1; page <= 20; page++) { - const result = await requestJson( +export function subject(accessToken: string, network: CloudflareFetch = fetch) { + return Effect.gen(function* () { + const result = yield* requestJson( network, - `${endpoints.accounts}?page=${page}&per_page=50`, + endpoints.userinfo, { headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json", }, }, - "accounts", + "identity", ); if ( - result.success !== true || - !Array.isArray(result.result) || - result.result.length > 50 + typeof result.sub !== "string" || + !result.sub.trim() || + result.sub.length > 512 || + /[\u0000-\u001f\u007f]/.test(result.sub) ) - throw new OAuthError("temporarily_unavailable"); - for (const account of result.result) { + return yield* new OAuthError("oauth_capability_unavailable"); + // ID tokens are never consumed. Identity is the HTTPS UserInfo subject only. + return result.sub; + }); +} +export interface Account { + id: string; + name: string; +} +export function accounts( + accessToken: string, + network: CloudflareFetch = fetch, +) { + return Effect.gen(function* () { + const found = new Map(); + for (let page = 1; page <= 20; page++) { + const result = yield* cloudflare( + listAccounts({ page, perPage: 50 }), + accessToken, + network, + 10_000, + MAX_RESPONSE, + ).pipe( + Effect.mapError( + (error) => + new OAuthError( + error instanceof Unauthorized + ? "reauthorization_required" + : error instanceof Forbidden + ? "account_denied" + : "temporarily_unavailable", + ), + ), + ); + if (!Array.isArray(result.result) || result.result.length > 50) + return yield* new OAuthError("temporarily_unavailable"); + for (const account of result.result) { + if ( + !account || + typeof account.id !== "string" || + !/^[a-f0-9]{32}$/.test(account.id) || + typeof account.name !== "string" || + !account.name.trim() || + account.name.length > 512 + ) + return yield* new OAuthError("temporarily_unavailable"); + found.set(account.id, { id: account.id, name: account.name }); + } + const total = result.resultInfo?.totalPages; if ( - !account || - typeof account.id !== "string" || - !/^[a-f0-9]{32}$/.test(account.id) || - typeof account.name !== "string" || - !account.name.trim() || - account.name.length > 512 + total !== undefined && + (!Number.isInteger(total) || + (Number(total) < page && + !(page === 1 && total === 0 && result.result.length === 0)) || + Number(total) > 20) ) - throw new OAuthError("temporarily_unavailable"); - found.set(account.id, { id: account.id, name: account.name }); + return yield* new OAuthError("temporarily_unavailable"); + if ( + total === 0 || + total === page || + (!total && result.result.length < 50) + ) + return [...found.values()]; } - const info = result.result_info as { total_pages?: unknown } | undefined; - if ( - info?.total_pages !== undefined && - (!Number.isInteger(info.total_pages) || - (Number(info.total_pages) < page && - !( - page === 1 && - info.total_pages === 0 && - result.result.length === 0 - )) || - Number(info.total_pages) > 20) - ) - throw new OAuthError("temporarily_unavailable"); - if ( - info?.total_pages === 0 || - info?.total_pages === page || - (!info?.total_pages && result.result.length < 50) - ) - return [...found.values()]; - } - throw new OAuthError("temporarily_unavailable"); + return yield* new OAuthError("temporarily_unavailable"); + }); } -export async function revoke( +export function revoke( config: OAuthConfiguration, accessToken: string, network: CloudflareFetch = fetch, ) { - const form = new URLSearchParams({ - token: accessToken, - token_type_hint: "access_token", - }); - try { - const response = await network(endpoints.revoke, { - method: "POST", - headers: clientAuthentication(config, form), - body: form.toString(), - redirect: "manual", - signal: AbortSignal.timeout(10_000), + return Effect.suspend(() => { + const form = new URLSearchParams({ + token: accessToken, + token_type_hint: "access_token", }); - await response.body?.cancel(); - return response.ok; - } catch { - return false; - } + return withResponse( + network, + endpoints.revoke, + { + method: "POST", + headers: clientAuthentication(config, form), + body: form.toString(), + }, + 10_000, + new OAuthError("temporarily_unavailable"), + (response) => Effect.succeed(response.ok), + ); + }).pipe(Effect.catchCause(() => Effect.succeed(false))); } diff --git a/control-plane/config.ts b/control-plane/config.ts index 63900c9..f9f188b 100644 --- a/control-plane/config.ts +++ b/control-plane/config.ts @@ -1,12 +1,13 @@ +import * as Effect from "effect/Effect"; import { loadControlPlaneConfig, loadControlPlaneSecrets, type ControlPlaneConfigBindings, } from "../configuration/control-plane.ts"; +import release from "../package.json"; import { OAuthError } from "./errors.ts"; -import type { AuthVault } from "./vault.ts"; import type { InstallationRegistry } from "./installation-registry.ts"; -import release from "../package.json"; +import type { AuthVault } from "./vault.ts"; export interface Env extends ControlPlaneConfigBindings { INSTALLATION_WORKFLOW: Workflow< import("./installation-workflow.ts").InstallationParams @@ -16,41 +17,51 @@ export interface Env extends ControlPlaneConfigBindings { ASSETS: Fetcher; } export function configuration(env: Env) { - let settings; - let secrets; - try { - settings = loadControlPlaneConfig(env); - secrets = loadControlPlaneSecrets(env); - } catch { - throw new OAuthError("oauth_setup_required"); - } - const config = settings.config; - const manifest = config.oauthCapabilities; - const method = config.oauthTokenAuthMethod; - if (!manifest || !method) - throw new OAuthError("oauth_capability_unavailable"); - const same = (a: readonly string[], b: readonly string[]) => - a.length === b.length && a.every((s) => b.includes(s)); - if ( - manifest.artifactVersion !== release.version || - manifest.registeredClient.clientId !== config.oauthClientId || - manifest.registeredClient.redirectUri !== config.oauthRedirectUri || - manifest.registeredClient.tokenAuthMethod !== method || - !config.oauthScopes.every((scope) => - manifest.registeredClient.scopeIds.includes(scope), - ) || - !same( - manifest.scopes.map((s) => s.id), - config.oauthScopes, - ) - ) - throw new OAuthError("oauth_capability_unavailable"); - if ( - new URL(config.oauthRedirectUri).pathname !== "/auth/callback" || - (method !== "none" && !secrets.oauthClientSecret) || - (method === "none" && secrets.oauthClientSecret) - ) - throw new OAuthError("oauth_setup_required"); - return { ...config, oauthTokenAuthMethod: method, secrets }; + return Effect.try({ + try: () => { + let settings; + let secrets; + try { + settings = loadControlPlaneConfig(env); + secrets = loadControlPlaneSecrets(env); + } catch { + throw new OAuthError("oauth_setup_required"); + } + const config = settings.config; + const manifest = config.oauthCapabilities; + const method = config.oauthTokenAuthMethod; + if (!manifest || !method) + throw new OAuthError("oauth_capability_unavailable"); + const same = (a: readonly string[], b: readonly string[]) => + a.length === b.length && a.every((s) => b.includes(s)); + if ( + manifest.artifactVersion !== release.version || + manifest.registeredClient.clientId !== config.oauthClientId || + manifest.registeredClient.redirectUri !== config.oauthRedirectUri || + manifest.registeredClient.tokenAuthMethod !== method || + !config.oauthScopes.every((scope) => + manifest.registeredClient.scopeIds.includes(scope), + ) || + !same( + manifest.scopes.map((s) => s.id), + config.oauthScopes, + ) + ) + throw new OAuthError("oauth_capability_unavailable"); + if ( + new URL(config.oauthRedirectUri).pathname !== "/auth/callback" || + (method !== "none" && !secrets.oauthClientSecret) || + (method === "none" && secrets.oauthClientSecret) + ) + throw new OAuthError("oauth_setup_required"); + return { ...config, oauthTokenAuthMethod: method, secrets }; + }, + catch: (error) => + error instanceof OAuthError + ? error + : new OAuthError("oauth_setup_required"), + }); } -export type OAuthConfiguration = ReturnType; +export type OAuthConfiguration = Effect.Success< + ReturnType +>; diff --git a/control-plane/container-api.ts b/control-plane/container-api.ts new file mode 100644 index 0000000..c32af1c --- /dev/null +++ b/control-plane/container-api.ts @@ -0,0 +1,284 @@ +import { createContainerApplication } from "@distilled.cloud/cloudflare/containers"; +import * as Effect from "effect/Effect"; +import type { Artifact } from "./artifact-types.ts"; +import { CloudflareAccount } from "./cloudflare-account.ts"; +import { + RecoveryRequired, + ResourceConflict, + TemporarilyUnavailable, +} from "./deployment-errors.ts"; +import { eq, fingerprint, id, object } from "./deployment-values.ts"; +import type { Installation } from "./installation-metadata.ts"; +export interface Application extends Record { + id: string; + name: string; + configuration: Record & { + image: string; + instance_type: string; + }; + durable_objects: Record & { namespace_id: string | null }; +} +function normalizedConfiguration(value: unknown) { + if (!object(value) || typeof value.image !== "string") + throw new ResourceConflict(); + const config = { ...value }; + const disk = config.disk; + const lite = + config.vcpu === 0.0625 && + config.memory_mib === 256 && + object(disk) && + disk.size_mb === 2000; + if (lite) { + config.instance_type = "lite"; + for (const key of ["vcpu", "memory", "memory_mib"]) delete config[key]; + if (Object.keys(disk).every((key) => key === "size_mb")) delete config.disk; + } + if (typeof config.instance_type !== "string") throw new ResourceConflict(); + return { ...config, image: value.image, instance_type: config.instance_type }; +} + +export class ContainerAPI { + constructor( + private readonly client: CloudflareAccount, + private readonly record: Installation, + ) {} + private application(value: unknown) { + return Effect.try({ + try: (): Application => { + if ( + !object(value) || + !id(value.id) || + value.name !== this.record.resources.sandboxApplicationName || + !object(value.durable_objects) || + value.durable_objects.namespace_id !== + this.record.resources.sandboxNamespaceId || + !object(value.configuration) || + typeof value.configuration.image !== "string" + ) + throw new ResourceConflict(); + return { + ...value, + id: value.id, + name: this.record.resources.sandboxApplicationName, + configuration: normalizedConfiguration(value.configuration), + durable_objects: { + ...value.durable_objects, + namespace_id: this.record.resources.sandboxNamespaceId, + }, + }; + }, + catch: () => new ResourceConflict(), + }); + } + findApplication() { + return Effect.gen({ self: this }, function* () { + const known = this.record.resources.sandboxApplicationId; + if (known) + return yield* this.application( + yield* this.client.request(`containers/applications/${known}`), + ); + const list = yield* this.client.request( + `containers/applications?name=${this.record.resources.sandboxApplicationName}`, + ); + if (!Array.isArray(list) || list.length > 100) + return yield* new TemporarilyUnavailable(); + const matches = list.filter( + (app) => app?.name === this.record.resources.sandboxApplicationName, + ); + if (matches.length > 1) return yield* new ResourceConflict(); + return matches.length ? yield* this.application(matches[0]) : null; + }); + } + desiredContainer(artifact: Artifact) { + const c = artifact.deployment.containers[0]; + return { + configuration: { image: c.image, instance_type: c.instance_type }, + max_instances: c.max_instances, + scheduling_policy: "default", + }; + } + matchesContainer(app: Application, artifact: Artifact) { + const d = this.desiredContainer(artifact); + return ( + app.configuration.image === d.configuration.image && + app.configuration.instance_type === d.configuration.instance_type && + app.max_instances === d.max_instances && + app.scheduling_policy === d.scheduling_policy + ); + } + createApplication(artifact: Artifact) { + return Effect.gen({ self: this }, function* () { + const container = artifact.deployment.containers[0]; + if (!this.record.resources.sandboxNamespaceId) + return yield* new ResourceConflict(); + const created = yield* this.client.sdk( + createContainerApplication({ + accountId: this.record.accountId, + name: this.record.resources.sandboxApplicationName, + instances: 0, + maxInstances: container.max_instances, + schedulingPolicy: "default", + configuration: { + image: container.image, + instanceType: container.instance_type, + }, + durableObjects: { + namespaceId: this.record.resources.sandboxNamespaceId, + }, + }), + ); + if (!id(created.id)) return yield* new TemporarilyUnavailable(); + return yield* this.application( + yield* this.client.request(`containers/applications/${created.id}`), + ); + }); + } + patchApplication(app: Application, artifact: Artifact) { + return Effect.gen({ self: this }, function* () { + yield* this.client.request(`containers/applications/${app.id}`, { + method: "PATCH", + body: { + ...this.desiredContainer(artifact), + configuration: { + ...app.configuration, + ...this.desiredContainer(artifact).configuration, + }, + }, + }); + }); + } + rollout( + app: Application, + artifact: Artifact, + operationId: string, + allowCreate: boolean, + knownId: string | null = null, + remember: (id: string) => Effect.Effect = () => Effect.void, + ) { + return Effect.gen({ self: this }, function* () { + const description = `Flarebot ${operationId}`; + const target = { + ...app.configuration, + ...this.desiredContainer(artifact).configuration, + }; + const list: unknown[] = []; + if (knownId) + list.push( + yield* this.client.request( + `containers/applications/${app.id}/rollouts/${knownId}`, + ), + ); + else { + let last: string | null = null; + for (let page = 0; page < 10; page++) { + const batch: unknown = yield* this.client.request( + `containers/applications/${app.id}/rollouts?limit=100${last ? `&last=${last}` : ""}`, + ); + if (!Array.isArray(batch) || batch.length > 100) + return yield* new TemporarilyUnavailable(); + list.push(...batch); + if (batch.length < 100) break; + const tail: unknown = batch.at(-1); + const next: unknown = object(tail) ? tail.id : undefined; + if (!id(next) || next === last || page === 9) + return yield* new TemporarilyUnavailable(); + last = next; + } + } + const matches = list.filter( + (r): r is Record => + object(r) && r.description === description, + ); + if (matches.length > 1) return yield* new ResourceConflict(); + if (matches.length) { + if ( + !eq( + yield* Effect.try({ + try: () => + normalizedConfiguration(matches[0].target_configuration), + catch: () => new ResourceConflict(), + }), + yield* Effect.try({ + try: () => normalizedConfiguration(target), + catch: () => new ResourceConflict(), + }), + ) || + !id(matches[0].id) + ) + return yield* new ResourceConflict(); + if (!knownId) yield* remember(matches[0].id); + if ( + matches[0].status === "pending" || + matches[0].status === "progressing" + ) + return yield* new TemporarilyUnavailable(); + if (matches[0].status !== "completed") + return yield* new ResourceConflict(); + return; + } + if (!allowCreate) return yield* new RecoveryRequired(); + const created = yield* this.client.request( + `containers/applications/${app.id}/rollouts`, + { + method: "POST", + body: { + description, + strategy: "rolling", + kind: "full_auto", + step_percentage: 100, + target_configuration: target, + }, + }, + ); + if (object(created) && id(created.id)) yield* remember(created.id); + if ( + !object(created) || + !id(created.id) || + created?.status !== "completed" || + created?.description !== description || + !eq( + yield* Effect.try({ + try: () => normalizedConfiguration(created.target_configuration), + catch: () => new ResourceConflict(), + }), + yield* Effect.try({ + try: () => normalizedConfiguration(target), + catch: () => new ResourceConflict(), + }), + ) + ) + return yield* new TemporarilyUnavailable(); + }); + } + containerFingerprint(app: Application) { + return fingerprint({ + id: app.id, + name: app.name, + namespaceId: app.durable_objects.namespace_id, + configuration: app.configuration, + max_instances: app.max_instances, + scheduling_policy: app.scheduling_policy, + }); + } + matchesSupportedContainer(app: Application) { + let configuration: Record; + try { + configuration = normalizedConfiguration(app.configuration); + } catch { + return false; + } + return ( + app.id === this.record.resources.sandboxApplicationId && + app.name === this.record.resources.sandboxApplicationName && + app.durable_objects.namespace_id === + this.record.resources.sandboxNamespaceId && + typeof configuration.image === "string" && + /^docker\.io\/cloudflare\/sandbox:[A-Za-z0-9._-]+@sha256:[a-f0-9]{64}$/.test( + configuration.image, + ) && + configuration.instance_type === "lite" && + app.max_instances === 4 && + app.scheduling_policy === "default" + ); + } +} diff --git a/control-plane/crypto.ts b/control-plane/crypto.ts index e6d25e7..8454638 100644 --- a/control-plane/crypto.ts +++ b/control-plane/crypto.ts @@ -1,3 +1,4 @@ +import * as Effect from "effect/Effect"; export const encode = (bytes: Uint8Array) => btoa(String.fromCharCode(...bytes)) .replaceAll("+", "-") @@ -8,42 +9,58 @@ export const decode = (value: string) => c.charCodeAt(0), ); export const random = () => encode(crypto.getRandomValues(new Uint8Array(32))); -export const hash = async (value: string) => - encode( - new Uint8Array( - await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)), - ), - ); +export const hash = (value: string) => + Effect.promise(() => + crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)), + ).pipe(Effect.map((bytes) => encode(new Uint8Array(bytes)))); export const opaque = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9_-]{43}$/.test(value); -async function key(secret: string) { - return crypto.subtle.importKey("raw", decode(secret), "AES-GCM", false, [ - "encrypt", - "decrypt", - ]); +function key(secret: string) { + return Effect.gen(function* () { + return yield* Effect.promise(() => + crypto.subtle.importKey("raw", decode(secret), "AES-GCM", false, [ + "encrypt", + "decrypt", + ]), + ); + }); } -export async function encrypt(secret: string, value: unknown, binding: string) { - const iv = crypto.getRandomValues(new Uint8Array(12)); - const ciphertext = await crypto.subtle.encrypt( - { name: "AES-GCM", iv, additionalData: new TextEncoder().encode(binding) }, - await key(secret), - new TextEncoder().encode(JSON.stringify(value)), - ); - return { iv: encode(iv), ciphertext: encode(new Uint8Array(ciphertext)) }; +export function encrypt(secret: string, value: unknown, binding: string) { + return Effect.gen(function* () { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const cryptoKey = yield* key(secret); + const ciphertext = yield* Effect.promise(() => + crypto.subtle.encrypt( + { + name: "AES-GCM", + iv, + additionalData: new TextEncoder().encode(binding), + }, + cryptoKey, + new TextEncoder().encode(JSON.stringify(value)), + ), + ); + return { iv: encode(iv), ciphertext: encode(new Uint8Array(ciphertext)) }; + }); } -export async function decrypt( +export function decrypt( secret: string, sealed: { iv: string; ciphertext: string }, binding: string, -): Promise { - const bytes = await crypto.subtle.decrypt( - { - name: "AES-GCM", - iv: decode(sealed.iv), - additionalData: new TextEncoder().encode(binding), - }, - await key(secret), - decode(sealed.ciphertext), - ); - return JSON.parse(new TextDecoder().decode(bytes)) as T; +) { + return Effect.gen(function* () { + const cryptoKey = yield* key(secret); + const bytes = yield* Effect.promise(() => + crypto.subtle.decrypt( + { + name: "AES-GCM", + iv: decode(sealed.iv), + additionalData: new TextEncoder().encode(binding), + }, + cryptoKey, + decode(sealed.ciphertext), + ), + ); + return JSON.parse(new TextDecoder().decode(bytes)) as T; + }); } diff --git a/control-plane/deployment-api.ts b/control-plane/deployment-api.ts deleted file mode 100644 index 2c58817..0000000 --- a/control-plane/deployment-api.ts +++ /dev/null @@ -1,1034 +0,0 @@ -import { parseInstallationConfig } from "../configuration/customer.ts"; -import type { UpgradeBaseline } from "./operation.ts"; -import { digest } from "./artifact.ts"; -import type { Artifact } from "./artifact-types.ts"; -import type { Installation, ReleaseIdentity } from "./installation-metadata.ts"; -import { fail } from "./deployment-errors.ts"; - -export type DeploymentNetwork = typeof fetch; -type Binding = { - type: string; - name: string; - class_name?: string; - script_name?: string; - namespace_id?: string; - text?: string; - json?: string; -}; -type Settings = { bindings: Binding[]; [key: string]: unknown }; -export type Application = { - id: string; - name: string; - configuration: { image: string; instance_type: string }; - max_instances: number; - scheduling_policy: string; - durable_objects: { namespace_id: string }; -}; -export type Rollout = { - id: string; - description: string; - target_configuration: { image: string; instance_type: string }; - status?: string; -}; -const id = (value: unknown): value is string => - typeof value === "string" && - /^(?:[a-f0-9]{32}|[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12})$/.test(value); -const object = (v: unknown): v is Record => - !!v && typeof v === "object" && !Array.isArray(v); -const token = (value: unknown): value is string => - typeof value === "string" && - value.length > 0 && - value.length <= 16_384 && - /^[A-Za-z0-9_.-]+$/.test(value); -function normalizedConfiguration(value: unknown) { - if (!object(value) || typeof value.image !== "string") - fail("resource_conflict"); - const config = { ...value }; - const lite = - config.vcpu === 0.0625 && - config.memory_mib === 256 && - object(config.disk) && - config.disk.size_mb === 2000; - if (lite) { - config.instance_type = "lite"; - for (const key of ["vcpu", "memory", "memory_mib"]) delete config[key]; - if ( - Object.keys(config.disk as Record).every( - (key) => key === "size_mb", - ) - ) - delete config.disk; - } - if (typeof config.instance_type !== "string") fail("resource_conflict"); - return config; -} -export const eq = (a: unknown, b: unknown): boolean => { - if (a === b) return true; - if (Array.isArray(a) && Array.isArray(b)) - return a.length === b.length && a.every((v, i) => eq(v, b[i])); - if (!object(a) || !object(b)) return false; - const keys = Object.keys(a); - return ( - keys.length === Object.keys(b).length && keys.every((k) => eq(a[k], b[k])) - ); -}; -export const fingerprint = (value: unknown): Promise => { - const canonical = (v: any): any => - Array.isArray(v) - ? v.map(canonical) - : object(v) - ? Object.fromEntries( - Object.keys(v) - .sort() - .map((k) => [k, canonical(v[k])]), - ) - : v; - return digest( - new TextEncoder().encode(JSON.stringify(canonical(value))) - .buffer as ArrayBuffer, - ); -}; -async function boundedJson(response: Response) { - const reader = response.body?.getReader(); - if (!reader) fail("temporarily_unavailable"); - const chunks: Uint8Array[] = []; - let size = 0; - for (;;) { - const { value, done } = await reader.read(); - if (done) break; - size += value.length; - if (size > 2 * 1024 * 1024) { - await reader.cancel(); - fail("temporarily_unavailable"); - } - chunks.push(value); - } - const bytes = new Uint8Array(size); - let offset = 0; - for (const chunk of chunks) { - bytes.set(chunk, offset); - offset += chunk.length; - } - try { - return JSON.parse(new TextDecoder().decode(bytes)); - } catch { - fail("temporarily_unavailable"); - } -} -// Fixed native v4 endpoint adapter. No arbitrary host/path from browser input, -// automatic mutation retries, redirect following, or provider error propagation. -export class DeploymentAPI { - constructor( - private readonly accessToken: string, - private readonly record: Installation, - private readonly network: DeploymentNetwork = fetch, - ) {} - private async request( - path: string, - method = "GET", - body?: unknown, - notFound = false, - authorization = this.accessToken, - ): Promise { - let response: Response; - // Native Workers fetch rejects an arbitrary object as its receiver. - const network = this.network; - try { - response = await network( - `https://api.cloudflare.com/client/v4/accounts/${this.record.accountId}/${path}`, - { - method, - redirect: "manual", - signal: AbortSignal.timeout(60_000), - headers: { - Authorization: `Bearer ${authorization}`, - ...(body instanceof FormData - ? {} - : body === undefined - ? {} - : { "Content-Type": "application/json" }), - }, - ...(body === undefined - ? {} - : { body: body instanceof FormData ? body : JSON.stringify(body) }), - }, - ); - } catch { - return fail("temporarily_unavailable"); - } - if (response.status === 401) fail("reauthorization_required"); - if (response.status === 403) fail("account_denied"); - if (response.status === 404 && notFound) return null; - const envelope = await boundedJson(response); - if ( - !response.ok || - !object(envelope) || - envelope.success !== true || - !("result" in envelope) - ) - fail("temporarily_unavailable"); - return envelope.result; - } - private get script() { - return `workers/scripts/${this.record.resources.workerName}`; - } - async ensureModelGateway() { - const gatewayId = "default"; - const gatewayPath = `ai-gateway/gateways/${gatewayId}`; - let gateway = await this.request(gatewayPath, "GET", undefined, true); - if (gateway === null) { - await this.request("ai-gateway/gateways", "POST", { - id: gatewayId, - cache_invalidate_on_update: true, - cache_ttl: 0, - collect_logs: false, - rate_limiting_interval: 0, - rate_limiting_limit: 0, - authentication: true, - }); - gateway = await this.request(gatewayPath); - } - if (gateway?.id !== gatewayId) fail("resource_conflict"); - // Gateways are account-shared. Preserve existing billing, logging, limits - // and authentication settings rather than replacing them with our defaults. - } - async enableOpenRouter() { - await this.ensureModelGateway(); - } - async origin() { - const result = await this.request("workers/subdomain"); - if ( - !object(result) || - typeof result.subdomain !== "string" || - !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(result.subdomain) - ) - fail("setup_required"); - return `https://${this.record.resources.workerName}.${result.subdomain}.workers.dev`; - } - async settings(): Promise { - const result = await this.request( - `${this.script}/settings`, - "GET", - undefined, - true, - ); - if (result === null) return null; - if ( - !object(result) || - !Array.isArray(result.bindings) || - result.bindings.length > 256 || - result.bindings.some( - (b) => - !object(b) || - typeof b.type !== "string" || - typeof b.name !== "string", - ) - ) - fail("resource_conflict"); - return result as Settings; - } - verifyWorker(settings: Settings, installationConfig: unknown) { - const bindings = settings.bindings; - if (new Set(bindings.map((b) => b.name)).size !== bindings.length) - fail("resource_conflict"); - const byName = (name: string, type: string) => { - const binding = bindings.find((b) => b.name === name); - if (binding?.type !== type) fail("resource_conflict"); - return binding; - }; - let installed: unknown; - try { - installed = JSON.parse( - byName("FLAREBOT_INSTALLATION", "plain_text").text!, - ); - } catch { - fail("resource_conflict"); - } - if ( - !eq(installed, installationConfig) || - byName("FLAREBOT_MODE", "plain_text").text !== "customer-runtime" || - byName("FLAREBOT_ENV", "plain_text").text !== "production" - ) - fail("resource_conflict"); - for (const [name, type] of [ - ["ASSETS", "assets"], - ["AI", "ai"], - ["BROWSER", "browser"], - ["LOADER", "worker_loader"], - ["FLAREBOT_SESSION_SECRET", "secret_text"], - ]) - byName(name, type); - for (const name of ["PersonalAgent", "Sandbox"]) { - const b = byName(name, "durable_object_namespace"); - if ( - b.class_name !== name || - (b.script_name !== undefined && - b.script_name !== this.record.resources.workerName) - ) - fail("resource_conflict"); - } - } - configuration(settings: Settings) { - try { - return parseInstallationConfig( - JSON.parse( - settings.bindings.find((b) => b.name === "FLAREBOT_INSTALLATION") - ?.text ?? "null", - ), - ); - } catch { - return fail("resource_conflict"); - } - } - async activeDeployment() { - const deployments = await this.request(`${this.script}/deployments`); - const latest = deployments?.deployments?.[0]; - if ( - !id(latest?.id) || - latest.versions?.length !== 1 || - latest.versions[0].percentage !== 100 || - !id(latest.versions[0].version_id) - ) - fail("resource_conflict"); - return { - deploymentId: latest.id as string, - versionId: latest.versions[0].version_id as string, - }; - } - async latestVersion() { - // The API returns newest uploads first, including undeployed versions. - const versions = await this.request( - `${this.script}/versions?page=1&per_page=1`, - ); - if ( - !Array.isArray(versions?.items) || - versions.items.length !== 1 || - !id(versions.items[0]?.id) - ) - fail("resource_conflict"); - return versions.items[0].id as string; - } - async endpoint() { - const current = await this.request(`${this.script}/subdomain`); - if (current?.enabled !== true || current?.previews_enabled !== false) - fail("resource_conflict"); - } - async observe( - artifact: Artifact, - deployedOperationId: string, - expectedConfigDigest: string | null, - sourceCodeHash?: string | null, - ) { - const deployment = await this.activeDeployment(); - if ((await this.latestVersion()) !== deployment.versionId) - fail("resource_conflict"); - const settings = await this.settings(); - if (!settings) fail("resource_conflict"); - const config = this.configuration(settings); - if ( - config.installationId !== this.record.installationId || - config.ownerSubject !== this.record.ownerSubject || - config.runtimeOrigin !== this.record.resources.runtimeOrigin || - config.release?.operationId !== deployedOperationId || - config.release?.artifactDigest !== artifact.identity.artifactDigest || - config.release?.version !== artifact.identity.version - ) - fail("resource_conflict"); - const configDigest = await fingerprint(config); - if (expectedConfigDigest && configDigest !== expectedConfigDigest) - fail("resource_conflict"); - this.verifyWorker(settings, config); - const codeHash = await this.verifyContent( - sourceCodeHash === undefined ? artifact : undefined, - ); - if (sourceCodeHash && codeHash !== sourceCodeHash) - fail("resource_conflict"); - const namespaces = await this.namespaces( - config, - artifact, - sourceCodeHash !== undefined, - ); - if ( - namespaces.personalAgentNamespaceId !== - this.record.resources.personalAgentNamespaceId || - namespaces.sandboxNamespaceId !== this.record.resources.sandboxNamespaceId - ) - fail("resource_conflict"); - const version = await this.request( - `${this.script}/versions/${deployment.versionId}`, - ); - const runtime = version?.resources?.script_runtime; - // The version API reports the normalized defaults of assets.config: {}. - // These and the container link are deployment-owned, not inherited settings. - if ( - !object(runtime) || - !eq(runtime.assets, { - serve_directly: true, - raw_run_worker_first: false, - }) || - !eq(runtime.containers, [ - { - name: this.record.resources.sandboxApplicationName, - class_name: "Sandbox", - }, - ]) - ) - fail("resource_conflict"); - const metadata: Record = {}; - const tags = settings.tags === undefined ? [] : settings.tags; - if (!Array.isArray(tags) || tags.some((tag) => typeof tag !== "string")) - fail("resource_conflict"); - metadata.tags = tags; - if (settings.annotations !== undefined) { - if (!object(settings.annotations)) fail("resource_conflict"); - const annotations: Record = {}; - for (const [key, value] of Object.entries(settings.annotations)) { - if (typeof value !== "string") fail("resource_conflict"); - // Cloudflare regenerates this read-only provenance on each upload. - if (key === "workers/triggered_by") continue; - const limit = - key === "workers/message" ? 1000 : key === "workers/tag" ? 100 : 0; - if (!limit || new TextEncoder().encode(value).length > limit) - fail("resource_conflict"); - annotations[key] = value; - } - if (Object.keys(annotations).length) metadata.annotations = annotations; - } - // Preserve native upload fields. Unknown settings fail closed before assets - // staging instead of relying on omission to retain customer configuration. - const preserved = [ - "limits", - "placement", - "observability", - "tail_consumers", - "logpush", - "usage_model", - ]; - for (const key of Object.keys(settings)) { - if ( - [ - "bindings", - "compatibility_date", - "compatibility_flags", - "created_on", - "modified_on", - "etag", - "has_assets", - "last_deployed_from", - "tags", - "annotations", - ].includes(key) - ) - continue; - if (!preserved.includes(key)) fail("resource_conflict"); - metadata[key] = settings[key]; - } - for (const key of Object.keys(runtime)) { - if ( - [ - "compatibility_date", - "compatibility_flags", - "exports", - "assets", - "containers", - ].includes(key) - ) - continue; - if (!preserved.includes(key)) fail("resource_conflict"); - metadata[key] = runtime[key]; - } - const app = await this.findApplication(); - if (!app || app.id !== this.record.resources.sandboxApplicationId) - fail("resource_conflict"); - await this.endpoint(); - if (!eq(deployment, await this.activeDeployment())) - fail("resource_conflict"); - return { - ...deployment, - settings, - config, - configDigest, - codeHash, - metadata, - app, - fingerprint: await fingerprint({ - bindings: settings.bindings.filter( - (b) => b.name !== "FLAREBOT_INSTALLATION", - ), - metadata, - namespaces, - }), - containerFingerprint: await this.containerFingerprint(app), - }; - } - containerFingerprint(app: Application) { - return fingerprint({ - id: app.id, - name: app.name, - namespaceId: app.durable_objects.namespace_id, - configuration: app.configuration, - max_instances: app.max_instances, - scheduling_policy: app.scheduling_policy, - }); - } - private matchesSupportedContainer(app: Application) { - let configuration: Record; - try { - configuration = normalizedConfiguration(app.configuration); - } catch { - return false; - } - return ( - app.id === this.record.resources.sandboxApplicationId && - app.name === this.record.resources.sandboxApplicationName && - app.durable_objects.namespace_id === - this.record.resources.sandboxNamespaceId && - typeof configuration.image === "string" && - /^docker\.io\/cloudflare\/sandbox:[A-Za-z0-9._-]+@sha256:[a-f0-9]{64}$/.test( - configuration.image, - ) && - configuration.instance_type === "lite" && - app.max_instances === 4 && - app.scheduling_policy === "default" - ); - } - async baseline( - source: ReleaseIdentity, - target: Artifact, - deployedOperationId: string, - configDigest: string | null, - ): Promise { - const observed = await this.observe( - { ...target, identity: source }, - deployedOperationId, - configDigest, - null, - ); - // The current contract supports only this durable-object identity and - // customer-owned Sandbox shape. It does not assert that old code equals an - // archived publisher bundle; the observed hash is pinned for race checks. - if (!this.matchesSupportedContainer(observed.app)) - fail("resource_conflict"); - return { - fromRelease: { - version: source.version, - sourceRevision: source.sourceRevision, - artifactDigest: source.artifactDigest, - }, - toRelease: target.identity, - deployedOperationId, - configDigest: observed.configDigest, - versionId: observed.versionId, - deploymentId: observed.deploymentId, - sourceCodeHash: observed.codeHash, - fingerprint: observed.fingerprint, - containerFingerprint: observed.containerFingerprint, - targetContainerFingerprint: await this.containerFingerprint({ - ...observed.app, - ...this.desiredContainer(target), - configuration: { - ...observed.app.configuration, - ...this.desiredContainer(target).configuration, - }, - }), - }; - } - async upload( - artifact: Artifact, - installationConfig: unknown, - bootstrapSecret: string | null, - beforeUpload: () => Promise, - inherited?: { - versionId: string; - bindings: Binding[]; - metadata: Record; - }, - ) { - const assets = artifact.files.filter((f) => f.assetHash); - const manifest = Object.fromEntries( - assets.map((f) => [ - `/${f.path.slice("assets/".length)}`, - { hash: f.assetHash!, size: f.size }, - ]), - ); - const session = await this.request( - `${this.script}/assets-upload-session`, - "POST", - { manifest }, - ); - if ( - !object(session) || - !token(session.jwt) || - !Array.isArray(session.buckets) || - session.buckets.length > assets.length - ) - fail("temporarily_unavailable"); - const byHash = new Map(assets.map((f) => [f.assetHash!, f])); - const seen = new Set(); - let completion = session.jwt; - let completed = session.buckets.length === 0; - for (const bucket of session.buckets) { - if ( - !Array.isArray(bucket) || - !bucket.length || - bucket.length > assets.length - ) - fail("temporarily_unavailable"); - const form = new FormData(); - for (const hash of bucket) { - const file = byHash.get(hash); - if (!file || seen.has(hash)) fail("temporarily_unavailable"); - seen.add(hash); - // Base64 encoding is transient per bucket; source bytes remain opaque. - const bytes = new Uint8Array(artifact.bytes[file.path]); - let binary = ""; - for (let i = 0; i < bytes.length; i += 8192) - binary += String.fromCharCode(...bytes.subarray(i, i + 8192)); - form.append(hash, new Blob([btoa(binary)], { type: file.mime }), hash); - } - const result = await this.request( - "workers/assets/upload?base64=true", - "POST", - form, - false, - session.jwt, - ); - if (!object(result)) fail("temporarily_unavailable"); - if (result.jwt !== undefined) { - if (!token(result.jwt)) fail("temporarily_unavailable"); - completion = result.jwt; - completed = true; - } - } - if (!completed) fail("temporarily_unavailable"); - const d = artifact.deployment; - let bindings: Record[] = [ - { type: "assets", name: "ASSETS" }, - ...d.durable_objects.bindings.map((b) => ({ - type: "durable_object_namespace", - ...b, - })), - { type: "ai", name: "AI" }, - { type: "browser", name: "BROWSER" }, - { type: "worker_loader", name: "LOADER" }, - { type: "plain_text", name: "FLAREBOT_MODE", text: "customer-runtime" }, - { type: "plain_text", name: "FLAREBOT_ENV", text: "production" }, - { - type: "plain_text", - name: "FLAREBOT_INSTALLATION", - text: JSON.stringify(installationConfig), - }, - { - type: "secret_text", - name: "FLAREBOT_SESSION_SECRET", - text: bootstrapSecret, - }, - ]; - if (inherited) { - // Only release config and Assets change. Native inheritance preserves every - // checked binding, including unreadable secrets, pinned to the old version. - bindings = [ - { type: "assets", name: "ASSETS" }, - { - type: "plain_text", - name: "FLAREBOT_INSTALLATION", - text: JSON.stringify(installationConfig), - }, - ...inherited.bindings - .filter((b) => !["ASSETS", "FLAREBOT_INSTALLATION"].includes(b.name)) - .map((b) => ({ - name: b.name, - type: "inherit", - version_id: "latest", - })), - ]; - } else if (!bootstrapSecret) fail("reauthorization_required"); - const form = new FormData(); - form.append( - "metadata", - new Blob( - [ - JSON.stringify({ - main_module: "index.js", - compatibility_date: d.compatibility_date, - compatibility_flags: d.compatibility_flags, - bindings, - exports: d.exports, - containers: [ - { - name: this.record.resources.sandboxApplicationName, - class_name: "Sandbox", - }, - ], - keep_bindings: ["secret_text", "secret_key", "plain_text", "json"], - observability: d.observability, - ...inherited?.metadata, - assets: { jwt: completion, config: {} }, - }), - ], - { type: "application/json" }, - ), - ); - for (const file of artifact.files.filter((f) => - f.path.startsWith("worker/"), - )) { - const name = file.path.slice("worker/".length); - form.append( - name, - new Blob([artifact.bytes[file.path]], { - type: "application/javascript+module", - }), - name, - ); - } - await beforeUpload(); - // Script PUT only accepts the literal latest for inheritance. Confirm that - // it still identifies the verified source immediately before writing. - if (inherited && (await this.latestVersion()) !== inherited.versionId) - fail("resource_conflict"); - await this.request( - this.script + (inherited ? "?bindings_inherit=strict" : ""), - "PUT", - form, - ); - } - async verifyContent(artifact?: Artifact) { - const files = - artifact?.files.filter((file) => file.path.startsWith("worker/")) ?? []; - const limit = artifact - ? files.reduce((sum, file) => sum + file.size, 0) + 65_536 - : 16 * 1024 * 1024 + 65_536; - let response: Response; - const network = this.network; - try { - response = await network( - `https://api.cloudflare.com/client/v4/accounts/${this.record.accountId}/${this.script}/content/v2`, - { - redirect: "manual", - signal: AbortSignal.timeout(60_000), - headers: { Authorization: `Bearer ${this.accessToken}` }, - }, - ); - } catch { - fail("temporarily_unavailable"); - } - if (response.status === 401) fail("reauthorization_required"); - if (response.status === 403) fail("account_denied"); - if ( - !response.ok || - !response.headers.get("Content-Type")?.startsWith("multipart/") || - response.headers.get("cf-entrypoint") !== "index.js" || - !response.body - ) - fail("resource_conflict"); - const reader = response.body.getReader(); - const chunks: Uint8Array[] = []; - let size = 0; - for (;;) { - const { value, done } = await reader.read(); - if (done) break; - size += value.length; - if (size > limit) { - await reader.cancel(); - fail("resource_conflict"); - } - chunks.push(value); - } - const bytes = new Uint8Array(size); - let offset = 0; - for (const chunk of chunks) { - bytes.set(chunk, offset); - offset += chunk.length; - } - const form = await new Response(bytes, { - headers: { "Content-Type": response.headers.get("Content-Type")! }, - }).formData(); - const keys = [...form.keys()]; - if (artifact && keys.length !== files.length) fail("resource_conflict"); - if ( - !artifact && - (!keys.includes("index.js") || - keys.length > 256 || - new Set(keys).size !== keys.length || - keys.some( - (name) => - !/^[A-Za-z0-9_./-]+\.js$/.test(name) || - name - .split("/") - .some((part) => !part || part === ".." || part === "."), - )) - ) - fail("resource_conflict"); - const observed: [string, string][] = []; - for (const key of keys.sort()) { - const parts = form.getAll(key); - if (parts.length !== 1) fail("resource_conflict"); - const value = parts[0]; - const content = - typeof value === "string" - ? (new TextEncoder().encode(value).buffer as ArrayBuffer) - : await value.arrayBuffer(); - observed.push([key, await digest(content)]); - } - for (const file of files) { - const parts = form.getAll(file.path.slice("worker/".length)); - if (parts.length !== 1) fail("resource_conflict"); - const value = parts[0]; - const content = - typeof value === "string" - ? (new TextEncoder().encode(value).buffer as ArrayBuffer) - : await value.arrayBuffer(); - if ( - content.byteLength !== file.size || - (await digest(content)) !== file.sha256 - ) - fail("resource_conflict"); - } - return fingerprint(observed); - } - async namespaces( - installationConfig: unknown, - artifact: Artifact, - supportedSource = false, - ) { - const deployments = await this.request(`${this.script}/deployments`); - const latest = deployments?.deployments?.[0]; - if ( - !Array.isArray(latest?.versions) || - latest.versions.length !== 1 || - latest.versions[0].percentage !== 100 || - !id(latest.versions[0].version_id) - ) - fail("resource_conflict"); - const version = await this.request( - `${this.script}/versions/${latest.versions[0].version_id}`, - ); - const runtime = version?.resources?.script_runtime; - if ( - !object(runtime) || - (supportedSource - ? typeof runtime.compatibility_date !== "string" || - !/^\d{4}-\d{2}-\d{2}$/.test(runtime.compatibility_date) || - (!eq(runtime.compatibility_flags, ["nodejs_compat"]) && - !eq(runtime.compatibility_flags, [ - "nodejs_compat", - "global_fetch_strictly_public", - ])) - : runtime.compatibility_date !== - artifact.deployment.compatibility_date || - !eq( - runtime.compatibility_flags, - artifact.deployment.compatibility_flags, - )) || - !object(runtime.exports) - ) - fail("resource_conflict"); - for (const name of ["PersonalAgent", "Sandbox"]) { - const exported = runtime.exports[name]; - if ( - !object(exported) || - exported.type !== "durable-object" || - exported.storage !== "sqlite" || - (exported.state !== undefined && exported.state !== "created") || - (exported.container !== undefined && - (name !== "Sandbox" || - exported.container !== - this.record.resources.sandboxApplicationName)) - ) - fail("resource_conflict"); - } - if ( - Object.entries(runtime.exports).some( - ([name, value]) => - object(value) && - value.type === "durable-object" && - name !== "PersonalAgent" && - name !== "Sandbox", - ) - ) - fail("resource_conflict"); - const bindings = version?.resources?.bindings; - if (Array.isArray(bindings)) - this.verifyWorker({ bindings }, installationConfig); - if (!Array.isArray(bindings)) fail("temporarily_unavailable"); - const namespace = (name: string) => { - const matches = bindings.filter( - (b) => - b.type === "durable_object_namespace" && - b.name === name && - b.class_name === name && - (b.script_name === undefined || - b.script_name === this.record.resources.workerName), - ); - if (matches.length !== 1 || !id(matches[0].namespace_id)) - fail("resource_conflict"); - return matches[0].namespace_id as string; - }; - return { - personalAgentNamespaceId: namespace("PersonalAgent"), - sandboxNamespaceId: namespace("Sandbox"), - }; - } - private application(value: unknown): Application { - if ( - !object(value) || - !id(value.id) || - value.name !== this.record.resources.sandboxApplicationName || - !object(value.durable_objects) || - value.durable_objects.namespace_id !== - this.record.resources.sandboxNamespaceId || - !object(value.configuration) || - typeof value.configuration.image !== "string" || - false - ) - fail("resource_conflict"); - return { - ...value, - configuration: normalizedConfiguration(value.configuration), - } as unknown as Application; - } - async findApplication(): Promise { - const known = this.record.resources.sandboxApplicationId; - if (known) - return this.application( - await this.request(`containers/applications/${known}`), - ); - const list = await this.request( - `containers/applications?name=${this.record.resources.sandboxApplicationName}`, - ); - if (!Array.isArray(list) || list.length > 100) - fail("temporarily_unavailable"); - const matches = list.filter( - (app) => app?.name === this.record.resources.sandboxApplicationName, - ); - if (matches.length > 1) fail("resource_conflict"); - return matches.length ? this.application(matches[0]) : null; - } - desiredContainer(artifact: Artifact) { - const c = artifact.deployment.containers[0]; - return { - configuration: { image: c.image, instance_type: c.instance_type }, - max_instances: c.max_instances, - scheduling_policy: "default", - }; - } - matchesContainer(app: Application, artifact: Artifact) { - const d = this.desiredContainer(artifact); - return ( - app.configuration.image === d.configuration.image && - app.configuration.instance_type === d.configuration.instance_type && - app.max_instances === d.max_instances && - app.scheduling_policy === d.scheduling_policy - ); - } - async createApplication(artifact: Artifact) { - return this.application( - await this.request("containers/applications", "POST", { - name: this.record.resources.sandboxApplicationName, - instances: 0, - ...this.desiredContainer(artifact), - durable_objects: { - namespace_id: this.record.resources.sandboxNamespaceId, - }, - }), - ); - } - async patchApplication(app: Application, artifact: Artifact) { - await this.request(`containers/applications/${app.id}`, "PATCH", { - ...this.desiredContainer(artifact), - configuration: { - ...app.configuration, - ...this.desiredContainer(artifact).configuration, - }, - }); - } - async rollout( - app: Application, - artifact: Artifact, - operationId: string, - allowCreate: boolean, - knownId: string | null = null, - remember: (id: string) => Promise = async () => {}, - ) { - const description = `Flarebot ${operationId}`; - const target = { - ...app.configuration, - ...this.desiredContainer(artifact).configuration, - }; - const list: any[] = []; - if (knownId) - list.push( - await this.request( - `containers/applications/${app.id}/rollouts/${knownId}`, - ), - ); - else { - let last: string | null = null; - for (let page = 0; page < 10; page++) { - const batch = await this.request( - `containers/applications/${app.id}/rollouts?limit=100${last ? `&last=${last}` : ""}`, - ); - if (!Array.isArray(batch) || batch.length > 100) - fail("temporarily_unavailable"); - list.push(...batch); - if (batch.length < 100) break; - const next = batch.at(-1)?.id; - if (!id(next) || next === last || page === 9) - fail("temporarily_unavailable"); - last = next; - } - } - const matches = list.filter((r) => r?.description === description); - if (matches.length > 1) fail("resource_conflict"); - if (matches.length) { - if ( - !eq( - normalizedConfiguration(matches[0].target_configuration), - normalizedConfiguration(target), - ) || - !id(matches[0].id) - ) - fail("resource_conflict"); - if (!knownId) await remember(matches[0].id); - if ( - matches[0].status === "pending" || - matches[0].status === "progressing" - ) - fail("temporarily_unavailable"); - if (matches[0].status !== "completed") fail("resource_conflict"); - return; - } - if (!allowCreate) fail("recovery_required"); - const created = await this.request( - `containers/applications/${app.id}/rollouts`, - "POST", - { - description, - strategy: "rolling", - kind: "full_auto", - step_percentage: 100, - target_configuration: target, - }, - ); - if (id(created?.id)) await remember(created.id); - if ( - !id(created?.id) || - created?.status !== "completed" || - created?.description !== description || - !eq( - normalizedConfiguration(created?.target_configuration), - normalizedConfiguration(target), - ) - ) - fail("temporarily_unavailable"); - } - async publish() { - const current = await this.request(`${this.script}/subdomain`); - if (current?.enabled === true && current?.previews_enabled === false) - return; - await this.request(`${this.script}/subdomain`, "POST", { - enabled: true, - previews_enabled: false, - }); - } -} diff --git a/control-plane/deployment-config.ts b/control-plane/deployment-config.ts index 99f1618..f991777 100644 --- a/control-plane/deployment-config.ts +++ b/control-plane/deployment-config.ts @@ -1,32 +1,85 @@ -import { parseInstallationConfig } from "../configuration/customer.ts"; +import * as Effect from "effect/Effect"; import { loadControlPlaneConfig } from "../configuration/control-plane.ts"; -import type { Env } from "./config.ts"; +import type { InstallationConfig } from "../configuration/customer.ts"; +import { parseInstallationConfig } from "../configuration/customer.ts"; import type { Artifact } from "./artifact-types.ts"; +import { verifyUpgradeIdentity } from "./artifact.ts"; +import type { Env } from "./config.ts"; +import { ResourceConflict, SetupRequired } from "./deployment-errors.ts"; +import { fingerprint } from "./deployment-values.ts"; import type { Installation } from "./installation-metadata.ts"; -import { fail } from "./deployment-errors.ts"; +import type { InstallationOperation } from "./operation.ts"; +import type { WorkerAPI } from "./worker-api.ts"; + export function runtimeConfiguration( env: Env, record: Installation, artifact: Artifact, deployedOperationId: string, ) { - const cp = loadControlPlaneConfig(env).config; - if (!cp.bridge) fail("setup_required"); - try { - return parseInstallationConfig({ - schemaVersion: 1, - installationId: record.installationId, - ownerSubject: record.ownerSubject, - runtimeOrigin: record.resources.runtimeOrigin, - controlPlaneOrigin: cp.publicOrigin, - bridge: cp.bridge, - release: { - version: artifact.identity.version, - artifactDigest: artifact.identity.artifactDigest, - operationId: deployedOperationId, - }, + return Effect.gen(function* () { + const { config: cp } = yield* Effect.try({ + try: () => loadControlPlaneConfig(env), + catch: () => new SetupRequired(), + }); + if (!cp.bridge) return yield* new SetupRequired(); + return yield* Effect.try({ + try: () => + parseInstallationConfig({ + schemaVersion: 1, + installationId: record.installationId, + ownerSubject: record.ownerSubject, + runtimeOrigin: record.resources.runtimeOrigin, + controlPlaneOrigin: cp.publicOrigin, + bridge: cp.bridge, + release: { + version: artifact.identity.version, + artifactDigest: artifact.identity.artifactDigest, + operationId: deployedOperationId, + }, + }), + catch: () => new SetupRequired(), }); - } catch { - return fail("setup_required"); - } + }); +} + +export function deploymentConfiguration( + env: Env, + record: Installation, + artifact: Artifact, + operation: InstallationOperation, + worker: Pick, +) { + return Effect.gen(function* () { + let config: InstallationConfig = yield* runtimeConfiguration( + env, + record, + artifact, + operation.workerIntent?.operationId ?? operation.operationId, + ); + if (operation.upgrade) { + const source = operation.upgrade.fromRelease; + yield* verifyUpgradeIdentity(source, artifact); + const settings = yield* worker.settings(); + if (!settings) return yield* new ResourceConflict(); + const existing = yield* worker.configuration(settings); + const expected = + existing.release?.artifactDigest === source.artifactDigest + ? operation.upgrade.configDigest + : operation.workerIntent?.configDigest; + if (!expected || (yield* fingerprint(existing)) !== expected) + return yield* new ResourceConflict(); + config = { + ...existing, + release: { + version: artifact.identity.version, + artifactDigest: artifact.identity.artifactDigest, + operationId: + operation.workerIntent?.operationId ?? operation.operationId, + }, + }; + } + + return config; + }); } diff --git a/control-plane/deployment-errors.ts b/control-plane/deployment-errors.ts index 8e4b629..b7a5278 100644 --- a/control-plane/deployment-errors.ts +++ b/control-plane/deployment-errors.ts @@ -1,28 +1,85 @@ -export const deploymentCodes = [ - "recovery_required", - "artifact_unavailable", - "setup_required", - "reauthorization_required", - "account_denied", - "resource_conflict", - "deployment_failed", - "health_failed", - "temporarily_unavailable", -] as const; -export type DeploymentCode = (typeof deploymentCodes)[number]; -export class DeploymentError extends Error { - readonly code: DeploymentCode; - constructor(code: DeploymentCode) { - super(code); - this.code = code; - } +import * as Data from "effect/Data"; + +export class RecoveryRequired extends Data.TaggedError("RecoveryRequired") { + readonly code = "recovery_required"; + override readonly message = this.code; } + +export class ArtifactUnavailable extends Data.TaggedError( + "ArtifactUnavailable", +) { + readonly code = "artifact_unavailable"; + override readonly message = this.code; +} + +export class SetupRequired extends Data.TaggedError("SetupRequired") { + readonly code = "setup_required"; + override readonly message = this.code; +} + +export class ReauthorizationRequired extends Data.TaggedError( + "ReauthorizationRequired", +) { + readonly code = "reauthorization_required"; + override readonly message = this.code; +} + +export class AccountDenied extends Data.TaggedError("AccountDenied") { + readonly code = "account_denied"; + override readonly message = this.code; +} + +export class ResourceConflict extends Data.TaggedError("ResourceConflict") { + readonly code = "resource_conflict"; + override readonly message = this.code; +} + +export class DeploymentFailed extends Data.TaggedError("DeploymentFailed") { + readonly code = "deployment_failed"; + override readonly message = this.code; +} + +export class HealthFailed extends Data.TaggedError("HealthFailed") { + readonly code = "health_failed"; + override readonly message = this.code; +} + +export class TemporarilyUnavailable extends Data.TaggedError( + "TemporarilyUnavailable", +) { + readonly code = "temporarily_unavailable"; + override readonly message = this.code; +} + +const errors = { + recovery_required: RecoveryRequired, + artifact_unavailable: ArtifactUnavailable, + setup_required: SetupRequired, + reauthorization_required: ReauthorizationRequired, + account_denied: AccountDenied, + resource_conflict: ResourceConflict, + deployment_failed: DeploymentFailed, + health_failed: HealthFailed, + temporarily_unavailable: TemporarilyUnavailable, +}; + +export type DeploymentCode = keyof typeof errors; +export type DeploymentFailure = InstanceType<(typeof errors)[DeploymentCode]>; +export const deploymentCodes = Object.keys(errors) as DeploymentCode[]; + +export function deploymentFailure(code: DeploymentCode): DeploymentFailure { + return new errors[code](); +} + +export function isDeploymentFailure( + error: unknown, +): error is DeploymentFailure { + return Object.values(errors).some((ErrorType) => error instanceof ErrorType); +} + export function deploymentCode(error: unknown): DeploymentCode { - if (error instanceof DeploymentError) return error.code; + if (isDeploymentFailure(error)) return error.code; // Never pass provider bodies, network URLs, credentials or arbitrary messages // into Workflow error history or installation metadata. return "temporarily_unavailable"; } -export function fail(code: DeploymentCode): never { - throw new DeploymentError(code); -} diff --git a/control-plane/deployment-values.ts b/control-plane/deployment-values.ts new file mode 100644 index 0000000..5a5510b --- /dev/null +++ b/control-plane/deployment-values.ts @@ -0,0 +1,34 @@ +import * as Effect from "effect/Effect"; +import { digest } from "./artifact.ts"; +export const id = (value: unknown): value is string => + typeof value === "string" && + /^(?:[a-f0-9]{32}|[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12})$/.test(value); +export const object = (v: unknown): v is Record => + !!v && typeof v === "object" && !Array.isArray(v); +export const eq = (a: unknown, b: unknown): boolean => { + if (a === b) return true; + if (Array.isArray(a) && Array.isArray(b)) + return a.length === b.length && a.every((v, i) => eq(v, b[i])); + if (!object(a) || !object(b)) return false; + const keys = Object.keys(a); + return ( + keys.length === Object.keys(b).length && keys.every((k) => eq(a[k], b[k])) + ); +}; +export const fingerprint = (value: unknown) => + Effect.suspend(() => { + const canonical = (v: unknown): unknown => + Array.isArray(v) + ? v.map(canonical) + : object(v) + ? Object.fromEntries( + Object.keys(v) + .sort() + .map((k) => [k, canonical(v[k])]), + ) + : v; + return digest( + new TextEncoder().encode(JSON.stringify(canonical(value))) + .buffer as ArrayBuffer, + ); + }); diff --git a/control-plane/deployment.ts b/control-plane/deployment.ts new file mode 100644 index 0000000..c57b2a9 --- /dev/null +++ b/control-plane/deployment.ts @@ -0,0 +1,230 @@ +import * as Effect from "effect/Effect"; +import type { Artifact } from "./artifact-types.ts"; +import { CloudflareAccount } from "./cloudflare-account.ts"; +import { ContainerAPI } from "./container-api.ts"; +import { ResourceConflict } from "./deployment-errors.ts"; +import { eq, fingerprint, object } from "./deployment-values.ts"; +import type { Installation, ReleaseIdentity } from "./installation-metadata.ts"; +import { WorkerAPI } from "./worker-api.ts"; + +type CodeVerification = + { kind: "artifact" } | { kind: "source"; hash: string | null }; + +export class Deployment { + readonly account: CloudflareAccount; + readonly worker: WorkerAPI; + readonly containers: ContainerAPI; + constructor( + accessToken: string, + private readonly record: Installation, + network: typeof fetch = fetch, + ) { + this.account = new CloudflareAccount( + accessToken, + record.accountId, + network, + ); + this.worker = new WorkerAPI(this.account, record); + this.containers = new ContainerAPI(this.account, record); + } + observe( + artifact: Artifact, + deployedOperationId: string, + expectedConfigDigest: string | null, + code: CodeVerification = { kind: "artifact" }, + ) { + return Effect.gen({ self: this }, function* () { + const deployment = yield* this.worker.activeDeployment(); + if ((yield* this.worker.latestVersion()) !== deployment.versionId) + return yield* new ResourceConflict(); + const settings = yield* this.worker.settings(); + if (!settings) return yield* new ResourceConflict(); + const config = yield* this.worker.configuration(settings); + if ( + config.installationId !== this.record.installationId || + config.ownerSubject !== this.record.ownerSubject || + config.runtimeOrigin !== this.record.resources.runtimeOrigin || + config.release?.operationId !== deployedOperationId || + config.release?.artifactDigest !== artifact.identity.artifactDigest || + config.release?.version !== artifact.identity.version + ) + return yield* new ResourceConflict(); + const configDigest = yield* fingerprint(config); + if (expectedConfigDigest && configDigest !== expectedConfigDigest) + return yield* new ResourceConflict(); + yield* this.worker.verifyWorker(settings, config); + const codeHash = yield* this.worker.verifyContent( + code.kind === "artifact" ? artifact : undefined, + ); + if (code.kind === "source" && code.hash && codeHash !== code.hash) + return yield* new ResourceConflict(); + const namespaces = yield* this.worker.namespaces( + config, + artifact, + code.kind === "source", + ); + if ( + namespaces.personalAgentNamespaceId !== + this.record.resources.personalAgentNamespaceId || + namespaces.sandboxNamespaceId !== + this.record.resources.sandboxNamespaceId + ) + return yield* new ResourceConflict(); + const version = yield* this.account.request( + `workers/scripts/${this.record.resources.workerName}/versions/${deployment.versionId}`, + ); + const resources = + object(version) && object(version.resources) + ? version.resources + : undefined; + const runtime = resources?.script_runtime; + // The version API reports the normalized defaults of assets.config: {}. + // These and the container link are deployment-owned, not inherited settings. + if ( + !object(runtime) || + !eq(runtime.assets, { + serve_directly: true, + raw_run_worker_first: false, + }) || + !eq(runtime.containers, [ + { + name: this.record.resources.sandboxApplicationName, + class_name: "Sandbox", + }, + ]) + ) + return yield* new ResourceConflict(); + const metadata: Record = {}; + const tags = settings.tags === undefined ? [] : settings.tags; + if (!Array.isArray(tags) || tags.some((tag) => typeof tag !== "string")) + return yield* new ResourceConflict(); + metadata.tags = tags; + if (settings.annotations !== undefined) { + if (!object(settings.annotations)) return yield* new ResourceConflict(); + const annotations: Record = {}; + for (const [key, value] of Object.entries(settings.annotations)) { + if (typeof value !== "string") return yield* new ResourceConflict(); + // Cloudflare regenerates this read-only provenance on each upload. + if (key === "workers/triggered_by") continue; + const limit = + key === "workers/message" ? 1000 : key === "workers/tag" ? 100 : 0; + if (!limit || new TextEncoder().encode(value).length > limit) + return yield* new ResourceConflict(); + annotations[key] = value; + } + if (Object.keys(annotations).length) metadata.annotations = annotations; + } + // Preserve native upload fields. Unknown settings fail closed before assets + // staging instead of relying on omission to retain customer configuration. + const preserved = [ + "limits", + "placement", + "observability", + "tail_consumers", + "logpush", + "usage_model", + ]; + for (const key of Object.keys(settings)) { + if ( + [ + "bindings", + "compatibility_date", + "compatibility_flags", + "created_on", + "modified_on", + "etag", + "has_assets", + "last_deployed_from", + "tags", + "annotations", + ].includes(key) + ) + continue; + if (!preserved.includes(key)) return yield* new ResourceConflict(); + metadata[key] = settings[key]; + } + for (const key of Object.keys(runtime)) { + if ( + [ + "compatibility_date", + "compatibility_flags", + "exports", + "assets", + "containers", + ].includes(key) + ) + continue; + if (!preserved.includes(key)) return yield* new ResourceConflict(); + metadata[key] = runtime[key]; + } + const app = yield* this.containers.findApplication(); + if (!app || app.id !== this.record.resources.sandboxApplicationId) + return yield* new ResourceConflict(); + yield* this.worker.endpoint(); + if (!eq(deployment, yield* this.worker.activeDeployment())) + return yield* new ResourceConflict(); + return { + ...deployment, + settings, + config, + configDigest, + codeHash, + metadata, + app, + fingerprint: yield* fingerprint({ + bindings: settings.bindings.filter( + (b) => b.name !== "FLAREBOT_INSTALLATION", + ), + metadata, + namespaces, + }), + containerFingerprint: yield* this.containers.containerFingerprint(app), + }; + }); + } + baseline( + source: ReleaseIdentity, + target: Artifact, + deployedOperationId: string, + configDigest: string | null, + ) { + return Effect.gen({ self: this }, function* () { + const observed = yield* this.observe( + { ...target, identity: source }, + deployedOperationId, + configDigest, + { kind: "source", hash: null }, + ); + // The current contract supports only this durable-object identity and + // customer-owned Sandbox shape. It does not assert that old code equals an + // archived publisher bundle; the observed hash is pinned for race checks. + if (!this.containers.matchesSupportedContainer(observed.app)) + return yield* new ResourceConflict(); + return { + fromRelease: { + version: source.version, + sourceRevision: source.sourceRevision, + artifactDigest: source.artifactDigest, + }, + toRelease: target.identity, + deployedOperationId, + configDigest: observed.configDigest, + versionId: observed.versionId, + deploymentId: observed.deploymentId, + sourceCodeHash: observed.codeHash, + fingerprint: observed.fingerprint, + containerFingerprint: observed.containerFingerprint, + targetContainerFingerprint: yield* this.containers.containerFingerprint( + { + ...observed.app, + ...this.containers.desiredContainer(target), + configuration: { + ...observed.app.configuration, + ...this.containers.desiredContainer(target).configuration, + }, + }, + ), + }; + }); + } +} diff --git a/control-plane/domain-api.ts b/control-plane/domain-api.ts index e649c1b..d76f670 100644 --- a/control-plane/domain-api.ts +++ b/control-plane/domain-api.ts @@ -1,176 +1,235 @@ -import type { Installation } from "./installation-metadata.ts"; +import type { CloudflareOpContext } from "@distilled.cloud/cloudflare"; +import { listRecords } from "@distilled.cloud/cloudflare/dns"; +import { + BadRequest, + Conflict, + Forbidden, + NotFound, + Unauthorized, +} from "@distilled.cloud/cloudflare/Errors"; +import { deleteDomain, listDomains } from "@distilled.cloud/cloudflare/workers"; +import { getZone, listZones } from "@distilled.cloud/cloudflare/zones"; +import * as Effect from "effect/Effect"; +import { cloudflare } from "./cloudflare-sdk.ts"; import { - domainFail, - domainIdentifier, - type DomainRecord, -} from "./domain-metadata.ts"; + DomainAccountDenied, + DomainAttachmentOutcomeUnknown, + DomainInvalidHostname, + DomainReauthorizationRequired, + DomainResourceConflict, + DomainTemporarilyUnavailable, +} from "./domain-errors.ts"; +import { domainIdentifier, type DomainRecord } from "./domain-metadata.ts"; +import type { Installation } from "./installation-metadata.ts"; +import { bodyJson, withResponse } from "./transport.ts"; const identifier = (value: unknown): value is string => typeof value === "string" && /^[a-f0-9]{32}$/.test(value); -export async function domainJson(response: Response): Promise { - const reader = response.body?.getReader(); - if (!reader) domainFail("temporarily_unavailable"); - let text = ""; - let size = 0; - const decoder = new TextDecoder(); - while (true) { - const chunk = await reader.read(); - if (chunk.done) break; - size += chunk.value.byteLength; - if (size > 128 * 1024) { - await reader.cancel(); - domainFail("temporarily_unavailable"); - } - text += decoder.decode(chunk.value, { stream: true }); - } - try { - return JSON.parse(text + decoder.decode()); - } catch { - return domainFail("temporarily_unavailable"); - } -} +const object = (value: unknown): value is Record => + value !== null && typeof value === "object" && !Array.isArray(value); -// Fixed provider endpoints; the token never leaves this operation's call stack. +// Fixed provider endpoints; each Workflow attempt supplies fresh authorization. export class DomainAPI { constructor( private readonly token: string, private readonly record: Installation, private readonly network: typeof fetch = fetch, ) {} - private async request( - path: string, - method = "GET", - body?: unknown, - missing = false, - ) { - const network = this.network; - let response; - try { - response = await network(`https://api.cloudflare.com/client/v4/${path}`, { - method, - redirect: "manual", - signal: AbortSignal.timeout(15_000), - headers: { - Authorization: `Bearer ${this.token}`, - ...(body === undefined ? {} : { "Content-Type": "application/json" }), - }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - }); - } catch { - return domainFail("temporarily_unavailable"); - } - if (response.status === 401) domainFail("reauthorization_required"); - if (response.status === 403) domainFail("account_denied"); - if (response.status === 404 && missing) return null; - if ([400, 409].includes(response.status)) domainFail("resource_conflict"); - if (!response.ok) domainFail("temporarily_unavailable"); - const envelope = await domainJson(response); - if (envelope?.success !== true || !("result" in envelope)) - domainFail("temporarily_unavailable"); - return envelope; + private sdk(operation: Effect.Effect) { + return cloudflare( + operation, + this.token, + this.network, + 15_000, + 128 * 1024, + ).pipe(Effect.mapError(providerFailure)); } - async zones() { - const zones: { id: string; name: string }[] = []; - for (let page = 1; page <= 20; page++) { - const result = await this.request( - `zones?account.id=${this.record.accountId}&status=active&per_page=50&page=${page}`, - ); - if (!Array.isArray(result.result)) domainFail("temporarily_unavailable"); - for (const zone of result.result) { + zones() { + return Effect.gen({ self: this }, function* () { + const zones: { id: string; name: string }[] = []; + for (let page = 1; page <= 20; page++) { + const envelope = yield* this.sdk( + listZones({ + account: { id: this.record.accountId }, + status: "active", + perPage: 50, + page, + }), + ); + if (!envelope || !Array.isArray(envelope.result)) + return yield* new DomainTemporarilyUnavailable(); + const pages = envelope.resultInfo?.totalPages; + if ( + envelope.resultInfo != null && + (typeof pages !== "number" || !Number.isInteger(pages) || pages < 0) + ) + return yield* new DomainTemporarilyUnavailable(); + for (const zone of envelope.result) { + if ( + !object(zone) || + !identifier(zone.id) || + typeof zone.name !== "string" || + !object(zone.account) || + zone.account.id !== this.record.accountId || + zone.status !== "active" + ) + return yield* new DomainAccountDenied(); + zones.push({ id: zone.id, name: zone.name }); + } if ( - !identifier(zone.id) || - typeof zone.name !== "string" || - zone.account?.id !== this.record.accountId || - zone.status !== "active" + envelope.resultInfo?.totalPages == null || + envelope.resultInfo.totalPages <= page ) - domainFail("account_denied"); - zones.push({ id: zone.id, name: zone.name }); + return zones; } - if (!result.result_info || result.result_info.total_pages <= page) - return zones; - } - return domainFail("temporarily_unavailable"); + return yield* new DomainTemporarilyUnavailable(); + }); } - async zone(zoneId: string, hostname: string) { - const { result: zone } = await this.request(`zones/${zoneId}`); - if ( - zone.id !== zoneId || - zone.account?.id !== this.record.accountId || - zone.status !== "active" - ) - domainFail("account_denied"); - if ( - typeof zone.name !== "string" || - !(hostname === zone.name || hostname.endsWith(`.${zone.name}`)) - ) - domainFail("invalid_hostname"); + zone(zoneId: string, hostname: string) { + return Effect.gen({ self: this }, function* () { + const zone = yield* this.sdk(getZone({ zoneId })); + if ( + !object(zone) || + zone.id !== zoneId || + !object(zone.account) || + zone.account.id !== this.record.accountId || + zone.status !== "active" + ) + return yield* new DomainAccountDenied(); + if ( + typeof zone.name !== "string" || + !(hostname === zone.name || hostname.endsWith(`.${zone.name}`)) + ) + return yield* new DomainInvalidHostname(); + }); } - private get domainsPath() { - return `accounts/${this.record.accountId}/workers/domains`; - } - private verify(value: any, domain: DomainRecord) { - if ( - !domainIdentifier.safeParse(value?.id).success || - value.hostname !== domain.hostname || - value.zone_id !== domain.zoneId || - value.service !== this.record.resources.workerName || - (value.environment && value.environment !== "production") - ) - domainFail("resource_conflict"); - return value.id as string; - } - async find(domain: DomainRecord): Promise { - const envelope = await this.request( - `${this.domainsPath}?hostname=${encodeURIComponent(domain.hostname)}`, - ); - if ( - !Array.isArray(envelope.result) || - envelope.result.length > 1 || - (envelope.result_info?.total_pages ?? 1) > 1 - ) - domainFail("resource_conflict"); - if (!envelope.result.length) return null; - return this.verify(envelope.result[0], domain); + find(domain: DomainRecord) { + return Effect.gen({ self: this }, function* () { + const envelope = yield* this.sdk( + listDomains({ + accountId: this.record.accountId, + hostname: domain.hostname, + }), + ); + if ( + !envelope || + !Array.isArray(envelope.result) || + envelope.result.length > 1 || + (envelope.resultInfo?.totalPages ?? 1) > 1 + ) + return yield* new DomainResourceConflict(); + if (!envelope.result.length) return null; + const value: unknown = envelope.result[0]; + if ( + !object(value) || + typeof value.id !== "string" || + !domainIdentifier.safeParse(value.id).success || + value.hostname !== domain.hostname || + value.zoneId !== domain.zoneId || + value.service !== this.record.resources.workerName || + (value.environment && value.environment !== "production") + ) + return yield* new DomainResourceConflict(); + return value.id; + }); } - async preflight(domain: DomainRecord) { - await this.zone(domain.zoneId, domain.hostname); - if (await this.find(domain)) domainFail("resource_conflict"); - const { result } = await this.request( - `zones/${domain.zoneId}/dns_records?name=${encodeURIComponent(domain.hostname)}&per_page=1`, - ); - if (!Array.isArray(result) || result.length) - domainFail("resource_conflict"); + preflight(domain: DomainRecord) { + return Effect.gen({ self: this }, function* () { + yield* this.zone(domain.zoneId, domain.hostname); + if (yield* this.find(domain)) return yield* new DomainResourceConflict(); + const envelope = yield* this.sdk( + listRecords({ + zoneId: domain.zoneId, + name: { exact: domain.hostname }, + perPage: 1, + }), + ); + if ( + !envelope || + !Array.isArray(envelope.result) || + envelope.result.length + ) + return yield* new DomainResourceConflict(); + }); } - async attach(domain: DomainRecord) { - if (!domain.writeIntent || domain.domainId) domainFail("resource_conflict"); - // Wrangler's native records interface provides commit-time conflict guards. - // Preserve domains outside this request, and NEVER opt into DNS/Worker takeover. - await this.request( - `accounts/${this.record.accountId}/workers/scripts/${this.record.resources.workerName}/domains/records`, - "PUT", - { - override_scope: false, - override_existing_origin: false, - override_existing_dns_record: false, - origins: [{ hostname: domain.hostname, zone_id: domain.zoneId }], - }, - ); + attach(domain: DomainRecord) { + return Effect.gen({ self: this }, function* () { + if (!domain.writeIntent || domain.domainId) + return yield* new DomainResourceConflict(); + // Preserve unrelated domains and never opt into DNS/Worker takeover. + // Distilled does not expose this no-takeover attachment operation. Keep + // its native contract until the SDK supports the three override guards. + yield* withResponse( + this.network, + `https://api.cloudflare.com/client/v4/accounts/${this.record.accountId}/workers/scripts/${this.record.resources.workerName}/domains/records`, + { + method: "PUT", + headers: { + Authorization: `Bearer ${this.token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + override_scope: false, + override_existing_origin: false, + override_existing_dns_record: false, + origins: [{ hostname: domain.hostname, zone_id: domain.zoneId }], + }), + }, + 15_000, + new DomainTemporarilyUnavailable(), + (response) => + Effect.gen(function* () { + if (response.status === 401) + return yield* new DomainReauthorizationRequired(); + if (response.status === 403) + return yield* new DomainAccountDenied(); + if ([400, 409].includes(response.status)) + return yield* new DomainResourceConflict(); + if (!response.ok) return yield* new DomainTemporarilyUnavailable(); + const envelope = yield* bodyJson( + response, + 128 * 1024, + new DomainTemporarilyUnavailable(), + ); + if ( + !object(envelope) || + envelope.success !== true || + !("result" in envelope) + ) + return yield* new DomainTemporarilyUnavailable(); + }), + ); + }); } - async remove(domain: DomainRecord) { - // No write was attempted: abandoning a failed preflight must not detach - // someone else's pre-existing hostname, even if it targets this Worker. - if (!domain.writeIntent) return; - if (!domain.domainId) domainFail("attachment_outcome_unknown"); - const found = await this.find(domain); - if (!found) return; - if (domain.domainId && found !== domain.domainId) - domainFail("resource_conflict"); - await this.request( - `${this.domainsPath}/${found}`, - "DELETE", - undefined, - true, - ); - if (await this.find(domain)) domainFail("temporarily_unavailable"); + remove(domain: DomainRecord) { + return Effect.gen({ self: this }, function* () { + // Abandoning a failed preflight must not detach an existing hostname. + if (!domain.writeIntent) return; + if (!domain.domainId) return yield* new DomainAttachmentOutcomeUnknown(); + const found = yield* this.find(domain); + if (!found) return; + if (found !== domain.domainId) return yield* new DomainResourceConflict(); + yield* this.sdk( + deleteDomain({ + accountId: this.record.accountId, + domainId: found, + }).pipe( + Effect.catchIf( + (error) => error instanceof NotFound, + () => Effect.void, + ), + ), + ); + if (yield* this.find(domain)) + return yield* new DomainTemporarilyUnavailable(); + }); } } + +function providerFailure(error: unknown) { + if (error instanceof Unauthorized) return new DomainReauthorizationRequired(); + if (error instanceof Forbidden) return new DomainAccountDenied(); + if (error instanceof BadRequest || error instanceof Conflict) + return new DomainResourceConflict(); + return new DomainTemporarilyUnavailable(); +} diff --git a/control-plane/domain-errors.ts b/control-plane/domain-errors.ts new file mode 100644 index 0000000..26ee0d8 --- /dev/null +++ b/control-plane/domain-errors.ts @@ -0,0 +1,78 @@ +import * as Data from "effect/Data"; + +export class DomainResourceConflict extends Data.TaggedError( + "DomainResourceConflict", +) { + readonly code = "resource_conflict"; + override readonly message = this.code; +} + +export class DomainReauthorizationRequired extends Data.TaggedError( + "DomainReauthorizationRequired", +) { + readonly code = "reauthorization_required"; + override readonly message = this.code; +} + +export class DomainTemporarilyUnavailable extends Data.TaggedError( + "DomainTemporarilyUnavailable", +) { + readonly code = "temporarily_unavailable"; + override readonly message = this.code; +} + +export class DomainSetupRequired extends Data.TaggedError( + "DomainSetupRequired", +) { + readonly code = "setup_required"; + override readonly message = this.code; +} + +export class DomainAccountDenied extends Data.TaggedError( + "DomainAccountDenied", +) { + readonly code = "account_denied"; + override readonly message = this.code; +} + +export class DomainInvalidHostname extends Data.TaggedError( + "DomainInvalidHostname", +) { + readonly code = "invalid_hostname"; + override readonly message = this.code; +} + +export class DomainHttpsPending extends Data.TaggedError("DomainHttpsPending") { + readonly code = "https_pending"; + override readonly message = this.code; +} + +export class DomainAttachmentOutcomeUnknown extends Data.TaggedError( + "DomainAttachmentOutcomeUnknown", +) { + readonly code = "attachment_outcome_unknown"; + override readonly message = this.code; +} + +const errors = { + resource_conflict: DomainResourceConflict, + reauthorization_required: DomainReauthorizationRequired, + temporarily_unavailable: DomainTemporarilyUnavailable, + setup_required: DomainSetupRequired, + account_denied: DomainAccountDenied, + invalid_hostname: DomainInvalidHostname, + https_pending: DomainHttpsPending, + attachment_outcome_unknown: DomainAttachmentOutcomeUnknown, +}; + +export type DomainErrorCode = keyof typeof errors; +export type DomainFailure = InstanceType<(typeof errors)[DomainErrorCode]>; +export function domainFailure(code: DomainErrorCode): DomainFailure { + return new errors[code](); +} +export function isDomainFailure(error: unknown): error is DomainFailure { + return Object.values(errors).some((ErrorType) => error instanceof ErrorType); +} +export function domainErrorCode(error: unknown): DomainErrorCode { + return isDomainFailure(error) ? error.code : "temporarily_unavailable"; +} diff --git a/control-plane/domain-lifecycle.ts b/control-plane/domain-lifecycle.ts new file mode 100644 index 0000000..3703d71 --- /dev/null +++ b/control-plane/domain-lifecycle.ts @@ -0,0 +1,107 @@ +import { z } from "zod"; +import { + domainRecord, + type DomainRecord, + type DomainStart, +} from "./domain-metadata.ts"; +import { InstallationConflict } from "./installation-errors.ts"; +import { parse } from "./installation-metadata.ts"; + +export function beginDomain( + current: DomainRecord | null, + intent: DomainStart, + operationId: string, + deadline: number, +): DomainRecord { + if (current && ["connecting", "removing"].includes(current.status)) + throw new InstallationConflict(); + const next = { + revision: (current?.revision ?? 0) + 1, + deadline, + operationId, + errorCode: null, + }; + switch (intent.action) { + case "attach": + if (current && current.status !== "removed") + throw new InstallationConflict(); + return parse(domainRecord, { + ...next, + action: "attach", + status: "connecting", + origin: intent.origin, + hostname: new URL(intent.origin).hostname, + zoneId: intent.zoneId, + domainId: null, + writeIntent: false, + }); + case "retry": + if (!current || current.status !== "failed") + throw new InstallationConflict(); + return parse(domainRecord, { + ...current, + ...next, + status: current.action === "attach" ? "connecting" : "removing", + }); + case "remove": + if ( + !current || + current.status === "removed" || + (current.writeIntent && !current.domainId) + ) + throw new InstallationConflict(); + return parse(domainRecord, { + ...current, + ...next, + action: "remove", + status: "removing", + origin: null, + }); + } +} + +const domainChanges = z.strictObject({ + status: domainRecord.shape.status, + errorCode: domainRecord.shape.errorCode, + domainId: domainRecord.shape.domainId, + writeIntent: z.boolean(), +}); +export type DomainChanges = z.infer; + +export function changeDomain( + current: DomainRecord, + operationId: string, + revision: number, + input: DomainChanges, +): DomainRecord { + if ( + current.operationId !== operationId || + current.revision !== revision || + !["connecting", "removing"].includes(current.status) + ) + throw new InstallationConflict(); + const changes = parse(domainChanges, input); + const definitiveRejection = + current.domainId === null && + changes.status === "failed" && + [ + "resource_conflict", + "account_denied", + "reauthorization_required", + ].includes(changes.errorCode ?? ""); + if ( + (current.writeIntent && !changes.writeIntent && !definitiveRejection) || + (current.domainId && current.domainId !== changes.domainId) || + (current.action === "attach" && + ["removing", "removed"].includes(changes.status)) || + (current.action === "remove" && + ["connecting", "active"].includes(changes.status)) || + (changes.status === "active" && (!changes.domainId || !changes.writeIntent)) + ) + throw new InstallationConflict(); + return parse(domainRecord, { + ...current, + ...changes, + revision: current.revision + 1, + }); +} diff --git a/control-plane/domain-metadata.ts b/control-plane/domain-metadata.ts index 8e03677..cf33faf 100644 --- a/control-plane/domain-metadata.ts +++ b/control-plane/domain-metadata.ts @@ -12,15 +12,6 @@ export const domainErrors = z.enum([ "https_pending", "attachment_outcome_unknown", ]); -export type DomainErrorCode = z.infer; -export class DomainError extends Error { - constructor(readonly code: DomainErrorCode) { - super(code); - } -} -export function domainFail(code: DomainErrorCode): never { - throw new DomainError(code); -} export const domainOrigin = z .string() .refine((value) => customDomainOrigin(value) === value); @@ -51,7 +42,7 @@ export const domainRecord = z value.origin === (value.action === "attach" ? `https://${value.hostname}` : null), ); -export type DomainRecord = z.infer; +export interface DomainRecord extends z.infer {} export const domainStart = z.discriminatedUnion("action", [ z.strictObject({ action: z.literal("attach"), diff --git a/control-plane/domain-operations.ts b/control-plane/domain-operations.ts new file mode 100644 index 0000000..d172f2c --- /dev/null +++ b/control-plane/domain-operations.ts @@ -0,0 +1,81 @@ +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import type { DomainAPI } from "./domain-api.ts"; +import { + DomainAccountDenied, + DomainAttachmentOutcomeUnknown, + DomainReauthorizationRequired, + DomainResourceConflict, +} from "./domain-errors.ts"; +import type { DomainRecord } from "./domain-metadata.ts"; +import type { RegistryFailure } from "./installation-errors.ts"; + +type DomainProvider = Pick; +export type SaveDomain = ( + domain: DomainRecord, + changes: Partial< + Pick + >, +) => Effect.Effect; + +export function attachDomain( + api: DomainProvider, + saved: DomainRecord, + save: SaveDomain, +) { + return Effect.gen(function* () { + let domain = saved; + if (!domain.writeIntent) { + yield* api.preflight(domain); + domain = yield* save(domain, { writeIntent: true }); + // Only a definitive rejection reopens permission to PUT. An interrupted, + // timed-out or lost reply leaves the durable intent intact for observation. + const rejected = ( + error: + | DomainResourceConflict + | DomainAccountDenied + | DomainReauthorizationRequired, + ) => + save(domain, { + writeIntent: false, + status: "failed", + errorCode: error.code, + }).pipe(Effect.andThen(Effect.fail(error))); + yield* api.attach(domain).pipe( + Effect.catchTags({ + DomainResourceConflict: rejected, + DomainAccountDenied: rejected, + DomainReauthorizationRequired: rejected, + }), + ); + } + const domainId = yield* api.find(domain); + if (!domainId) return yield* new DomainAttachmentOutcomeUnknown(); + if (domain.domainId && domain.domainId !== domainId) + return yield* new DomainResourceConflict(); + if (domain.domainId !== domainId) yield* save(domain, { domainId }); + }); +} + +export function verifyDomainMapping( + api: Pick, + domain: DomainRecord, +) { + return Effect.gen(function* () { + if ((yield* api.find(domain)) !== domain.domainId) + return yield* new DomainResourceConflict(); + }); +} + +export function checkDomainHttps( + api: Pick, + domain: DomainRecord, + health: Effect.Effect, +) { + return Effect.gen(function* () { + yield* verifyDomainMapping(api, domain); + // A failed health probe means TLS/runtime readiness is still pending. + // Mapping and authorization errors above must remain terminal observations. + return Exit.isSuccess(yield* Effect.exit(health)); + }); +} diff --git a/control-plane/domain-setup.ts b/control-plane/domain-setup.ts index a668a82..a0b669a 100644 --- a/control-plane/domain-setup.ts +++ b/control-plane/domain-setup.ts @@ -1,172 +1,188 @@ -import { customDomainOrigin } from "../shared/domain-origin.ts"; +import * as Effect from "effect/Effect"; import { DOMAIN_CONFIGURATION_PURPOSE } from "../shared/bridge.ts"; +import { customDomainOrigin } from "../shared/domain-origin.ts"; +import { signBridgeAssertion } from "./bridge.ts"; import { configuration, type Env } from "./config.ts"; -import { DomainAPI, domainJson } from "./domain-api.ts"; +import { DomainAPI } from "./domain-api.ts"; import { - domainFail, - type DomainStart, - type DomainRecord, -} from "./domain-metadata.ts"; + DomainAccountDenied, + DomainAttachmentOutcomeUnknown, + DomainInvalidHostname, + DomainReauthorizationRequired, + DomainSetupRequired, + DomainTemporarilyUnavailable, +} from "./domain-errors.ts"; +import { type DomainRecord, type DomainStart } from "./domain-metadata.ts"; +import { form, privateResponse } from "./http-response.ts"; +import { ownedInstallation } from "./installation-access.ts"; import { - authenticatedPrincipal, - authorizedGrant, - grantedAccounts, - vault, -} from "./session.ts"; -import { ownedInstallation, installationRegistry } from "./installations.ts"; + InstallationConflict, + InvalidMetadata, +} from "./installation-errors.ts"; import { installationId, parse, - unwrap, - InstallationError, type Installation, } from "./installation-metadata.ts"; -import { form, privateResponse } from "./http.ts"; -import { signBridgeAssertion } from "./bridge.ts"; +import { installationRegistry } from "./registry-client.ts"; +import { + authenticatedPrincipal, + authorizedGrant, + grantedAccounts, + vault, +} from "./session.ts"; +import { bodyJson, withResponse } from "./transport.ts"; +import { ensureWorkflow, terminateWorkflow } from "./workflow-instance.ts"; export function requireDomainCapability(env: Env, scopes: string[]) { - const manifest = configuration(env).oauthCapabilities!; - for (const capability of ["domain-zones", "domain-routing"] as const) { - const required = manifest.scopes.filter((scope) => - scope.capabilities.includes(capability), - ); - if (!required.length) domainFail("setup_required"); - if (required.some((scope) => !scopes.includes(scope.id))) - domainFail("reauthorization_required"); - } + return Effect.gen(function* () { + const manifest = (yield* configuration(env).pipe( + Effect.mapError(() => new DomainSetupRequired()), + )).oauthCapabilities!; + for (const capability of ["domain-zones", "domain-routing"] as const) { + const required = manifest.scopes.filter((scope) => + scope.capabilities.includes(capability), + ); + if (!required.length) return yield* new DomainSetupRequired(); + if (required.some((scope) => !scopes.includes(scope.id))) + return yield* new DomainReauthorizationRequired(); + } + }); } -export async function configureRuntimeDomain( +export function configureRuntimeDomain( env: Env, record: Installation, domain: DomainRecord, network: typeof fetch = fetch, ) { - const assertion = await signBridgeAssertion(env, { - aud: record.resources.runtimeOrigin!, - sub: record.ownerSubject, - installationId: record.installationId, - purpose: DOMAIN_CONFIGURATION_PURPOSE, - state: String(domain.revision), - challenge: domain.origin ?? "", - }); - const response = await network( - new URL("/auth/domain-configuration", record.resources.runtimeOrigin!), - { - method: "POST", - redirect: "manual", - signal: AbortSignal.timeout(15_000), - headers: { Authorization: `Bearer ${assertion}` }, - }, - ); - if (!response.ok) - domainFail( - response.status === 404 ? "setup_required" : "temporarily_unavailable", + return Effect.gen(function* () { + const assertion = yield* signBridgeAssertion(env, { + aud: record.resources.runtimeOrigin!, + sub: record.ownerSubject, + installationId: record.installationId, + purpose: DOMAIN_CONFIGURATION_PURPOSE, + state: String(domain.revision), + challenge: domain.origin ?? "", + }).pipe(Effect.mapError(() => new DomainTemporarilyUnavailable())); + yield* withResponse( + network, + new URL("/auth/domain-configuration", record.resources.runtimeOrigin!), + { + method: "POST", + headers: { Authorization: `Bearer ${assertion}` }, + }, + 15_000, + new DomainTemporarilyUnavailable(), + (response) => + Effect.gen(function* () { + if (!response.ok) + return yield* response.status === 404 + ? new DomainSetupRequired() + : new DomainTemporarilyUnavailable(); + const result = yield* bodyJson( + response, + 128 * 1024, + new DomainTemporarilyUnavailable(), + ); + if ( + !result || + typeof result !== "object" || + !("revision" in result) || + !("origin" in result) || + result.revision !== domain.revision || + result.origin !== domain.origin || + Object.keys(result).sort().join() !== "origin,revision" + ) + return yield* new DomainTemporarilyUnavailable(); + }), ); - const result = await domainJson(response); - if ( - result?.revision !== domain.revision || - result?.origin !== domain.origin || - Object.keys(result).sort().join() !== "origin,revision" - ) - domainFail("temporarily_unavailable"); + }); } // Called only after the installation router's exact-Origin mutation guard. -export async function setupDomain( +export function setupDomain( request: Request, env: Env, id: string, action: string | undefined, network: typeof fetch, ) { - const record = await ownedInstallation(request, env, id); - if (!record.installedRelease || !record.resources.runtimeOrigin) - domainFail("setup_required"); - const principal = await authenticatedPrincipal(request, env); - const registry = installationRegistry(env, principal.subject); - const json = (data: unknown, status = 200) => - privateResponse(Response.json(data, { status })); - if (request.method === "GET" && !action) - return json({ - installationId: id, - accountId: record.accountId, - runtimeOrigin: record.resources.runtimeOrigin, - domain: unwrap(await registry.getDomain(principal.subject, id)), - }); - const grant = await authorizedGrant(env, principal); - requireDomainCapability(env, grant.scopes); - if ( - !(await grantedAccounts(env, principal, network)).some( - (account) => account.id === record.accountId, + return Effect.gen(function* () { + const record = yield* ownedInstallation(request, env, id); + if (!record.installedRelease || !record.resources.runtimeOrigin) + return yield* new DomainSetupRequired(); + const principal = yield* authenticatedPrincipal(request, env); + const registry = installationRegistry(env, principal.subject); + const json = (data: unknown, status = 200) => + privateResponse(Response.json(data, { status })); + if (request.method === "GET" && !action) + return json({ + installationId: id, + accountId: record.accountId, + runtimeOrigin: record.resources.runtimeOrigin, + domain: yield* registry.getDomain(principal.subject, id), + }); + const grant = yield* authorizedGrant(env, principal); + yield* requireDomainCapability(env, grant.scopes); + if ( + !(yield* grantedAccounts(env, principal, network)).some( + (account) => account.id === record.accountId, + ) + ) + return yield* new DomainAccountDenied(); + const api = new DomainAPI(grant.accessToken, record, network); + if (request.method === "GET" && action === "zones") + return json({ zones: yield* api.zones() }); + if ( + request.method !== "POST" || + (action && !["remove", "retry"].includes(action)) ) - ) - domainFail("account_denied"); - const api = new DomainAPI(grant.accessToken, record, network); - if (request.method === "GET" && action === "zones") - return json({ zones: await api.zones() }); - if ( - request.method !== "POST" || - (action && !["remove", "retry"].includes(action)) - ) - throw new InstallationError("invalid_metadata"); - const input = await form(request); - const keys = action ? ["requestId"] : ["requestId", "zoneId", "hostname"]; - if ( - [...input.keys()].length !== keys.length || - keys.some((key) => input.getAll(key).length !== 1) - ) - throw new InstallationError("invalid_metadata"); - const requestId = parse(installationId, input.get("requestId")); - let intent: DomainStart; - if (action) intent = { action: action as "remove" | "retry" }; - else { - const hostname = input.get("hostname")!.trim().toLowerCase(); - const origin = customDomainOrigin(`https://${hostname}`); - if (!origin) domainFail("invalid_hostname"); - const zoneId = parse(installationId, input.get("zoneId")); - await api.zone(zoneId, hostname); - intent = { action: "attach", origin, zoneId }; - } - if (grant.expiresAt < Date.now() + 120_000) - domainFail("reauthorization_required"); - if ( - action && - !unwrap(await registry.domainReplay(principal.subject, id, requestId)) - ) { - const previous = unwrap(await registry.getDomain(principal.subject, id)); + return yield* new InvalidMetadata(); + const input = yield* form(request); + const keys = action ? ["requestId"] : ["requestId", "zoneId", "hostname"]; if ( - action === "remove" && - previous?.action === "attach" && - (previous.status === "connecting" || - (previous.writeIntent && !previous.domainId)) + [...input.keys()].length !== keys.length || + keys.some((key) => input.getAll(key).length !== 1) ) - domainFail("attachment_outcome_unknown"); - if (previous && ["connecting", "removing"].includes(previous.status)) { - // Stop the old execution before a new intent can supersede it. Remote - // writes already in flight are reconciled using the preserved write intent. - try { - const workflow = await env.INSTALLATION_WORKFLOW.get( - previous.operationId, + return yield* new InvalidMetadata(); + const requestId = parse(installationId, input.get("requestId")); + let intent: DomainStart; + if (action) intent = { action: action as "remove" | "retry" }; + else { + const hostname = input.get("hostname")!.trim().toLowerCase(); + const origin = customDomainOrigin(`https://${hostname}`); + if (!origin) return yield* new DomainInvalidHostname(); + const zoneId = parse(installationId, input.get("zoneId")); + yield* api.zone(zoneId, hostname); + intent = { action: "attach", origin, zoneId }; + } + if (grant.expiresAt < Date.now() + 120_000) + return yield* new DomainReauthorizationRequired(); + if ( + action && + !(yield* registry.domainReplay(principal.subject, id, requestId)) + ) { + const previous = yield* registry.getDomain(principal.subject, id); + if ( + action === "remove" && + previous?.action === "attach" && + (previous.status === "connecting" || + (previous.writeIntent && !previous.domainId)) + ) + return yield* new DomainAttachmentOutcomeUnknown(); + if (previous && ["connecting", "removing"].includes(previous.status)) { + // Stop the old execution before a new intent can supersede it. Remote + // writes already in flight are reconciled using the preserved write intent. + yield* terminateWorkflow(env, previous.operationId).pipe( + Effect.catch((error) => + error.missing ? Effect.void : new DomainTemporarilyUnavailable(), + ), ); - await workflow.terminate(); - if ((await workflow.status()).status !== "terminated") - domainFail("temporarily_unavailable"); - } catch (error) { - if ( - ![ - "instance.not_found", - "Error: instance.not_found", - "(instance.not_found) Instance not found", - ].includes((error as Error)?.message) - ) - domainFail("temporarily_unavailable"); - } - const current = unwrap(await registry.getDomain(principal.subject, id)); - if (current?.operationId !== previous.operationId) - throw new InstallationError("installation_conflict"); - if (["connecting", "removing"].includes(current.status)) - unwrap( - await registry.updateDomain( + const current = yield* registry.getDomain(principal.subject, id); + if (current?.operationId !== previous.operationId) + return yield* new InstallationConflict(); + if (["connecting", "removing"].includes(current.status)) + yield* registry.updateDomain( principal.subject, id, current.operationId, @@ -177,52 +193,39 @@ export async function setupDomain( domainId: current.domainId, writeIntent: current.writeIntent, }, - ), - ); - else if ( - action === "retry" && - ["active", "removed"].includes(current.status) - ) - return json({ domain: current }); + ); + else if ( + action === "retry" && + ["active", "removed"].includes(current.status) + ) + return json({ domain: current }); + } } - } - const domain = unwrap( - await registry.startDomain( + const domain = yield* registry.startDomain( principal.subject, id, requestId, intent, Math.min(Date.now() + 20 * 60_000, grant.expiresAt - 30_000), - ), - ); - if (!["connecting", "removing"].includes(domain.status)) - return json({ domain }); - // The same reservation repairs ambiguous vault or Workflow creation replies. - await vault(env, "operation", domain.operationId).createOperation({ - subject: principal.subject, - accountId: record.accountId, - installationId: id, - operationId: domain.operationId, - grantRef: principal.grantRef, - expiresAt: domain.deadline, - bootstrapSecret: null, - }); - try { - await env.INSTALLATION_WORKFLOW.create({ - id: domain.operationId, - params: { - ownerSubject: principal.subject, - installationId: id, - operationId: domain.operationId, - kind: "domain", - }, + ); + if (!["connecting", "removing"].includes(domain.status)) + return json({ domain }); + // The same reservation repairs ambiguous vault or Workflow creation replies. + yield* vault(env, "operation", domain.operationId).createOperation({ + subject: principal.subject, + accountId: record.accountId, + installationId: id, + operationId: domain.operationId, + grantRef: principal.grantRef, + expiresAt: domain.deadline, + bootstrapSecret: null, }); - } catch { - try { - await (await env.INSTALLATION_WORKFLOW.get(domain.operationId)).status(); - } catch { - domainFail("temporarily_unavailable"); - } - } - return json({ domain }, 202); + yield* ensureWorkflow(env, domain.operationId, { + ownerSubject: principal.subject, + installationId: id, + operationId: domain.operationId, + kind: "domain", + }).pipe(Effect.mapError(() => new DomainTemporarilyUnavailable())); + return json({ domain }, 202); + }); } diff --git a/control-plane/domain-workflow.ts b/control-plane/domain-workflow.ts index fe2fc68..5990b20 100644 --- a/control-plane/domain-workflow.ts +++ b/control-plane/domain-workflow.ts @@ -1,21 +1,32 @@ import type { WorkflowStep } from "cloudflare:workers"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import { healthDomain } from "./bridge.ts"; import type { Env } from "./config.ts"; -import type { InstallationParams } from "./installation-workflow.ts"; -import { installationRegistry } from "./installations.ts"; -import { unwrap } from "./installation-metadata.ts"; import { DomainAPI } from "./domain-api.ts"; import { - DomainError, - domainFail, - type DomainRecord, - type DomainErrorCode, -} from "./domain-metadata.ts"; + domainErrorCode, + domainFailure, + DomainHttpsPending, + DomainReauthorizationRequired, + DomainResourceConflict, + type DomainFailure, +} from "./domain-errors.ts"; +import { + attachDomain, + checkDomainHttps, + verifyDomainMapping, + type SaveDomain, +} from "./domain-operations.ts"; import { configureRuntimeDomain, requireDomainCapability, } from "./domain-setup.ts"; -import { healthDomain } from "./bridge.ts"; +import type { RegistryFailure } from "./installation-errors.ts"; +import type { InstallationParams } from "./installation-workflow.ts"; +import { installationRegistry } from "./registry-client.ts"; import { vault } from "./session.ts"; +import { domainExitCode, runDomainStep } from "./workflow-boundary.ts"; export async function runDomainWorkflow( env: Env, @@ -24,12 +35,16 @@ export async function runDomainWorkflow( network: typeof fetch = fetch, ) { const registry = installationRegistry(env, params.ownerSubject); - async function context() { - const record = unwrap( - await registry.get(params.ownerSubject, params.installationId), + // This Effect is rerun inside every native step attempt. No token or mutable + // registry snapshot survives a retry, checkpoint or durable sleep. + const context = Effect.gen(function* () { + const record = yield* registry.get( + params.ownerSubject, + params.installationId, ); - const domain = unwrap( - await registry.getDomain(params.ownerSubject, params.installationId), + const domain = yield* registry.getDomain( + params.ownerSubject, + params.installationId, ); if ( !record || @@ -37,8 +52,8 @@ export async function runDomainWorkflow( domain.operationId !== params.operationId || !["connecting", "removing"].includes(domain.status) ) - domainFail("resource_conflict"); - const authorization = await vault( + return yield* new DomainResourceConflict(); + const authorization = yield* vault( env, "operation", params.operationId, @@ -49,43 +64,37 @@ export async function runDomainWorkflow( operationId: params.operationId, }); if (!authorization || authorization.expiresAt <= Date.now() + 15_000) - domainFail("reauthorization_required"); - const grant = await vault(env, "grant", authorization.grantRef).grant( + return yield* new DomainReauthorizationRequired(); + const grant = yield* vault(env, "grant", authorization.grantRef).grant( params.ownerSubject, ); - if (!grant) domainFail("reauthorization_required"); - requireDomainCapability(env, grant.scopes); + if (!grant) return yield* new DomainReauthorizationRequired(); + yield* requireDomainCapability(env, grant.scopes); return { record, domain, api: new DomainAPI(grant.accessToken, record, network), }; - } - async function save( - domain: DomainRecord, - changes: Partial< - Pick - >, - ) { - return unwrap( - await registry.updateDomain( - params.ownerSubject, - params.installationId, - params.operationId, - domain.revision, - { - status: domain.status, - errorCode: domain.errorCode, - domainId: domain.domainId, - writeIntent: domain.writeIntent, - ...changes, - }, - ), + }); + const save: SaveDomain = (domain, changes) => + registry.updateDomain( + params.ownerSubject, + params.installationId, + params.operationId, + domain.revision, + { + status: domain.status, + errorCode: domain.errorCode, + domainId: domain.domainId, + writeIntent: domain.writeIntent, + ...changes, + }, ); - } async function execute( name: string, - action: (value: Awaited>) => Promise, + action: ( + value: Effect.Success, + ) => Effect.Effect, ) { const result = await step.do( name, @@ -93,66 +102,22 @@ export async function runDomainWorkflow( retries: { limit: 3, delay: "5 seconds", backoff: "exponential" }, timeout: "2 minutes", }, - async () => { - try { - await action(await context()); - return { error: null }; - } catch (error) { - const code = - error instanceof DomainError - ? error.code - : "temporarily_unavailable"; - if (code === "temporarily_unavailable") throw new Error(code); - return { error: code }; - } - }, + () => runDomainStep(context.pipe(Effect.flatMap(action))), ); - if (result.error) domainFail(result.error as DomainErrorCode); + if (result.error) throw domainFailure(result.error); } try { - const initial = unwrap( - await registry.getDomain(params.ownerSubject, params.installationId), + const initial = await Effect.runPromise( + registry.getDomain(params.ownerSubject, params.installationId), ); if (!initial || initial.operationId !== params.operationId) return; if (["active", "removed"].includes(initial.status)) return; if (initial.action === "attach") { - await execute("attach custom domain", async ({ domain: saved, api }) => { - let domain = saved; - if (!domain.writeIntent) { - await api.preflight(domain); - domain = await save(domain, { writeIntent: true }); - try { - await api.attach(domain); - } catch (error) { - // A definitive provider rejection proves no write was accepted. - // Timeouts, 5xx and lost replies NEVER reopen permission to PUT. - if ( - error instanceof DomainError && - [ - "resource_conflict", - "account_denied", - "reauthorization_required", - ].includes(error.code) - ) - await save(domain, { - writeIntent: false, - status: "failed", - errorCode: error.code, - }); - throw error; - } - } - const domainId = await api.find(domain); - if (!domainId) domainFail("attachment_outcome_unknown"); - if (domain.domainId && domain.domainId !== domainId) - domainFail("resource_conflict"); - if (domain.domainId !== domainId) await save(domain, { domainId }); - }); - await execute( - "authorize domain in runtime", - async ({ record, domain }) => { - await configureRuntimeDomain(env, record, domain, network); - }, + await execute("attach custom domain", ({ domain, api }) => + attachDomain(api, domain, save), + ); + await execute("authorize domain in runtime", ({ record, domain }) => + configureRuntimeDomain(env, record, domain, network), ); // TLS readiness is observed, not inferred from attachment or a DNS record. let ready = false; @@ -161,64 +126,66 @@ export async function runDomainWorkflow( `check domain HTTPS ${attempt}`, { retries: { limit: 0, delay: "1 second" }, timeout: "30 seconds" }, async () => { - try { - const { record, domain, api } = await context(); - if ((await api.find(domain)) !== domain.domainId) - domainFail("resource_conflict"); - try { - await healthDomain(env, record, domain.origin!, network); - return { ready: true, error: null }; - } catch { - return { ready: false, error: null }; - } - } catch (error) { - return { - ready: false, - error: - error instanceof DomainError - ? error.code - : ("temporarily_unavailable" as const), - }; - } + const exit = await Effect.runPromiseExit( + context.pipe( + Effect.flatMap(({ record, domain, api }) => + checkDomainHttps( + api, + domain, + healthDomain(env, record, domain.origin!, network), + ), + ), + ), + ); + return Exit.isSuccess(exit) + ? { ready: exit.value, error: null } + : { ready: false, error: domainExitCode(exit) }; }, ); - if (observation.error) domainFail(observation.error); + if (observation.error) throw domainFailure(observation.error); ready = observation.ready; if (ready) break; await step.sleep(`wait for domain HTTPS ${attempt}`, "30 seconds"); } - if (!ready) domainFail("https_pending"); - await execute("activate custom domain", async ({ domain, api }) => { - if ((await api.find(domain)) !== domain.domainId) - domainFail("resource_conflict"); - await save(domain, { status: "active" }); - }); + if (!ready) throw new DomainHttpsPending(); + await execute("activate custom domain", ({ domain, api }) => + verifyDomainMapping(api, domain).pipe( + Effect.andThen(save(domain, { status: "active" })), + Effect.asVoid, + ), + ); } else { - await execute("revoke runtime domain", async ({ record, domain }) => { - await configureRuntimeDomain(env, record, domain, network); - }); - await execute("detach custom domain", async ({ domain, api }) => { - await api.remove(domain); - }); - await execute("finish domain removal", async ({ domain }) => { - await save(domain, { status: "removed" }); - }); + await execute("revoke runtime domain", ({ record, domain }) => + configureRuntimeDomain(env, record, domain, network), + ); + await execute("detach custom domain", ({ domain, api }) => + api.remove(domain), + ); + await execute("finish domain removal", ({ domain }) => + save(domain, { status: "removed" }).pipe(Effect.asVoid), + ); } } catch (error) { - const code = - error instanceof DomainError ? error.code : "temporarily_unavailable"; - await step.do("record domain failure", async () => { - const domain = unwrap( - await registry.getDomain(params.ownerSubject, params.installationId), - ); - if ( - domain?.operationId === params.operationId && - ["connecting", "removing"].includes(domain.status) - ) - await save(domain, { status: "failed", errorCode: code }); - }); + const code = domainErrorCode(error); + await step.do("record domain failure", () => + Effect.runPromise( + Effect.gen(function* () { + const domain = yield* registry.getDomain( + params.ownerSubject, + params.installationId, + ); + if ( + domain?.operationId === params.operationId && + ["connecting", "removing"].includes(domain.status) + ) + yield* save(domain, { status: "failed", errorCode: code }); + }), + ), + ); } await step.do("retire domain authorization", async () => { - await vault(env, "operation", params.operationId).destroy(); + await Effect.runPromise( + vault(env, "operation", params.operationId).destroy(), + ); }); } diff --git a/control-plane/errors.ts b/control-plane/errors.ts index 8cf7bbb..64816f6 100644 --- a/control-plane/errors.ts +++ b/control-plane/errors.ts @@ -1,3 +1,4 @@ +import * as Data from "effect/Data"; export const messages = { oauth_setup_required: "Cloudflare connection is unavailable. The Flarebot publisher needs to finish OAuth setup.", @@ -20,11 +21,14 @@ export const messages = { invalid_request: "This request is invalid. Reload the page and try again.", } as const; export type ErrorCode = keyof typeof messages; -export class OAuthError extends Error { - readonly code: ErrorCode; +export class OAuthError extends Data.TaggedError("OAuthError")<{ + code: ErrorCode; +}> { constructor(code: ErrorCode) { - super(code); - this.code = code; + super({ code }); + } + override get message() { + return this.code; } } export const safeCode = (error: unknown): ErrorCode => diff --git a/control-plane/http-response.ts b/control-plane/http-response.ts new file mode 100644 index 0000000..cc71377 --- /dev/null +++ b/control-plane/http-response.ts @@ -0,0 +1,55 @@ +import * as Effect from "effect/Effect"; +import { OAuthError } from "./errors.ts"; +import { bodyBytes } from "./transport.ts"; + +const privateHeaders = { + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", + "Content-Security-Policy": + "frame-ancestors 'none'; base-uri 'self'; form-action 'self' https://dash.cloudflare.com", +}; +export function privateResponse(response: Response) { + const result = new Response(response.body, response); + for (const [key, value] of Object.entries(privateHeaders)) + result.headers.set(key, value); + return result; +} +export function json(body: unknown, status = 200) { + return privateResponse(Response.json(body, { status })); +} +export function redirect(path: string, cookies: string[] = []) { + const response = new Response(null, { + status: 303, + headers: { Location: path }, + }); + for (const value of cookies) response.headers.append("Set-Cookie", value); + return privateResponse(response); +} +export function checkOrigin(request: Request, origin: string) { + return Effect.gen(function* () { + if ( + new URL(request.url).origin !== origin || + request.headers.get("Origin") !== origin + ) + return yield* new OAuthError("forbidden"); + }); +} +export function form(request: Request) { + return Effect.gen(function* () { + if ( + !request.headers + .get("Content-Type") + ?.startsWith("application/x-www-form-urlencoded") || + Number(request.headers.get("Content-Length")) > 2048 + ) + return yield* new OAuthError("invalid_request"); + if (!request.body) return new URLSearchParams(); + const bytes = yield* bodyBytes( + new Response(request.body), + 2048, + new OAuthError("invalid_request"), + ); + return new URLSearchParams(new TextDecoder().decode(bytes)); + }); +} diff --git a/control-plane/http.ts b/control-plane/http.ts index 0319724..a00c37b 100644 --- a/control-plane/http.ts +++ b/control-plane/http.ts @@ -1,16 +1,17 @@ -import { handleBridge, resumeBridge } from "./bridge.ts"; -import type { BridgeRequest } from "./vault.ts"; +import * as Effect from "effect/Effect"; import { loadControlPlaneOrigin } from "../configuration/control-plane.ts"; -import { configuration, type Env } from "./config.ts"; +import { handleBridge, resumeBridge } from "./bridge.ts"; import { - endpoints, exchange, revoke, subject, type CloudflareFetch, } from "./cloudflare.ts"; +import { configuration, type Env } from "./config.ts"; import { hash, opaque, random } from "./crypto.ts"; import { messages, OAuthError, safeCode, type ErrorCode } from "./errors.ts"; +import { checkOrigin, form, json, redirect } from "./http-response.ts"; +import { beginOAuth } from "./oauth-authorization.ts"; import { authenticatedPrincipal, authorizedGrant, @@ -22,65 +23,6 @@ import { vault, } from "./session.ts"; -const privateHeaders = { - "Cache-Control": "no-store", - "Referrer-Policy": "no-referrer", - "X-Content-Type-Options": "nosniff", - "Content-Security-Policy": - "frame-ancestors 'none'; base-uri 'self'; form-action 'self' https://dash.cloudflare.com", -}; -export function privateResponse(response: Response) { - const result = new Response(response.body, response); - for (const [key, value] of Object.entries(privateHeaders)) - result.headers.set(key, value); - return result; -} -function json(body: unknown, status = 200) { - return privateResponse(Response.json(body, { status })); -} -function redirect(path: string, cookies: string[] = []) { - const response = new Response(null, { - status: 303, - headers: { Location: path }, - }); - for (const value of cookies) response.headers.append("Set-Cookie", value); - return privateResponse(response); -} -export function checkOrigin(request: Request, origin: string) { - if ( - new URL(request.url).origin !== origin || - request.headers.get("Origin") !== origin - ) - throw new OAuthError("forbidden"); -} -export async function form(request: Request) { - if ( - !request.headers - .get("Content-Type") - ?.startsWith("application/x-www-form-urlencoded") - ) - throw new OAuthError("invalid_request"); - const size = Number(request.headers.get("Content-Length")); - if (size > 2048) throw new OAuthError("invalid_request"); - const reader = request.body?.getReader(); - let body = ""; - let bytes = 0; - if (reader) { - const decoder = new TextDecoder(); - while (true) { - const chunk = await reader.read(); - if (chunk.done) break; - bytes += chunk.value.length; - if (bytes > 2048) { - await reader.cancel(); - throw new OAuthError("invalid_request"); - } - body += decoder.decode(chunk.value, { stream: true }); - } - body += decoder.decode(); - } - return new URLSearchParams(body); -} function one(params: URLSearchParams, key: string) { const values = params.getAll(key); if ( @@ -96,38 +38,38 @@ function failure(code: ErrorCode, status = 400) { return json({ error: code, message: messages[code] }, status); } -export async function handleOAuth( +export function handleOAuth( request: Request, env: Env, network: CloudflareFetch = fetch, -): Promise { - const url = new URL(request.url); - const bridgeResponse = await handleBridge(request, env); - if (bridgeResponse) return bridgeResponse; - if (!(url.pathname.startsWith("/auth/") || url.pathname.startsWith("/api/"))) - return null; - try { +) { + return Effect.gen(function* () { + const url = new URL(request.url); + const bridgeResponse = yield* handleBridge(request, env); + if (bridgeResponse) return bridgeResponse; + if (!( + url.pathname.startsWith("/auth/") || url.pathname.startsWith("/api/") + )) + return null; + if (url.pathname === "/auth/disconnect" && request.method === "POST") { - checkOrigin(request, loadControlPlaneOrigin(env)); + yield* checkOrigin(request, loadControlPlaneOrigin(env)); const ref = readCookie(request, SESSION_COOKIE); let revoked = true; if (ref) { - const principal = await vault(env, "session", ref).retireSession(); + const principal = yield* vault(env, "session", ref).retireSession(); if (principal) { - const grant = await vault(env, "grant", principal.grantRef).grant( + const grant = yield* vault(env, "grant", principal.grantRef).grant( principal.subject, ); - await vault(env, "grant", principal.grantRef).destroy(); + yield* vault(env, "grant", principal.grantRef).destroy(); if (grant) { - try { - revoked = await revoke( - configuration(env), - grant.accessToken, - network, - ); - } catch { - revoked = false; - } + revoked = yield* configuration(env).pipe( + Effect.flatMap((config) => + revoke(config, grant.accessToken, network), + ), + Effect.catch(() => Effect.succeed(false)), + ); } } } @@ -136,11 +78,13 @@ export async function handleOAuth( [cookie(SESSION_COOKIE, "", 0), cookie(TRANSACTION_COOKIE, "", 0)], ); } - const config = configuration(env); - if (url.origin !== config.publicOrigin) throw new OAuthError("forbidden"); - if (request.method === "POST") checkOrigin(request, config.publicOrigin); + const config = yield* configuration(env); + if (url.origin !== config.publicOrigin) + return yield* new OAuthError("forbidden"); + if (request.method === "POST") + yield* checkOrigin(request, config.publicOrigin); if (url.pathname === "/auth/start" && request.method === "POST") { - const input = await form(request); + const input = yield* form(request); const providerInstallationId = input.get("providerInstallationId"); const domainInstallationId = input.get("domainInstallationId"); // Only a fixed local setup route may survive reconnect; never accept an @@ -167,8 +111,8 @@ export async function handleOAuth( input.has("returnTo") || input.has("providerInstallationId"))) ) - throw new OAuthError("invalid_request"); - return beginOAuth( + return yield* new OAuthError("invalid_request"); + return yield* beginOAuth( request, env, undefined, @@ -176,129 +120,13 @@ export async function handleOAuth( domainInstallationId ?? undefined, ); } - if (url.pathname === "/auth/callback" && request.method === "GET") { - try { - if ( - request.url.length > 8192 || - [...url.searchParams.keys()].some( - (key) => - ![ - "state", - "code", - // Cloudflare returns scope metadata here. Granted permissions - // remain authoritative only in the token exchange response. - "scope", - "error", - "error_description", - "error_uri", - "iss", - ].includes(key), - ) || - [...new Set(url.searchParams.keys())].some( - (key) => url.searchParams.getAll(key).length !== 1, - ) - ) - throw new OAuthError("oauth_invalid_callback"); - const state = one(url.searchParams, "state"); - const binding = readCookie(request, TRANSACTION_COOKIE); - if ( - !opaque(state) || - !binding || - (url.searchParams.has("iss") && - url.searchParams.get("iss") !== "https://dash.cloudflare.com") || - (url.searchParams.has("code") && - (url.searchParams.has("error_description") || - url.searchParams.has("error_uri"))) || - url.searchParams.has("code") === url.searchParams.has("error") - ) - throw new OAuthError("oauth_invalid_callback"); - if (url.searchParams.has("code")) one(url.searchParams, "code"); - else one(url.searchParams, "error"); - const transaction = await vault( - env, - "transaction", - state, - ).claimTransaction( - await hash(binding), - readCookie(request, SESSION_COOKIE), - ); - if (!transaction) throw new OAuthError("oauth_invalid_callback"); - if (url.searchParams.has("error")) - throw new OAuthError( - url.searchParams.get("error") === "access_denied" - ? "oauth_denied" - : "oauth_invalid_callback", - ); - const grant = await exchange( - config, - one(url.searchParams, "code"), - transaction.verifier, - network, - ); - let owner: string; - try { - owner = await subject(grant.accessToken, network); - } catch (error) { - await revoke(config, grant.accessToken, network); - throw error; - } - const sessionRef = random(); - const grantRef = random(); - // Expiring grants are not refreshed/offline. Browser identity lasts 8h; - // an expired grant explicitly requests another OAuth connection. - await vault(env, "grant", grantRef).createGrant({ - ...grant, - subject: owner, - }); - await vault(env, "session", sessionRef).createSession({ - subject: owner, - grantRef, - selectedAccountId: null, - expiresAt: Date.now() + 8 * 60 * 60_000, - }); - if (transaction.previousSession) { - const previous = await vault( - env, - "session", - transaction.previousSession, - ).session(); - await vault(env, "session", transaction.previousSession).destroy(); - if (previous) { - const oldGrant = await vault(env, "grant", previous.grantRef).grant( - previous.subject, - ); - await vault(env, "grant", previous.grantRef).destroy(); - if (oldGrant && oldGrant.accessToken !== grant.accessToken) - await revoke(config, oldGrant.accessToken, network); - } - } - const destination = transaction.bridgeContinuation - ? await resumeBridge( - env, - owner, - transaction.bridgeContinuation, - await hash(binding), - ) - : transaction.providerInstallationId - ? `/connect?enableProvider=openrouter&installationId=${transaction.providerInstallationId}` - : transaction.domainInstallationId - ? `/connect?configureDomain=${transaction.domainInstallationId}` - : transaction.returnTo; - return redirect(destination, [ - cookie(SESSION_COOKIE, sessionRef, 8 * 60 * 60), - cookie(TRANSACTION_COOKIE, "", 0), - ]); - } catch (error) { - return redirect(`/connect?error=${safeCode(error)}`, [ - cookie(TRANSACTION_COOKIE, "", 0), - ]); - } - } + if (url.pathname === "/auth/callback" && request.method === "GET") + return yield* oauthCallback(request, env, network, config, url); if (url.pathname === "/api/connection" && request.method === "GET") { if (!readCookie(request, SESSION_COOKIE)) return json({ error: "not_connected" }, 401); - const principal = await authenticatedPrincipal(request, env); - const available = await grantedAccounts(env, principal, network); + const principal = yield* authenticatedPrincipal(request, env); + const available = yield* grantedAccounts(env, principal, network); return json({ accounts: available, selectedAccountId: available.some( @@ -306,86 +134,176 @@ export async function handleOAuth( ) ? principal.selectedAccountId : null, - grantExpiresAt: (await authorizedGrant(env, principal)).expiresAt, + grantExpiresAt: (yield* authorizedGrant(env, principal)).expiresAt, }); } if (url.pathname === "/api/account" && request.method === "POST") { - const principal = await authenticatedPrincipal(request, env); - const input = await form(request); + const principal = yield* authenticatedPrincipal(request, env); + const input = yield* form(request); if ( [...input.keys()].some((key) => key !== "accountId") || input.getAll("accountId").length !== 1 || !/^[a-f0-9]{32}$/.test(input.get("accountId") ?? "") ) - throw new OAuthError("invalid_request"); + return yield* new OAuthError("invalid_request"); const accountId = input.get("accountId")!; - const available = await grantedAccounts(env, principal, network); + const available = yield* grantedAccounts(env, principal, network); if (!available.some((account) => account.id === accountId)) - throw new OAuthError("account_denied"); + return yield* new OAuthError("account_denied"); if ( - !(await vault( + !(yield* vault( env, "session", readCookie(request, SESSION_COOKIE)!, ).selectAccount(principal.subject, principal.grantRef, accountId)) ) - throw new OAuthError("reauthorization_required"); + return yield* new OAuthError("reauthorization_required"); return json({ selectedAccountId: accountId }); } return failure("invalid_request", 404); - } catch (error) { - const code = safeCode(error); - return failure( - code, - code === "forbidden" - ? 403 - : code === "reauthorization_required" - ? 401 - : code.includes("setup") || - code === "oauth_capability_unavailable" || - code === "temporarily_unavailable" - ? 503 - : 400, - ); - } + }).pipe( + Effect.catch(oauthFailureEffect), + Effect.catchDefect(oauthFailureEffect), + ); } -export async function beginOAuth( +function oauthFailure(error: unknown) { + const code = safeCode(error); + return failure( + code, + code === "forbidden" + ? 403 + : code === "reauthorization_required" + ? 401 + : code.includes("setup") || + code === "oauth_capability_unavailable" || + code === "temporarily_unavailable" + ? 503 + : 400, + ); +} +function oauthCallback( request: Request, env: Env, - bridge?: BridgeRequest, - providerInstallationId?: string, - domainInstallationId?: string, + network: CloudflareFetch, + config: import("./config.ts").OAuthConfiguration, + url: URL, ) { - const config = configuration(env); - const state = random(); - const binding = random(); - const verifier = random(); - const continuationRef = bridge ? random() : undefined; - if (bridge && continuationRef) - await vault(env, "continuation", continuationRef).createContinuation({ - ...bridge, - bindingHash: await hash(binding), + return Effect.gen(function* () { + if ( + request.url.length > 8192 || + [...url.searchParams.keys()].some( + (key) => + ![ + "state", + "code", + // Cloudflare returns scope metadata here. Granted permissions + // remain authoritative only in the token exchange response. + "scope", + "error", + "error_description", + "error_uri", + "iss", + ].includes(key), + ) || + [...new Set(url.searchParams.keys())].some( + (key) => url.searchParams.getAll(key).length !== 1, + ) + ) + return yield* new OAuthError("oauth_invalid_callback"); + const state = one(url.searchParams, "state"); + const binding = readCookie(request, TRANSACTION_COOKIE); + if ( + !opaque(state) || + !binding || + (url.searchParams.has("iss") && + url.searchParams.get("iss") !== "https://dash.cloudflare.com") || + (url.searchParams.has("code") && + (url.searchParams.has("error_description") || + url.searchParams.has("error_uri"))) || + url.searchParams.has("code") === url.searchParams.has("error") + ) + return yield* new OAuthError("oauth_invalid_callback"); + if (url.searchParams.has("code")) one(url.searchParams, "code"); + else one(url.searchParams, "error"); + const transaction = yield* vault( + env, + "transaction", + state, + ).claimTransaction( + yield* hash(binding), + readCookie(request, SESSION_COOKIE), + ); + if (!transaction) return yield* new OAuthError("oauth_invalid_callback"); + if (url.searchParams.has("error")) + return yield* new OAuthError( + url.searchParams.get("error") === "access_denied" + ? "oauth_denied" + : "oauth_invalid_callback", + ); + const grant = yield* exchange( + config, + one(url.searchParams, "code"), + transaction.verifier, + network, + ); + const owner = yield* subject(grant.accessToken, network).pipe( + Effect.onError(() => revoke(config, grant.accessToken, network)), + ); + const sessionRef = random(); + const grantRef = random(); + // Expiring grants are not refreshed/offline. Browser identity lasts 8h; + // an expired grant explicitly requests another OAuth connection. + yield* vault(env, "grant", grantRef).createGrant({ + ...grant, + subject: owner, }); - await vault(env, "transaction", state).createTransaction({ - bindingHash: await hash(binding), - verifier, - previousSession: readCookie(request, SESSION_COOKIE), - returnTo: "/connect", - ...(providerInstallationId ? { providerInstallationId } : {}), - ...(domainInstallationId ? { domainInstallationId } : {}), - ...(continuationRef ? { bridgeContinuation: continuationRef } : {}), - expiresAt: Date.now() + 10 * 60_000, - }); - const destination = new URL(endpoints.authorization); - destination.search = new URLSearchParams({ - response_type: "code", - client_id: config.oauthClientId, - redirect_uri: config.oauthRedirectUri, - scope: config.oauthScopes.join(" "), - state, - code_challenge: await hash(verifier), - code_challenge_method: "S256", - }).toString(); - return redirect(destination.href, [cookie(TRANSACTION_COOKIE, binding, 600)]); + yield* vault(env, "session", sessionRef).createSession({ + subject: owner, + grantRef, + selectedAccountId: null, + expiresAt: Date.now() + 8 * 60 * 60_000, + }); + if (transaction.previousSession) { + const previous = yield* vault( + env, + "session", + transaction.previousSession, + ).session(); + yield* vault(env, "session", transaction.previousSession).destroy(); + if (previous) { + const oldGrant = yield* vault(env, "grant", previous.grantRef).grant( + previous.subject, + ); + yield* vault(env, "grant", previous.grantRef).destroy(); + if (oldGrant && oldGrant.accessToken !== grant.accessToken) + yield* revoke(config, oldGrant.accessToken, network); + } + } + const destination = transaction.bridgeContinuation + ? yield* resumeBridge( + env, + owner, + transaction.bridgeContinuation, + yield* hash(binding), + ) + : transaction.providerInstallationId + ? `/connect?enableProvider=openrouter&installationId=${transaction.providerInstallationId}` + : transaction.domainInstallationId + ? `/connect?configureDomain=${transaction.domainInstallationId}` + : transaction.returnTo; + return redirect(destination, [ + cookie(SESSION_COOKIE, sessionRef, 8 * 60 * 60), + cookie(TRANSACTION_COOKIE, "", 0), + ]); + }).pipe(Effect.catch(callbackFailure), Effect.catchDefect(callbackFailure)); } + +const oauthFailureEffect = (error: unknown) => + Effect.succeed(oauthFailure(error)); +const callbackFailure = (error: unknown) => + Effect.succeed( + redirect(`/connect?error=${safeCode(error)}`, [ + cookie(TRANSACTION_COOKIE, "", 0), + ]), + ); diff --git a/control-plane/index.ts b/control-plane/index.ts index e0487fb..32cdf86 100644 --- a/control-plane/index.ts +++ b/control-plane/index.ts @@ -1,45 +1,57 @@ -import app from "../dist/control-plane/server/worker.js"; +import * as Effect from "effect/Effect"; import { loadControlPlaneConfig } from "../configuration/control-plane.ts"; +import app from "../dist/control-plane/server/worker.js"; import type { Env } from "./config.ts"; -import { handleOAuth, privateResponse } from "./http.ts"; +import { privateResponse } from "./http-response.ts"; +import { handleOAuth } from "./http.ts"; import { handleInstallations } from "./installations.ts"; -export { InstallationWorkflow } from "./installation-workflow.ts"; export { InstallationRegistry } from "./installation-registry.ts"; +export { InstallationWorkflow } from "./installation-workflow.ts"; export { AuthVault } from "./vault.ts"; export default { - async fetch(request, env, ctx) { - const result = - (await handleInstallations(request, env)) ?? - (await handleOAuth(request, env)); - if (result) return result; - const url = new URL(request.url); - let origin: string; - try { - origin = loadControlPlaneConfig(env).config.publicOrigin; - } catch { - return privateResponse( - new Response("Flarebot publisher OAuth setup is required.", { - status: 503, - }), - ); - } - if (url.origin !== origin) - return privateResponse(new Response("Forbidden", { status: 403 })); - if (url.pathname === "/") - return privateResponse( - new Response(null, { status: 303, headers: { Location: "/connect" } }), - ); - if (url.pathname !== "/connect" || request.method !== "GET") - return privateResponse(new Response("Not found", { status: 404 })); - // Public renderer gets only first-party assets. No customer runtime/Agent - // transports, principal, credentials, metadata, or raw environment in SSR. - const response = privateResponse( - await app.fetch!(request, { ASSETS: env.ASSETS }, ctx), + fetch(request, env, ctx) { + return Effect.runPromise( + Effect.gen(function* () { + const result = + (yield* handleInstallations(request, env)) ?? + (yield* handleOAuth(request, env)); + if (result) return result; + const url = new URL(request.url); + let origin: string; + try { + origin = loadControlPlaneConfig(env).config.publicOrigin; + } catch { + return privateResponse( + new Response("Flarebot publisher OAuth setup is required.", { + status: 503, + }), + ); + } + if (url.origin !== origin) + return privateResponse(new Response("Forbidden", { status: 403 })); + if (url.pathname === "/") + return privateResponse( + new Response(null, { + status: 303, + headers: { Location: "/connect" }, + }), + ); + if (url.pathname !== "/connect" || request.method !== "GET") + return privateResponse(new Response("Not found", { status: 404 })); + // Public renderer gets only first-party assets. No customer runtime/Agent + // transports, principal, credentials, metadata, or raw environment in SSR. + const response = privateResponse( + yield* Effect.promise(async () => + app.fetch!(request, { ASSETS: env.ASSETS }, ctx), + ), + ); + // Chromium sends Origin:null on navigation form POSTs from a no-referrer + // document. Preserve exact-Origin CSRF checks while withholding referrers + // from Cloudflare and other external origins. Callback responses stay no-referrer. + response.headers.set("Referrer-Policy", "same-origin"); + return response; + }), + { signal: request.signal }, ); - // Chromium sends Origin:null on navigation form POSTs from a no-referrer - // document. Preserve exact-Origin CSRF checks while withholding referrers - // from Cloudflare and other external origins. Callback responses stay no-referrer. - response.headers.set("Referrer-Policy", "same-origin"); - return response; }, } satisfies ExportedHandler; diff --git a/control-plane/installation-access.ts b/control-plane/installation-access.ts new file mode 100644 index 0000000..f716a52 --- /dev/null +++ b/control-plane/installation-access.ts @@ -0,0 +1,19 @@ +import * as Effect from "effect/Effect"; +import type { Env } from "./config.ts"; +import { InstallationNotFound } from "./installation-errors.ts"; +import { installationId, parse } from "./installation-metadata.ts"; +import { installationRegistry } from "./registry-client.ts"; +import { authenticatedPrincipal } from "./session.ts"; + +export function ownedInstallation(request: Request, env: Env, id: string) { + return Effect.gen(function* () { + parse(installationId, id); + const principal = yield* authenticatedPrincipal(request, env); + const record = yield* installationRegistry(env, principal.subject).get( + principal.subject, + id, + ); + if (!record) return yield* new InstallationNotFound(); + return record; + }); +} diff --git a/control-plane/installation-errors.ts b/control-plane/installation-errors.ts new file mode 100644 index 0000000..9d4840f --- /dev/null +++ b/control-plane/installation-errors.ts @@ -0,0 +1,43 @@ +import * as Data from "effect/Data"; + +export class InvalidMetadata extends Data.TaggedError("InvalidMetadata") { + readonly code = "invalid_metadata"; + override readonly message = this.code; +} + +export class InstallationNotFound extends Data.TaggedError( + "InstallationNotFound", +) { + readonly code = "installation_not_found"; + override readonly message = this.code; +} + +export class InstallationConflict extends Data.TaggedError( + "InstallationConflict", +) { + readonly code = "installation_conflict"; + override readonly message = this.code; +} + +export type RegistryFailure = + InvalidMetadata | InstallationNotFound | InstallationConflict; +export type RegistryError = RegistryFailure["code"]; + +export function isRegistryFailure(error: unknown): error is RegistryFailure { + return ( + error instanceof InvalidMetadata || + error instanceof InstallationNotFound || + error instanceof InstallationConflict + ); +} + +export function registryFailure(code: RegistryError): RegistryFailure { + switch (code) { + case "invalid_metadata": + return new InvalidMetadata(); + case "installation_not_found": + return new InstallationNotFound(); + case "installation_conflict": + return new InstallationConflict(); + } +} diff --git a/control-plane/installation-lifecycle.ts b/control-plane/installation-lifecycle.ts new file mode 100644 index 0000000..6f3356a --- /dev/null +++ b/control-plane/installation-lifecycle.ts @@ -0,0 +1,140 @@ +import { InstallationConflict } from "./installation-errors.ts"; +import { + parse, + parseInstallation, + sameRelease, + type Installation, + type InstallationChanges, + type ReleaseIdentity, +} from "./installation-metadata.ts"; +import { + operationSchema, + type InstallationOperation, + type InstallationRecovery, + type UpgradeBaseline, +} from "./operation.ts"; + +interface InstallationStart { + desired: ReleaseIdentity; + deadline: number; + recovery: InstallationRecovery | null; + upgrade: UpgradeBaseline | null; + operationId: string; + now: number; +} + +export function beginInstallation( + current: Installation, + previous: InstallationOperation | null, + command: InstallationStart, +) { + const { desired, deadline, recovery, upgrade, operationId, now } = command; + if ( + current.status === "installing" || + current.status === "updating" || + !!current.installedRelease !== !!upgrade || + (upgrade && + (!sameRelease(upgrade.fromRelease, current.installedRelease) || + !sameRelease(upgrade.toRelease, desired))) || + (current.desiredRelease && + current.status !== "ready" && + !sameRelease(current.desiredRelease, desired)) + ) + throw new InstallationConflict(); + if ( + recovery && + (current.status !== "failed" || + current.errorCode !== "recovery_required" || + current.revision !== recovery.expectedRevision) + ) + throw new InstallationConflict(); + if ( + recovery?.clearWorker && + !upgrade && + (current.resources.personalAgentNamespaceId || + current.resources.sandboxNamespaceId) + ) + throw new InstallationConflict(); + if (recovery?.clearContainer && current.resources.sandboxApplicationId) + throw new InstallationConflict(); + const continuing = current.status === "ready" ? null : previous; + const operation = parse(operationSchema, { + upgrade, + rolloutId: recovery?.clearRollout ? null : (continuing?.rolloutId ?? null), + operationId, + deadline, + workerIntent: recovery?.clearWorker + ? null + : (continuing?.workerIntent ?? null), + containerIntent: recovery?.clearContainer + ? false + : (continuing?.containerIntent ?? false), + containerUpdate: continuing?.containerUpdate ?? false, + rolloutIntent: recovery?.clearRollout + ? null + : (continuing?.rolloutIntent ?? null), + }); + const installation = parseInstallation({ + ...current, + desiredRelease: desired, + operationId, + status: upgrade ? "updating" : "installing", + progress: "preparing", + errorCode: null, + revision: current.revision + 1, + updatedAt: now, + }); + return { installation, operation }; +} + +export function changeInstallation( + current: Installation, + expectedRevision: number, + changes: InstallationChanges, + now: number, +): Installation { + if (current.revision !== expectedRevision) throw new InstallationConflict(); + const nextStatus = changes.status ?? current.status; + const active = + current.status === "installing" || current.status === "updating"; + // FLA9/12 own execution. This only prevents stale/overlapping operations + // and distinguishes the desired artifact from the last verified install. + if (active) { + if ( + ![current.status, "failed", "ready"].includes(nextStatus) || + (changes.operationId !== undefined && + changes.operationId !== current.operationId) || + (changes.desiredRelease !== undefined && + !sameRelease(changes.desiredRelease, current.desiredRelease)) + ) + throw new InstallationConflict(); + } else if ( + nextStatus !== (current.installedRelease ? "updating" : "installing") || + !changes.operationId || + !changes.desiredRelease + ) + throw new InstallationConflict(); + for (const [key, newValue] of Object.entries(changes.resources ?? {})) { + const previous = current.resources[key as keyof Installation["resources"]]; + if (previous !== null && previous !== newValue) + throw new InstallationConflict(); + } + const next = parseInstallation({ + ...current, + ...changes, + resources: { ...current.resources, ...changes.resources }, + revision: current.revision + 1, + updatedAt: now, + errorCode: + changes.errorCode ?? (nextStatus === "failed" ? current.errorCode : null), + progress: + nextStatus === "ready" + ? "complete" + : (changes.progress ?? (active ? current.progress : "preparing")), + installedRelease: + nextStatus === "ready" + ? { ...current.desiredRelease!, installedAt: now } + : current.installedRelease, + }); + return next; +} diff --git a/control-plane/installation-metadata.ts b/control-plane/installation-metadata.ts index 4937cf1..8a18821 100644 --- a/control-plane/installation-metadata.ts +++ b/control-plane/installation-metadata.ts @@ -1,4 +1,7 @@ import { z } from "zod"; +import { InvalidMetadata } from "./installation-errors.ts"; + +export const registryName = (subject: string) => `owner:${subject}`; export const installationId = z.string().regex(/^[a-f0-9]{32}$/); export const ownerSubject = z @@ -76,8 +79,9 @@ const installationSchema = z.strictObject({ errorCode, operationId: installationId.nullable(), }); -export type Installation = z.infer; -export type ReleaseIdentity = z.infer; +// Keep schema-derived models named when they pass through RPC and Effect types. +export interface Installation extends z.infer {} +export interface ReleaseIdentity extends z.infer {} // No identity, account, names, installed release or timestamps can be assigned. // Only the trusted provisioner can call the registry's update RPC. export const installationChanges = z @@ -90,24 +94,12 @@ export const installationChanges = z operationId: installationId.optional(), }) .refine((value) => Object.keys(value).length > 0); -export type InstallationChanges = z.infer; -export type RegistryError = - "invalid_metadata" | "installation_not_found" | "installation_conflict"; -export type RegistryResult = - { ok: true; value: T } | { ok: false; error: RegistryError }; -export class InstallationError extends Error { - readonly code: RegistryError; - constructor(code: RegistryError) { - super(code); - this.code = code; - } -} -export function reject(code: RegistryError): never { - throw new InstallationError(code); -} +export interface InstallationChanges extends z.infer< + typeof installationChanges +> {} export function parse(schema: z.ZodType, value: unknown): T { const result = schema.safeParse(value); - if (!result.success) reject("invalid_metadata"); + if (!result.success) throw new InvalidMetadata(); return result.data; } export function sameRelease( @@ -124,78 +116,80 @@ export function sameRelease( ); } -// Revalidate even typed values at every storage/read/export boundary. Strict -// nested schemas reject JS spreads containing credentials or customer content. -export function parseInstallation(value: unknown): Installation { - const record = parse(installationSchema, value); - const { resources: r, installationId: id, status: s } = record; - if ( - r.workerName !== `flarebot-${id}` || - r.sandboxApplicationName !== `flarebot-shell-${id}` || - record.updatedAt < record.createdAt || - (r.runtimeOrigin !== null && - !new RegExp( - `^https://flarebot-${id}\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.workers\\.dev$`, - ).test(r.runtimeOrigin)) - ) - reject("invalid_metadata"); - if ((s === "failed") !== (record.errorCode !== null)) - reject("invalid_metadata"); - if ( - (s === "reserved" && record.progress !== null) || - (record.progress === "complete" && s !== "ready") || - (s === "ready" && - record.progress !== null && - record.progress !== "complete") - ) - reject("invalid_metadata"); - if (s === "reserved") { - if ( - record.desiredRelease || - record.installedRelease || - record.operationId || - Object.keys(resourceFields).some( - (key) => r[key as keyof typeof resourceFields] !== null, - ) - ) - reject("invalid_metadata"); - } else if (!record.desiredRelease || !record.operationId) - reject("invalid_metadata"); - if (s === "installing" && record.installedRelease) reject("invalid_metadata"); - if (s === "updating" && !record.installedRelease) reject("invalid_metadata"); - if ( - record.installedRelease && - (record.installedRelease.installedAt < record.createdAt || - record.installedRelease.installedAt > record.updatedAt) - ) - reject("invalid_metadata"); +function hasOwnedResources(record: Installation) { + const { resources, installationId } = record; + return ( + resources.workerName === `flarebot-${installationId}` && + resources.sandboxApplicationName === `flarebot-shell-${installationId}` && + (resources.runtimeOrigin === null || + new RegExp( + `^https://flarebot-${installationId}\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.workers\\.dev$`, + ).test(resources.runtimeOrigin)) + ); +} + +function hasValidTimestamps(record: Installation) { + return ( + record.updatedAt >= record.createdAt && + (!record.installedRelease || + (record.installedRelease.installedAt >= record.createdAt && + record.installedRelease.installedAt <= record.updatedAt)) + ); +} + +function hasValidProgress(record: Installation) { + if (record.status === "reserved") return record.progress === null; + if (record.status === "ready") + return record.progress === null || record.progress === "complete"; + return record.progress !== "complete"; +} + +const resourceKeys = Object.keys( + resourceFields, +) as (keyof typeof resourceFields)[]; +function hasValidReleaseState(record: Installation) { + if ((record.status === "failed") !== (record.errorCode !== null)) + return false; if ( record.installedRelease && - Object.keys(resourceFields).some( - (key) => r[key as keyof typeof resourceFields] === null, - ) + resourceKeys.some((key) => record.resources[key] === null) ) - reject("invalid_metadata"); + return false; if ( - s === "ready" && - (!record.installedRelease || - !sameRelease(record.desiredRelease, record.installedRelease)) + record.status !== "reserved" && + (!record.desiredRelease || !record.operationId) ) - reject("invalid_metadata"); - return record; -} -export async function registryResult( - action: () => Promise, -): Promise> { - try { - return { ok: true, value: await action() }; - } catch (error) { - if (error instanceof InstallationError) - return { ok: false, error: error.code }; - throw error; + return false; + switch (record.status) { + case "reserved": + return ( + !record.desiredRelease && + !record.installedRelease && + !record.operationId && + resourceKeys.every((key) => record.resources[key] === null) + ); + case "installing": + return record.installedRelease === null; + case "updating": + return record.installedRelease !== null; + case "ready": + return ( + record.installedRelease !== null && + sameRelease(record.desiredRelease, record.installedRelease) + ); + case "failed": + return true; } } -export function unwrap(result: RegistryResult): T { - if (!result.ok) reject(result.error); - return result.value; + +const validatedInstallation = installationSchema + .refine(hasOwnedResources) + .refine(hasValidTimestamps) + .refine(hasValidProgress) + .refine(hasValidReleaseState); + +// Revalidate even typed values at every storage/read/export boundary. Strict +// nested schemas reject JS spreads containing credentials or customer content. +export function parseInstallation(value: unknown): Installation { + return parse(validatedInstallation, value); } diff --git a/control-plane/installation-registry.ts b/control-plane/installation-registry.ts index b725b39..32b30b5 100644 --- a/control-plane/installation-registry.ts +++ b/control-plane/installation-registry.ts @@ -1,37 +1,55 @@ import { DurableObject } from "cloudflare:workers"; +import * as Effect from "effect/Effect"; import { z } from "zod"; import type { Env } from "./config.ts"; +import { + beginDomain, + changeDomain, + type DomainChanges, +} from "./domain-lifecycle.ts"; import { domainRecord, domainStart, - type DomainStart, type DomainRecord, + type DomainStart, } from "./domain-metadata.ts"; +import { + InstallationConflict, + InstallationNotFound, + InvalidMetadata, + type RegistryFailure, +} from "./installation-errors.ts"; +import { + beginInstallation, + changeInstallation, +} from "./installation-lifecycle.ts"; import { installationChanges, installationId, ownerSubject, parse, parseInstallation, - registryResult, - reject, - sameRelease, + registryName, releaseIdentity, - type ReleaseIdentity, + sameRelease, type Installation, type InstallationChanges, + type ReleaseIdentity, } from "./installation-metadata.ts"; +import { registryResult, type RegistryResult } from "./registry-result.ts"; +import { transaction } from "./storage.ts"; import { + installationRecovery, + operationChanges, operationSchema, upgradeBaseline, - type UpgradeBaseline, - operationChanges, type InstallationOperation, + type InstallationRecovery, + type UpgradeBaseline, } from "./operation.ts"; const rowKey = (id: string) => `installation:${id}`; -export const registryName = (subject: string) => `owner:${subject}`; const replaySchema = z.strictObject({ installationId, accountId: installationId, @@ -46,6 +64,11 @@ export interface InstallationPage { nextCursor: string | null; } +export interface InstallationState { + installation: Installation; + operation: InstallationOperation; +} + // Private control-plane binding only. One owner object keeps reservation replay, // records and owner listing in the same native atomic storage boundary. export class InstallationRegistry extends DurableObject { @@ -56,102 +79,158 @@ export class InstallationRegistry extends DurableObject { this.env.INSTALLATIONS.idFromName(registryName(subject)), ) ) - reject("installation_not_found"); + throw new InstallationNotFound(); } #record(value: unknown, subject: string, id: string) { const record = parseInstallation(value); if (record.ownerSubject !== subject || record.installationId !== id) - reject("installation_not_found"); + throw new InstallationNotFound(); return record; } - reserve(subject: string, accountId: string, requestId: string) { - return registryResult(async () => { - this.#owner(subject); - parse(installationId, accountId); - parse(installationId, requestId); - return this.ctx.storage.transaction(async (tx) => { - const replayKey = `request:${requestId}`; - const saved = await tx.get(replayKey); - if (saved !== undefined) { - const replay = parse(replaySchema, saved); - if (replay.accountId !== accountId) reject("installation_conflict"); - const record = this.#record( - await tx.get(rowKey(replay.installationId)), - subject, - replay.installationId, - ); - if (record.accountId !== replay.accountId) reject("invalid_metadata"); - return record; - } - const id = crypto.randomUUID().replaceAll("-", ""); - if (await tx.get(rowKey(id))) reject("installation_conflict"); - const now = Date.now(); - const record = parseInstallation({ - schemaVersion: 1, - installationId: id, - ownerSubject: subject, - accountId, - createdAt: now, - updatedAt: now, - revision: 1, - resources: { - workerName: `flarebot-${id}`, - sandboxApplicationName: `flarebot-shell-${id}`, - personalAgentNamespaceId: null, - sandboxNamespaceId: null, - sandboxApplicationId: null, - runtimeOrigin: null, - }, - desiredRelease: null, - installedRelease: null, - status: "reserved", - errorCode: null, - operationId: null, - }); - await tx.put({ - [rowKey(id)]: record, - [replayKey]: parse(replaySchema, { installationId: id, accountId }), - }); - return record; - }); - }); + #execute( + subject: string, + program: Effect.Effect, + ): Promise> { + return registryResult( + Effect.suspend(() => { + this.#owner(subject); + return program; + }), + ); + } + #readRecord( + storage: DurableObjectStorage | DurableObjectTransaction, + subject: string, + id: string, + ) { + return Effect.promise(() => storage.get(rowKey(id))).pipe( + Effect.map((value) => this.#record(value, subject, id)), + ); + } + reserve( + subject: string, + accountId: string, + requestId: string, + ): Promise> { + return this.#execute( + subject, + Effect.gen({ self: this }, function* () { + parse(installationId, accountId); + parse(installationId, requestId); + return yield* transaction(this.ctx.storage, (tx) => + Effect.gen({ self: this }, function* () { + const replayKey = `request:${requestId}`; + const saved = yield* Effect.promise(() => tx.get(replayKey)); + if (saved !== undefined) { + const replay = parse(replaySchema, saved); + if (replay.accountId !== accountId) + return yield* new InstallationConflict(); + const record = yield* this.#readRecord( + tx, + subject, + replay.installationId, + ); + if (record.accountId !== replay.accountId) + return yield* new InvalidMetadata(); + return record; + } + const id = crypto.randomUUID().replaceAll("-", ""); + if (yield* Effect.promise(() => tx.get(rowKey(id)))) + return yield* new InstallationConflict(); + const now = Date.now(); + const record = parseInstallation({ + schemaVersion: 1, + installationId: id, + ownerSubject: subject, + accountId, + createdAt: now, + updatedAt: now, + revision: 1, + resources: { + workerName: `flarebot-${id}`, + sandboxApplicationName: `flarebot-shell-${id}`, + personalAgentNamespaceId: null, + sandboxNamespaceId: null, + sandboxApplicationId: null, + runtimeOrigin: null, + }, + desiredRelease: null, + installedRelease: null, + status: "reserved", + errorCode: null, + operationId: null, + }); + yield* Effect.promise(() => + tx.put({ + [rowKey(id)]: record, + [replayKey]: parse(replaySchema, { + installationId: id, + accountId, + }), + }), + ); + return record; + }), + ); + }), + ); } - get(subject: string, id: string) { - return registryResult(async () => { - this.#owner(subject); - parse(installationId, id); - const value = await this.ctx.storage.get(rowKey(id)); - return value === undefined ? null : this.#record(value, subject, id); - }); + get( + subject: string, + id: string, + ): Promise> { + return this.#execute( + subject, + Effect.gen({ self: this }, function* () { + parse(installationId, id); + const value = yield* Effect.promise(() => + this.ctx.storage.get(rowKey(id)), + ); + return value === undefined ? null : this.#record(value, subject, id); + }), + ); } - getDomain(subject: string, id: string) { - return registryResult(async () => { - this.#owner(subject); - parse(installationId, id); - this.#record(await this.ctx.storage.get(rowKey(id)), subject, id); - const value = await this.ctx.storage.get(`domain:${id}`); - return value === undefined ? null : parse(domainRecord, value); - }); + getDomain( + subject: string, + id: string, + ): Promise> { + return this.#execute( + subject, + Effect.gen({ self: this }, function* () { + parse(installationId, id); + yield* this.#readRecord(this.ctx.storage, subject, id); + const value = yield* Effect.promise(() => + this.ctx.storage.get(`domain:${id}`), + ); + return value === undefined ? null : parse(domainRecord, value); + }), + ); } - domainReplay(subject: string, id: string, requestId: string) { - return registryResult(async () => { - this.#owner(subject); - parse(installationId, id); - parse(installationId, requestId); - this.#record(await this.ctx.storage.get(rowKey(id)), subject, id); - const value = await this.ctx.storage.get( - `domain-request:${id}:${requestId}`, - ); - return value === undefined - ? null - : parse( - z.strictObject({ - intent: domainStart, - operationId: installationId, - }), - value, - ).operationId; - }); + domainReplay( + subject: string, + id: string, + requestId: string, + ): Promise> { + return this.#execute( + subject, + Effect.gen({ self: this }, function* () { + parse(installationId, id); + parse(installationId, requestId); + yield* this.#readRecord(this.ctx.storage, subject, id); + const value = yield* Effect.promise(() => + this.ctx.storage.get(`domain-request:${id}:${requestId}`), + ); + return value === undefined + ? null + : parse( + z.strictObject({ + intent: domainStart, + operationId: installationId, + }), + value, + ).operationId; + }), + ); } startDomain( subject: string, @@ -159,182 +238,120 @@ export class InstallationRegistry extends DurableObject { requestId: string, input: DomainStart, deadline = Date.now() + 20 * 60_000, - ) { - return registryResult(async () => { - this.#owner(subject); - parse(installationId, id); - parse(installationId, requestId); - const intent = parse(domainStart, input); - parse( - z - .number() - .int() - .min(Date.now() + 30_000) - .max(Date.now() + 30 * 60_000), - deadline, - ); - return this.ctx.storage.transaction(async (tx) => { - const installation = this.#record( - await tx.get(rowKey(id)), - subject, - id, - ); - if (!installation.installedRelease || installation.status !== "ready") - reject("installation_conflict"); - const key = `domain:${id}`; - const saved = await tx.get(key); - const current = saved === undefined ? null : parse(domainRecord, saved); - const replayKey = `domain-request:${id}:${requestId}`; - const replay = await tx.get<{ - intent: DomainStart; - operationId: string; - }>(replayKey); - if (replay) { - if ( - JSON.stringify(replay.intent) !== JSON.stringify(intent) || - current?.operationId !== replay.operationId - ) - reject("installation_conflict"); - return current!; - } - if (current && ["connecting", "removing"].includes(current.status)) - reject("installation_conflict"); - if ( - intent.action === "attach" && - current && - current.status !== "removed" - ) - reject("installation_conflict"); - if (intent.action === "retry" && current?.status !== "failed") - reject("installation_conflict"); - if ( - intent.action === "remove" && - (!current || - current.status === "removed" || - (current.writeIntent && !current.domainId)) - ) - reject("installation_conflict"); - const action = - intent.action === "retry" ? current!.action : intent.action; - const value = parse(domainRecord, { - revision: (current?.revision ?? 0) + 1, + ): Promise> { + return this.#execute( + subject, + Effect.gen({ self: this }, function* () { + parse(installationId, id); + parse(installationId, requestId); + const intent = parse(domainStart, input); + parse( + z + .number() + .int() + .min(Date.now() + 30_000) + .max(Date.now() + 30 * 60_000), deadline, - origin: - action === "remove" - ? null - : intent.action === "attach" - ? intent.origin - : current!.origin, - hostname: - intent.action === "attach" - ? new URL(intent.origin).hostname - : current!.hostname, - zoneId: intent.action === "attach" ? intent.zoneId : current!.zoneId, - domainId: intent.action === "attach" ? null : current!.domainId, - operationId: crypto.randomUUID().replaceAll("-", ""), - status: action === "attach" ? "connecting" : "removing", - action, - errorCode: null, - writeIntent: - intent.action === "attach" ? false : current!.writeIntent, - }); - await tx.put({ - [key]: value, - [replayKey]: { intent, operationId: value.operationId }, - }); - return value; - }); - }); + ); + return yield* transaction(this.ctx.storage, (tx) => + Effect.gen({ self: this }, function* () { + const installation = yield* this.#readRecord(tx, subject, id); + if ( + !installation.installedRelease || + installation.status !== "ready" + ) + return yield* new InstallationConflict(); + const key = `domain:${id}`; + const saved = yield* Effect.promise(() => tx.get(key)); + const current = + saved === undefined ? null : parse(domainRecord, saved); + const replayKey = `domain-request:${id}:${requestId}`; + const replay = yield* Effect.promise(() => + tx.get<{ + intent: DomainStart; + operationId: string; + }>(replayKey), + ); + if (replay) { + if ( + JSON.stringify(replay.intent) !== JSON.stringify(intent) || + current?.operationId !== replay.operationId + ) + return yield* new InstallationConflict(); + return current!; + } + const value = beginDomain( + current, + intent, + crypto.randomUUID().replaceAll("-", ""), + deadline, + ); + yield* Effect.promise(() => + tx.put({ + [key]: value, + [replayKey]: { intent, operationId: value.operationId }, + }), + ); + return value; + }), + ); + }), + ); } updateDomain( subject: string, id: string, operationId: string, revision: number, - changes: Pick< - DomainRecord, - "status" | "errorCode" | "domainId" | "writeIntent" - >, - ) { - return registryResult(async () => { - this.#owner(subject); - parse(installationId, id); - parse(installationId, operationId); - return this.ctx.storage.transaction(async (tx) => { - this.#record(await tx.get(rowKey(id)), subject, id); - const key = `domain:${id}`; - const current = parse(domainRecord, await tx.get(key)); - if ( - current.operationId !== operationId || - current.revision !== revision || - !["connecting", "removing"].includes(current.status) - ) - reject("installation_conflict"); - const allowed = parse( - z.strictObject({ - status: z.enum([ - "connecting", - "active", - "removing", - "removed", - "failed", - ]), - errorCode: domainRecord.shape.errorCode, - domainId: domainRecord.shape.domainId, - writeIntent: z.boolean(), + changes: DomainChanges, + ): Promise> { + return this.#execute( + subject, + Effect.gen({ self: this }, function* () { + parse(installationId, id); + parse(installationId, operationId); + return yield* transaction(this.ctx.storage, (tx) => + Effect.gen({ self: this }, function* () { + yield* this.#readRecord(tx, subject, id); + const key = `domain:${id}`; + const current = parse( + domainRecord, + yield* Effect.promise(() => tx.get(key)), + ); + const next = changeDomain(current, operationId, revision, changes); + yield* Effect.promise(() => tx.put(key, next)); + return next; }), - changes, ); - if ( - (current.writeIntent && - !allowed.writeIntent && - !( - current.domainId === null && - allowed.status === "failed" && - [ - "resource_conflict", - "account_denied", - "reauthorization_required", - ].includes(allowed.errorCode ?? "") - )) || - (current.domainId && current.domainId !== allowed.domainId) || - (current.action === "attach" && - ["removing", "removed"].includes(allowed.status)) || - (current.action === "remove" && - ["connecting", "active"].includes(allowed.status)) || - (allowed.status === "active" && - (!allowed.domainId || !allowed.writeIntent)) - ) - reject("installation_conflict"); - const next = parse(domainRecord, { - ...current, - ...allowed, - revision: current.revision + 1, - }); - await tx.put(key, next); - return next; - }); - }); + }), + ); } - list(subject: string, cursor: string | null = null) { - return registryResult(async (): Promise => { - this.#owner(subject); - if (cursor !== null) parse(installationId, cursor); - const rows = await this.ctx.storage.list({ - prefix: "installation:", - limit: 51, - ...(cursor === null ? {} : { startAfter: rowKey(cursor) }), - }); - const records = [...rows].map(([key, value]) => - this.#record(value, subject, key.slice("installation:".length)), - ); - const installations = records.slice(0, 50); - return { - installations, - nextCursor: - records.length > 50 ? installations.at(-1)!.installationId : null, - }; - }); + list( + subject: string, + cursor: string | null = null, + ): Promise> { + return this.#execute( + subject, + Effect.gen({ self: this }, function* () { + if (cursor !== null) parse(installationId, cursor); + const rows = yield* Effect.promise(() => + this.ctx.storage.list({ + prefix: "installation:", + limit: 51, + ...(cursor === null ? {} : { startAfter: rowKey(cursor) }), + }), + ); + const records = [...rows].map(([key, value]) => + this.#record(value, subject, key.slice("installation:".length)), + ); + const installations = records.slice(0, 50); + return { + installations, + nextCursor: + records.length > 50 ? installations.at(-1)!.installationId : null, + }; + }), + ); } start( subject: string, @@ -343,198 +360,168 @@ export class InstallationRegistry extends DurableObject { requestId: string, release: ReleaseIdentity, deadline: number, - recovery: { - expectedRevision: number; - clearWorker: boolean; - clearContainer: boolean; - clearRollout: boolean; - } | null = null, + recovery: InstallationRecovery | null = null, upgrade: UpgradeBaseline | null = null, - ) { - return registryResult(async () => { - this.#owner(subject); - parse(installationId, id); - parse(installationId, accountId); - parse(installationId, requestId); - const desired = parse(releaseIdentity, release); - if (upgrade) upgrade = parse(upgradeBaseline, upgrade); - if (recovery) - recovery = parse( - z.strictObject({ - expectedRevision: z.number().int().positive(), - clearWorker: z.boolean(), - clearContainer: z.boolean(), - clearRollout: z.boolean(), - }), - recovery, - ); - parse( - z - .number() - .int() - .min(Date.now() + 30_000) - .max(Date.now() + 30 * 60_000), - deadline, - ); - return this.ctx.storage.transaction(async (tx) => { - const current = this.#record(await tx.get(rowKey(id)), subject, id); - if (current.accountId !== accountId) reject("installation_conflict"); - const domain = await tx.get(`domain:${id}`); - if ( - domain && - ["connecting", "removing"].includes( - parse(domainRecord, domain).status, - ) - ) - reject("installation_conflict"); - const replayKey = `operation-request:${requestId}`; - const replayValue = await tx.get(replayKey); - const replay = - replayValue === undefined - ? null - : parse(operationReplaySchema, replayValue); - const previousValue = await tx.get(`operation:${id}`); - const previous = - previousValue === undefined - ? null - : parse(operationSchema, previousValue); - if (replay) { - if ( - replay.installationId !== id || - replay.operationId !== current.operationId || - !sameRelease(replay.release, desired) || - !previous - ) - reject("installation_conflict"); - return { - installation: current, - operation: parse(operationSchema, previous), - }; - } - if ( - current.status === "installing" || - current.status === "updating" || - !!current.installedRelease !== !!upgrade || - (upgrade && - (!sameRelease(upgrade.fromRelease, current.installedRelease) || - !sameRelease(upgrade.toRelease, desired))) || - (current.desiredRelease && - current.status !== "ready" && - !sameRelease(current.desiredRelease, desired)) - ) - reject("installation_conflict"); - if ( - recovery && - (current.status !== "failed" || - current.errorCode !== "recovery_required" || - current.revision !== recovery.expectedRevision) - ) - reject("installation_conflict"); - if ( - recovery?.clearWorker && - !upgrade && - (current.resources.personalAgentNamespaceId || - current.resources.sandboxNamespaceId) - ) - reject("installation_conflict"); - if (recovery?.clearContainer && current.resources.sandboxApplicationId) - reject("installation_conflict"); - const operationId = crypto.randomUUID().replaceAll("-", ""); - const now = Math.max(Date.now(), current.updatedAt); - const continuing = current.status === "ready" ? null : previous; - const operation = parse(operationSchema, { - upgrade, - rolloutId: recovery?.clearRollout - ? null - : (continuing?.rolloutId ?? null), - operationId, + ): Promise> { + return this.#execute( + subject, + Effect.gen({ self: this }, function* () { + parse(installationId, id); + parse(installationId, accountId); + parse(installationId, requestId); + const desired = parse(releaseIdentity, release); + if (upgrade) upgrade = parse(upgradeBaseline, upgrade); + if (recovery) recovery = parse(installationRecovery, recovery); + parse( + z + .number() + .int() + .min(Date.now() + 30_000) + .max(Date.now() + 30 * 60_000), deadline, - workerIntent: recovery?.clearWorker - ? null - : (continuing?.workerIntent ?? null), - containerIntent: recovery?.clearContainer - ? false - : (continuing?.containerIntent ?? false), - containerUpdate: continuing?.containerUpdate ?? false, - rolloutIntent: recovery?.clearRollout - ? null - : (continuing?.rolloutIntent ?? null), - }); - const installation = parseInstallation({ - ...current, - desiredRelease: desired, - operationId, - status: upgrade ? "updating" : "installing", - progress: "preparing", - errorCode: null, - revision: current.revision + 1, - updatedAt: now, - }); - await tx.put({ - [rowKey(id)]: installation, - [`operation:${id}`]: operation, - [replayKey]: parse(operationReplaySchema, { - installationId: id, - operationId, - release: desired, + ); + return yield* transaction(this.ctx.storage, (tx) => + Effect.gen({ self: this }, function* () { + const current = yield* this.#readRecord(tx, subject, id); + if (current.accountId !== accountId) + return yield* new InstallationConflict(); + const domain = yield* Effect.promise(() => tx.get(`domain:${id}`)); + if ( + domain && + ["connecting", "removing"].includes( + parse(domainRecord, domain).status, + ) + ) + return yield* new InstallationConflict(); + const replayKey = `operation-request:${requestId}`; + const replayValue = yield* Effect.promise(() => tx.get(replayKey)); + const replay = + replayValue === undefined + ? null + : parse(operationReplaySchema, replayValue); + const previousValue = yield* Effect.promise(() => + tx.get(`operation:${id}`), + ); + const previous = + previousValue === undefined + ? null + : parse(operationSchema, previousValue); + if (replay) { + if ( + replay.installationId !== id || + replay.operationId !== current.operationId || + !sameRelease(replay.release, desired) || + !previous + ) + return yield* new InstallationConflict(); + return { + installation: current, + operation: previous, + }; + } + const operationId = crypto.randomUUID().replaceAll("-", ""); + const { installation, operation } = beginInstallation( + current, + previous, + { + desired, + deadline, + recovery, + upgrade, + operationId, + now: Math.max(Date.now(), current.updatedAt), + }, + ); + yield* Effect.promise(() => + tx.put({ + [rowKey(id)]: installation, + [`operation:${id}`]: operation, + [replayKey]: parse(operationReplaySchema, { + installationId: id, + operationId, + release: desired, + }), + }), + ); + return { installation, operation }; }), - }); - return { installation, operation }; - }); - }); - } - replay(subject: string, id: string, requestId: string) { - return registryResult(async () => { - this.#owner(subject); - parse(installationId, id); - parse(installationId, requestId); - return this.ctx.storage.transaction(async (tx) => { - const saved = await tx.get(`operation-request:${requestId}`); - if (saved === undefined) return null; - const replay = parse(operationReplaySchema, saved); - const current = this.#record(await tx.get(rowKey(id)), subject, id); - if ( - replay.installationId !== id || - replay.operationId !== current.operationId || - !sameRelease(replay.release, current.desiredRelease) - ) - reject("installation_conflict"); - return current; - }); - }); + ); + }), + ); } - operation(subject: string, id: string) { - return registryResult(async () => { - this.#owner(subject); - parse(installationId, id); - const value = await this.ctx.storage.get(`operation:${id}`); - return value === undefined ? null : parse(operationSchema, value); - }); + replay( + subject: string, + id: string, + requestId: string, + ): Promise> { + return this.#execute( + subject, + Effect.gen({ self: this }, function* () { + parse(installationId, id); + parse(installationId, requestId); + return yield* transaction(this.ctx.storage, (tx) => + Effect.gen({ self: this }, function* () { + const saved = yield* Effect.promise(() => + tx.get(`operation-request:${requestId}`), + ); + if (saved === undefined) return null; + const replay = parse(operationReplaySchema, saved); + const current = yield* this.#readRecord(tx, subject, id); + if ( + replay.installationId !== id || + replay.operationId !== current.operationId || + !sameRelease(replay.release, current.desiredRelease) + ) + return yield* new InstallationConflict(); + return current; + }), + ); + }), + ); } - active(subject: string, id: string, operationId: string) { - return registryResult(async () => { - this.#owner(subject); - parse(installationId, id); - parse(installationId, operationId); - return this.ctx.storage.transaction(async (tx) => { - const installation = this.#record( - await tx.get(rowKey(id)), - subject, - id, + operation( + subject: string, + id: string, + ): Promise> { + return this.#execute( + subject, + Effect.gen({ self: this }, function* () { + parse(installationId, id); + const value = yield* Effect.promise(() => + this.ctx.storage.get(`operation:${id}`), ); - const operation = parse( - operationSchema, - await tx.get(`operation:${id}`), + return value === undefined ? null : parse(operationSchema, value); + }), + ); + } + active( + subject: string, + id: string, + operationId: string, + ): Promise> { + return this.#execute( + subject, + Effect.gen({ self: this }, function* () { + parse(installationId, id); + parse(installationId, operationId); + return yield* transaction(this.ctx.storage, (tx) => + Effect.gen({ self: this }, function* () { + const installation = yield* this.#readRecord(tx, subject, id); + const operation = parse( + operationSchema, + yield* Effect.promise(() => tx.get(`operation:${id}`)), + ); + if ( + installation.operationId !== operationId || + operation.operationId !== operationId || + !["installing", "updating"].includes(installation.status) + ) + return yield* new InstallationConflict(); + return { installation, operation }; + }), ); - if ( - installation.operationId !== operationId || - operation.operationId !== operationId || - !["installing", "updating"].includes(installation.status) - ) - reject("installation_conflict"); - return { installation, operation }; - }); - }); + }), + ); } intent( subject: string, @@ -551,114 +538,76 @@ export class InstallationRegistry extends DurableObject { | "rolloutId" > >, - ) { - return registryResult(async () => { - this.#owner(subject); - parse(installationId, id); - parse(installationId, operationId); - const input = parse(operationChanges, changes); - return this.ctx.storage.transaction(async (tx) => { - const installation = this.#record( - await tx.get(rowKey(id)), - subject, - id, - ); - const operation = parse( - operationSchema, - await tx.get(`operation:${id}`), + ): Promise> { + return this.#execute( + subject, + Effect.gen({ self: this }, function* () { + parse(installationId, id); + parse(installationId, operationId); + const input = parse(operationChanges, changes); + return yield* transaction(this.ctx.storage, (tx) => + Effect.gen({ self: this }, function* () { + const installation = yield* this.#readRecord(tx, subject, id); + const operation = parse( + operationSchema, + yield* Effect.promise(() => tx.get(`operation:${id}`)), + ); + if ( + installation.operationId !== operationId || + operation.operationId !== operationId || + installation.revision !== expectedRevision || + !["installing", "updating"].includes(installation.status) + ) + return yield* new InstallationConflict(); + const next = parse(operationSchema, { ...operation, ...input }); + // Intent cannot be cleared or rewritten to conceal an ambiguous write. + for (const field of [ + "workerIntent", + "containerIntent", + "containerUpdate", + "rolloutIntent", + "rolloutId", + ] as const) + if (operation[field] && field in input) + return yield* new InstallationConflict(); + yield* Effect.promise(() => tx.put(`operation:${id}`, next)); + return next; + }), ); - if ( - installation.operationId !== operationId || - operation.operationId !== operationId || - installation.revision !== expectedRevision || - !["installing", "updating"].includes(installation.status) - ) - reject("installation_conflict"); - const next = parse(operationSchema, { ...operation, ...input }); - // Intent cannot be cleared or rewritten to conceal an ambiguous write. - for (const field of [ - "workerIntent", - "containerIntent", - "containerUpdate", - "rolloutIntent", - "rolloutId", - ] as const) - if (operation[field] && field in input) - reject("installation_conflict"); - await tx.put(`operation:${id}`, next); - return next; - }); - }); + }), + ); } update( subject: string, id: string, expectedRevision: number, input: InstallationChanges, - ) { - return registryResult(async () => { - this.#owner(subject); - parse(installationId, id); - parse( - z.number().int().positive().max(Number.MAX_SAFE_INTEGER), - expectedRevision, - ); - const changes = parse(installationChanges, input); - return this.ctx.storage.transaction(async (tx) => { - const value = await tx.get(rowKey(id)); - if (value === undefined) reject("installation_not_found"); - const current = this.#record(value, subject, id); - if (current.revision !== expectedRevision) - reject("installation_conflict"); - const nextStatus = changes.status ?? current.status; - const active = - current.status === "installing" || current.status === "updating"; - // FLA9/12 own execution. This only prevents stale/overlapping operations - // and distinguishes the desired artifact from the last verified install. - if (active) { - if ( - ![current.status, "failed", "ready"].includes(nextStatus) || - (changes.operationId !== undefined && - changes.operationId !== current.operationId) || - (changes.desiredRelease !== undefined && - !sameRelease(changes.desiredRelease, current.desiredRelease)) - ) - reject("installation_conflict"); - } else if ( - nextStatus !== - (current.installedRelease ? "updating" : "installing") || - !changes.operationId || - !changes.desiredRelease - ) - reject("installation_conflict"); - for (const [key, newValue] of Object.entries(changes.resources ?? {})) { - const previous = - current.resources[key as keyof Installation["resources"]]; - if (previous !== null && previous !== newValue) - reject("installation_conflict"); - } - const now = Math.max(Date.now(), current.updatedAt); - const next = parseInstallation({ - ...current, - ...changes, - resources: { ...current.resources, ...changes.resources }, - revision: current.revision + 1, - updatedAt: now, - errorCode: - changes.errorCode ?? - (nextStatus === "failed" ? current.errorCode : null), - progress: - nextStatus === "ready" - ? "complete" - : (changes.progress ?? (active ? current.progress : "preparing")), - installedRelease: - nextStatus === "ready" - ? { ...current.desiredRelease!, installedAt: now } - : current.installedRelease, - }); - await tx.put(rowKey(id), next); - return next; - }); - }); + ): Promise> { + return this.#execute( + subject, + Effect.gen({ self: this }, function* () { + parse(installationId, id); + parse( + z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + expectedRevision, + ); + const changes = parse(installationChanges, input); + return yield* transaction(this.ctx.storage, (tx) => + Effect.gen({ self: this }, function* () { + const value = yield* Effect.promise(() => tx.get(rowKey(id))); + if (value === undefined) return yield* new InstallationNotFound(); + const current = this.#record(value, subject, id); + const next = changeInstallation( + current, + expectedRevision, + changes, + Math.max(Date.now(), current.updatedAt), + ); + yield* Effect.promise(() => tx.put(rowKey(id), next)); + return next; + }), + ); + }), + ); } } diff --git a/control-plane/installation-workflow.ts b/control-plane/installation-workflow.ts index 5484f68..1039dd2 100644 --- a/control-plane/installation-workflow.ts +++ b/control-plane/installation-workflow.ts @@ -3,29 +3,37 @@ import { type WorkflowEvent, type WorkflowStep, } from "cloudflare:workers"; -import { runtimeConfiguration } from "./deployment-config.ts"; -import type { Env } from "./config.ts"; +import * as Effect from "effect/Effect"; +import type { InstallationConfig } from "../configuration/customer.ts"; +import type { Artifact } from "./artifact-types.ts"; +import { healthInstallation } from "./bridge.ts"; import { customerArtifact } from "./catalog.ts"; +import type { Env } from "./config.ts"; +import { deploymentConfiguration } from "./deployment-config.ts"; +import type { DeploymentFailure } from "./deployment-errors.ts"; import { - DeploymentAPI, - fingerprint, - eq, - type DeploymentNetwork, -} from "./deployment-api.ts"; -import { deploymentCode, fail } from "./deployment-errors.ts"; -import { installationRegistry } from "./installations.ts"; + deploymentFailure, + HealthFailed, + ReauthorizationRequired, + RecoveryRequired, + ResourceConflict, +} from "./deployment-errors.ts"; +import { fingerprint } from "./deployment-values.ts"; +import { Deployment } from "./deployment.ts"; +import { runDomainWorkflow } from "./domain-workflow.ts"; +import type { RegistryFailure } from "./installation-errors.ts"; import { - unwrap, sameRelease, type Installation, type InstallationChanges, type ReleaseIdentity, } from "./installation-metadata.ts"; +import { ensureModelGateway } from "./model-gateway.ts"; +import type { InstallationOperation } from "./operation.ts"; +import { installationRegistry } from "./registry-client.ts"; import { vault } from "./session.ts"; -import { verifyUpgradeIdentity } from "./artifact.ts"; -import type { Artifact } from "./artifact-types.ts"; -import { healthInstallation } from "./bridge.ts"; -import { runDomainWorkflow } from "./domain-workflow.ts"; +import { uploadWorker, verifyWorkerNamespaces } from "./worker-deployment.ts"; +import { runDeploymentStep } from "./workflow-boundary.ts"; export interface InstallationParams { kind?: "domain"; @@ -33,6 +41,18 @@ export interface InstallationParams { installationId: string; operationId: string; } +type DeploymentProgress = + "preparing" | "deploying" | "provisioning" | "verifying"; +interface DeploymentAttempt { + record: Installation; + artifact: Artifact; + api: Deployment; + bootstrapSecret: string | null; + operation: InstallationOperation; + checkpoint: ( + changes: InstallationChanges, + ) => Effect.Effect; +} const retry = { retries: { limit: 3, delay: "2 seconds", backoff: "exponential" as const }, timeout: "2 minutes", @@ -43,19 +63,25 @@ export class InstallationWorkflow extends WorkflowEntrypoint< > { // Only fixture subclasses replace these server seams. No configurable provider // host, fixture flag, arbitrary artifact or health bypass exists in production. - protected artifact(pinned: ReleaseIdentity): Promise { + protected artifact(pinned: ReleaseIdentity) { return customerArtifact(pinned); } - protected network(): DeploymentNetwork { + protected network(): typeof fetch { return fetch; } - protected health(record: Installation, deployedOperationId: string) { + protected health( + record: Installation, + deployedOperationId: string, + ): Effect.Effect { return healthInstallation( this.env, record, this.network(), deployedOperationId, - ).catch(() => fail("health_failed")); + ).pipe( + Effect.mapError(() => new HealthFailed()), + Effect.catchDefect(() => new HealthFailed()), + ); } async run(event: WorkflowEvent, step: WorkflowStep) { const params = event.payload; @@ -68,255 +94,120 @@ export class InstallationWorkflow extends WorkflowEntrypoint< installationId: record.installationId, operationId: params.operationId, }); - const execute = async ( + const runStep = async ( name: string, - progress: "preparing" | "deploying" | "provisioning" | "verifying", - action: (context: { - record: Installation; - artifact: Artifact; - api: DeploymentAPI; - config: unknown; - bootstrapSecret: string | null; - operation: import("./operation.ts").InstallationOperation; - checkpoint: (changes: InstallationChanges) => Promise; - }) => Promise, + progress: DeploymentProgress, + action: ( + context: DeploymentAttempt, + ) => Effect.Effect, ) => { - const result = await step.do(name, retry, async () => { - try { - let { installation: record, operation } = unwrap( - await registry.active( + const result = await step.do(name, retry, () => + runDeploymentStep( + Effect.gen({ self: this }, function* () { + let { installation: record, operation } = yield* registry.active( params.ownerSubject, params.installationId, params.operationId, - ), - ); - if (operation.deadline <= Date.now() + 60_000) - fail("reauthorization_required"); - const authorization = await vault( - this.env, - "operation", - params.operationId, - ).operation(binding(record)); - if (!authorization) fail("reauthorization_required"); - const grant = await vault( - this.env, - "grant", - authorization.grantRef, - ).grant(record.ownerSubject); - if (!grant || grant.expiresAt <= Date.now() + 60_000) - fail("reauthorization_required"); - if (record.progress !== progress) - record = unwrap( - await registry.update( + ); + if (operation.deadline <= Date.now() + 60_000) + return yield* new ReauthorizationRequired(); + const authorization = yield* vault( + this.env, + "operation", + params.operationId, + ).operation(binding(record)); + if (!authorization) return yield* new ReauthorizationRequired(); + const grant = yield* vault( + this.env, + "grant", + authorization.grantRef, + ).grant(record.ownerSubject); + if (!grant || grant.expiresAt <= Date.now() + 60_000) + return yield* new ReauthorizationRequired(); + if (record.progress !== progress) + record = yield* registry.update( record.ownerSubject, record.installationId, record.revision, { operationId: params.operationId, progress }, - ), - ); - const artifact = await this.artifact(record.desiredRelease!); - const api = new DeploymentAPI( - grant.accessToken, - record, - this.network(), - ); - let config = - name === "resolve customer origin" - ? null - : runtimeConfiguration( - this.env, - record, - artifact, - operation.workerIntent?.operationId ?? params.operationId, - ); - if (operation.upgrade && name !== "resolve customer origin") { - const source = operation.upgrade.fromRelease; - verifyUpgradeIdentity(source, artifact); - const settings = await api.settings(); - if (!settings) fail("resource_conflict"); - const existing = api.configuration(settings); - const expected = - existing.release?.artifactDigest === source.artifactDigest - ? operation.upgrade.configDigest - : operation.workerIntent?.configDigest; - if (!expected || (await fingerprint(existing)) !== expected) - fail("resource_conflict"); - config = { - ...existing, - release: { - version: artifact.identity.version, - artifactDigest: artifact.identity.artifactDigest, - operationId: - operation.workerIntent?.operationId ?? params.operationId, - }, - }; - } - await action({ - record, - operation, - artifact, - config, - bootstrapSecret: authorization.bootstrapSecret, - api, - checkpoint: async (changes) => { - record = unwrap( - await registry.update( - record.ownerSubject, - record.installationId, - record.revision, - { ...changes, operationId: params.operationId }, - ), ); - return record; - }, - }); - return { complete: true, errorCode: null }; - } catch (error) { - const code = deploymentCode(error); - if (code !== "temporarily_unavailable") - return { complete: false, errorCode: code }; - throw new Error(code); - } - }); - if (!result.complete) fail(result.errorCode!); + const artifact = yield* this.artifact(record.desiredRelease!); + const api = new Deployment( + grant.accessToken, + record, + this.network(), + ); + yield* action({ + record, + operation, + artifact, + bootstrapSecret: authorization.bootstrapSecret, + api, + checkpoint: (changes) => + registry + .update( + record.ownerSubject, + record.installationId, + record.revision, + { ...changes, operationId: params.operationId }, + ) + .pipe( + Effect.tap((updated) => + Effect.sync(() => { + record = updated; + }), + ), + ), + }); + return { complete: true, errorCode: null }; + }), + ), + ); + if (!result.complete) throw deploymentFailure(result.errorCode); }; + const execute = ( + name: string, + progress: DeploymentProgress, + action: ( + context: DeploymentAttempt & { config: InstallationConfig }, + ) => Effect.Effect, + ) => + runStep(name, progress, (context) => + deploymentConfiguration( + this.env, + context.record, + context.artifact, + context.operation, + context.api.worker, + ).pipe(Effect.flatMap((config) => action({ ...context, config }))), + ); try { - await execute( + await runStep( "resolve customer origin", "preparing", - async ({ record, api, checkpoint }) => { - const runtimeOrigin = await api.origin(); - if ( - record.resources.runtimeOrigin && - record.resources.runtimeOrigin !== runtimeOrigin - ) - fail("resource_conflict"); - if (!record.resources.runtimeOrigin) - await checkpoint({ resources: { runtimeOrigin } }); - }, + ({ record, api, checkpoint }) => + Effect.gen({ self: this }, function* () { + const runtimeOrigin = yield* api.worker.origin(); + if ( + record.resources.runtimeOrigin && + record.resources.runtimeOrigin !== runtimeOrigin + ) + return yield* new ResourceConflict(); + if (!record.resources.runtimeOrigin) + yield* checkpoint({ resources: { runtimeOrigin } }); + }), + ); + await execute("reconcile model gateway", "preparing", ({ api }) => + ensureModelGateway(api.account), ); - await execute("reconcile model gateway", "preparing", async ({ api }) => { - await api.ensureModelGateway(); - }); await execute( "upload assets and Worker", "deploying", - async ({ - record, - operation, - artifact, - api, - config, - bootstrapSecret, - }) => { - if (operation.upgrade) { - const baseline = operation.upgrade; - const settings = await api.settings(); - if (!settings) fail("resource_conflict"); - const existing = api.configuration(settings); - if ( - existing.release?.artifactDigest === - artifact.identity.artifactDigest - ) { - if (!operation.workerIntent) fail("resource_conflict"); - const adopted = await api.observe( - artifact, - operation.workerIntent.operationId, - operation.workerIntent.configDigest, - ); - if (adopted.fingerprint !== baseline.fingerprint) - fail("resource_conflict"); - return; - } - const observed = await api.observe( - { ...artifact, identity: baseline.fromRelease }, - baseline.deployedOperationId, - baseline.configDigest, - baseline.sourceCodeHash, - ); - if ( - !eq( - { - versionId: observed.versionId, - deploymentId: observed.deploymentId, - fingerprint: observed.fingerprint, - containerFingerprint: observed.containerFingerprint, - sourceCodeHash: observed.codeHash, - }, - { - versionId: baseline.versionId, - deploymentId: baseline.deploymentId, - fingerprint: baseline.fingerprint, - containerFingerprint: baseline.containerFingerprint, - sourceCodeHash: baseline.sourceCodeHash, - }, - ) - ) - fail("resource_conflict"); - if (operation.workerIntent) fail("recovery_required"); - await api.upload( - artifact, - config, - null, - async () => { - const checked = await api.observe( - { ...artifact, identity: baseline.fromRelease }, - baseline.deployedOperationId, - baseline.configDigest, - baseline.sourceCodeHash, - ); - if ( - checked.fingerprint !== baseline.fingerprint || - checked.containerFingerprint !== - baseline.containerFingerprint || - checked.deploymentId !== baseline.deploymentId || - checked.versionId !== baseline.versionId - ) - fail("resource_conflict"); - unwrap( - await registry.intent( - record.ownerSubject, - record.installationId, - params.operationId, - record.revision, - { - workerIntent: { - operationId: params.operationId, - release: artifact.identity, - configDigest: await fingerprint(config), - }, - }, - ), - ); - if ( - !eq(await api.activeDeployment(), { - versionId: baseline.versionId, - deploymentId: baseline.deploymentId, - }) - ) - fail("resource_conflict"); - }, - { - versionId: baseline.versionId, - bindings: observed.settings.bindings, - metadata: observed.metadata, - }, - ); - return; - } - const settings = await api.settings(); - if (settings) { - // The sole authorized upload intent must exist before adopting a Worker. - if (!operation.workerIntent) fail("resource_conflict"); - api.verifyWorker(settings, config); - await api.verifyContent(artifact); - return; - } - if (operation.workerIntent) fail("recovery_required"); - if (!bootstrapSecret) fail("reauthorization_required"); - await api.upload(artifact, config, bootstrapSecret, async () => { - unwrap( - await registry.intent( + ({ record, operation, artifact, api, config, bootstrapSecret }) => + Effect.gen({ self: this }, function* () { + const recordIntent = Effect.gen(function* () { + const configDigest = yield* fingerprint(config); + yield* registry.intent( record.ownerSubject, record.installationId, params.operationId, @@ -325,179 +216,184 @@ export class InstallationWorkflow extends WorkflowEntrypoint< workerIntent: { operationId: params.operationId, release: artifact.identity, - configDigest: await fingerprint(config), + configDigest, }, }, - ), - ); - }); - }, + ); + }); + yield* uploadWorker(api, { + artifact, + config, + operation, + bootstrapSecret, + recordIntent, + }); + }), ); await execute( "reconcile native namespaces", "provisioning", - async ({ api, config, artifact, operation, checkpoint }) => { - if (operation.upgrade) { - if (!operation.workerIntent?.configDigest) - fail("resource_conflict"); - const observed = await api.observe( + ({ api, config, artifact, operation, checkpoint }) => + Effect.gen({ self: this }, function* () { + const resources = yield* verifyWorkerNamespaces( + api, artifact, - operation.workerIntent.operationId, - operation.workerIntent.configDigest, + config, + operation, ); - if (observed.fingerprint !== operation.upgrade.fingerprint) - fail("resource_conflict"); - } - await api.verifyContent(artifact); - await checkpoint({ - resources: await api.namespaces(config, artifact), - }); - }, + yield* checkpoint({ resources }); + }), ); await execute( "reconcile Containers application", "provisioning", - async ({ record, operation, artifact, api, checkpoint }) => { - let app = await api.findApplication(); - if (!app && operation.upgrade) fail("resource_conflict"); - if (!app) { - if (operation.containerIntent) fail("recovery_required"); - unwrap( - await registry.intent( + ({ record, operation, artifact, api, checkpoint }) => + Effect.gen({ self: this }, function* () { + let app = yield* api.containers.findApplication(); + if (!app && operation.upgrade) return yield* new ResourceConflict(); + if (!app) { + if (operation.containerIntent) + return yield* new RecoveryRequired(); + yield* registry.intent( record.ownerSubject, record.installationId, params.operationId, record.revision, { containerIntent: true }, - ), - ); - app = await api.createApplication(artifact); - } else if ( - !record.resources.sandboxApplicationId && - !operation.containerIntent - ) - fail("resource_conflict"); - if (operation.upgrade) { - const observed = await api.containerFingerprint(app); - if ( - observed !== operation.upgrade.containerFingerprint && - observed !== operation.upgrade.targetContainerFingerprint + ); + app = yield* api.containers.createApplication(artifact); + } else if ( + !record.resources.sandboxApplicationId && + !operation.containerIntent ) - fail("resource_conflict"); - } - if ( - !api.matchesContainer(app, artifact) || - operation.containerUpdate - ) { - // PATCH is declarative; its durable repair flag distinguishes a lost - // PATCH response from a rollout POST that was actually attempted. - if (!operation.containerUpdate) - unwrap( - await registry.intent( + return yield* new ResourceConflict(); + if (operation.upgrade) { + const observed = yield* api.containers.containerFingerprint(app); + if ( + observed !== operation.upgrade.containerFingerprint && + observed !== operation.upgrade.targetContainerFingerprint + ) + return yield* new ResourceConflict(); + } + if ( + !api.containers.matchesContainer(app, artifact) || + operation.containerUpdate + ) { + // PATCH is declarative; its durable repair flag distinguishes a lost + // PATCH response from a rollout POST that was actually attempted. + if (!operation.containerUpdate) + yield* registry.intent( record.ownerSubject, record.installationId, params.operationId, record.revision, { containerUpdate: true }, - ), - ); - if (!api.matchesContainer(app, artifact)) - await api.patchApplication(app, artifact); - const previous = operation.rolloutIntent; - if (!previous) - unwrap( - await registry.intent( + ); + if (!api.containers.matchesContainer(app, artifact)) + yield* api.containers.patchApplication(app, artifact); + const previous = operation.rolloutIntent; + if (!previous) + yield* registry.intent( record.ownerSubject, record.installationId, params.operationId, record.revision, { rolloutIntent: params.operationId }, - ), - ); - await api.rollout( - app, - artifact, - previous ?? params.operationId, - !previous, - operation.rolloutId, - async (rolloutId) => { - unwrap( - await registry.intent( - record.ownerSubject, - record.installationId, - params.operationId, - record.revision, - { rolloutId }, - ), ); - }, - ); - } - if (!record.resources.sandboxApplicationId) - await checkpoint({ resources: { sandboxApplicationId: app.id } }); - }, + yield* api.containers.rollout( + app, + artifact, + previous ?? params.operationId, + !previous, + operation.rolloutId, + (rolloutId) => + registry + .intent( + record.ownerSubject, + record.installationId, + params.operationId, + record.revision, + { rolloutId }, + ) + .pipe(Effect.asVoid), + ); + } + if (!record.resources.sandboxApplicationId) + yield* checkpoint({ + resources: { sandboxApplicationId: app.id }, + }); + }), ); await execute( "publish and verify runtime", "verifying", - async ({ record, api, operation }) => { - if (operation.upgrade) await api.endpoint(); - else await api.publish(); - await this.health(record, operation.workerIntent!.operationId); - }, + ({ record, api, operation }) => + Effect.gen({ self: this }, function* () { + if (operation.upgrade) yield* api.worker.endpoint(); + else yield* api.worker.publish(); + yield* this.health(record, operation.workerIntent!.operationId); + }), ); - await step.do("record verified installation", retry, async () => { - try { - const current = unwrap( - await registry.get(params.ownerSubject, params.installationId), - ); - if ( - current?.operationId === params.operationId && - current.status === "ready" && - sameRelease(current.desiredRelease, current.installedRelease) - ) - return { complete: true }; - const active = unwrap( - await registry.active( + await step.do("record verified installation", retry, () => + Effect.runPromise( + Effect.gen({ self: this }, function* () { + const current = yield* registry.get( + params.ownerSubject, + params.installationId, + ); + if ( + current?.operationId === params.operationId && + current.status === "ready" && + sameRelease(current.desiredRelease, current.installedRelease) + ) + return { complete: true }; + const active = yield* registry.active( params.ownerSubject, params.installationId, params.operationId, - ), - ); - unwrap( - await registry.update( + ); + yield* registry.update( params.ownerSubject, params.installationId, active.installation.revision, { operationId: params.operationId, status: "ready" }, + ); + return { complete: true }; + }).pipe( + Effect.catch(() => + Effect.fail(new Error("temporarily_unavailable")), ), - ); - return { complete: true }; - } catch { - throw new Error("temporarily_unavailable"); - } - }); + Effect.catchDefect(() => + Effect.fail(new Error("temporarily_unavailable")), + ), + ), + ), + ); // A lost retirement response is harmless. The bounded protected record // expires independently; no secret is copied into this Workflow's result. - await step.do("retire bootstrap material", retry, async () => { - try { - const record = unwrap( - await registry.get(params.ownerSubject, params.installationId), - ); - if ( - record?.status === "ready" && - record.operationId === params.operationId - ) - await vault( - this.env, - "operation", - params.operationId, - ).retireBootstrap(binding(record)); - return { complete: true }; - } catch { - return { complete: false }; - } - }); + await step.do("retire bootstrap material", retry, () => + Effect.runPromise( + Effect.gen({ self: this }, function* () { + const record = yield* registry.get( + params.ownerSubject, + params.installationId, + ); + if ( + record?.status === "ready" && + record.operationId === params.operationId + ) + yield* vault( + this.env, + "operation", + params.operationId, + ).retireBootstrap(binding(record)); + return { complete: true }; + }).pipe( + Effect.catch(() => Effect.succeed({ complete: false })), + Effect.catchDefect(() => Effect.succeed({ complete: false })), + ), + ), + ); return { status: "ready" }; } catch (error) { // Native Workflow errors carry our declared code only, never provider data. @@ -519,24 +415,30 @@ export class InstallationWorkflow extends WorkflowEntrypoint< ] as const ).find((c) => known === c) ?? "temporarily_unavailable"; await step.do("record safe failure", retry, async () => { - const state = await registry.active( - params.ownerSubject, - params.installationId, - params.operationId, + await Effect.runPromise( + Effect.gen(function* () { + // A stale or absent operation cannot be downgraded by this attempt. + // Only this lookup suppresses expected registry failures, as before. + const state = yield* registry + .active( + params.ownerSubject, + params.installationId, + params.operationId, + ) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (state) + yield* registry.update( + params.ownerSubject, + params.installationId, + state.installation.revision, + { + operationId: params.operationId, + status: "failed", + errorCode: code, + }, + ); + }), ); - if (state.ok) - unwrap( - await registry.update( - params.ownerSubject, - params.installationId, - state.value.installation.revision, - { - operationId: params.operationId, - status: "failed", - errorCode: code, - }, - ), - ); return { errorCode: code }; }); return { status: "failed", errorCode: code }; diff --git a/control-plane/installations.ts b/control-plane/installations.ts index 4eaacec..539c347 100644 --- a/control-plane/installations.ts +++ b/control-plane/installations.ts @@ -1,47 +1,29 @@ +import * as Effect from "effect/Effect"; import { loadControlPlaneOrigin } from "../configuration/control-plane.ts"; -import type { Env } from "./config.ts"; import type { CloudflareFetch } from "./cloudflare.ts"; +import type { Env } from "./config.ts"; import { OAuthError, messages, safeCode } from "./errors.ts"; -import { checkOrigin, form, privateResponse } from "./http.ts"; +import { checkOrigin, form, privateResponse } from "./http-response.ts"; +import { InvalidMetadata, isRegistryFailure } from "./installation-errors.ts"; import { installationId, - releaseIdentity, - InstallationError, parse, - parseInstallation, - unwrap, + releaseIdentity, } from "./installation-metadata.ts"; -import { registryName } from "./installation-registry.ts"; +import { installationRegistry } from "./registry-client.ts"; import { authenticatedPrincipal, selectedDeploymentGrant } from "./session.ts"; -import { startInstallation } from "./start-installation.ts"; -import { DeploymentError } from "./deployment-errors.ts"; -import { customerArtifact } from "./catalog.ts"; -import { setupOpenRouter } from "./provider-setup.ts"; +import { customerArtifact, type ArtifactLoader } from "./catalog.ts"; +import { isDeploymentFailure } from "./deployment-errors.ts"; +import { isDomainFailure } from "./domain-errors.ts"; import { setupDomain } from "./domain-setup.ts"; -import { DomainError } from "./domain-metadata.ts"; - -export const installationRegistry = (env: Env, subject: string) => - env.INSTALLATIONS.get(env.INSTALLATIONS.idFromName(registryName(subject))); +import { setupOpenRouter } from "./provider-setup.ts"; +import { + startInstallation, + type InstallationCommand, +} from "./start-installation.ts"; -// Real owner lookup for the future customer login bridge. No public reverse -// index, caller-selected principal, token, or customer session issuance here. -export async function ownedInstallation( - request: Request, - env: Env, - id: string, -) { - parse(installationId, id); - const principal = await authenticatedPrincipal(request, env); - const record = unwrap( - await installationRegistry(env, principal.subject).get( - principal.subject, - id, - ), - ); - if (!record) throw new InstallationError("installation_not_found"); - return parseInstallation(record); -} +import { ownedInstallation } from "./installation-access.ts"; const json = (body: unknown, status = 200) => privateResponse(Response.json(body, { status })); const metadataMessages = { @@ -50,26 +32,27 @@ const metadataMessages = { installation_conflict: "The installation changed or this request was already used. Reload and try again.", }; -export async function handleInstallations( +export function handleInstallations( request: Request, env: Env, network: CloudflareFetch = fetch, - artifactLoader: Parameters[5] = customerArtifact, -): Promise { - const url = new URL(request.url); - if ( - url.pathname !== "/api/releases/latest" && - url.pathname !== "/api/installations" && - !url.pathname.startsWith("/api/installations/") - ) - return null; - try { + artifactLoader: ArtifactLoader = customerArtifact, +) { + return Effect.gen(function* () { + const url = new URL(request.url); + if ( + url.pathname !== "/api/releases/latest" && + url.pathname !== "/api/installations" && + !url.pathname.startsWith("/api/installations/") + ) + return null; + const origin = loadControlPlaneOrigin(env); - if (url.origin !== origin) throw new OAuthError("forbidden"); - if (request.method !== "GET") checkOrigin(request, origin); + if (url.origin !== origin) return yield* new OAuthError("forbidden"); + if (request.method !== "GET") yield* checkOrigin(request, origin); if (url.pathname === "/api/releases/latest" && request.method === "GET") { - if (url.search) throw new InstallationError("invalid_metadata"); - const latest = await artifactLoader(); + if (url.search) return yield* new InvalidMetadata(); + const latest = yield* artifactLoader(); // Public and deliberately minimal: no installation, owner, account, // resource, operation, or grant metadata is exposed here. return Response.json(latest.identity, { @@ -81,24 +64,24 @@ export async function handleInstallations( url.pathname, ); if (domain) { - if (url.search) throw new InstallationError("invalid_metadata"); - return await setupDomain(request, env, domain[1], domain[2], network); + if (url.search) return yield* new InvalidMetadata(); + return yield* setupDomain(request, env, domain[1], domain[2], network); } const provider = /^\/api\/installations\/([a-f0-9]{32})\/providers\/openrouter$/.exec( url.pathname, ); if (provider && ["GET", "POST"].includes(request.method)) { - if (url.search) throw new InstallationError("invalid_metadata"); - return await setupOpenRouter(request, env, provider[1], network); + if (url.search) return yield* new InvalidMetadata(); + return yield* setupOpenRouter(request, env, provider[1], network); } const start = /^\/api\/installations\/([a-f0-9]{32})\/(start|recover|upgrade)$/.exec( url.pathname, ); if (start && request.method === "POST") { - if (url.search) throw new InstallationError("invalid_metadata"); - const input = await form(request); + if (url.search) return yield* new InvalidMetadata(); + const input = yield* form(request); if ( [...input.keys()].some( (key) => @@ -108,88 +91,90 @@ export async function handleInstallations( input.getAll("requestId").length !== 1 || (start[2] === "upgrade" && input.getAll("target").length !== 1) ) - throw new InstallationError("invalid_metadata"); + return yield* new InvalidMetadata(); const requestId = parse(installationId, input.get("requestId")); - let target = null; + let command: InstallationCommand = { + action: start[2] === "recover" ? "recover" : "start", + }; if (start[2] === "upgrade") { try { - target = parse(releaseIdentity, JSON.parse(input.get("target")!)); + command = { + action: "upgrade", + target: parse(releaseIdentity, JSON.parse(input.get("target")!)), + }; } catch { - throw new InstallationError("invalid_metadata"); + return yield* new InvalidMetadata(); } } return json( { - installation: await startInstallation( + installation: yield* startInstallation( request, env, start[1], requestId, network, artifactLoader, - start[2] === "recover", - start[2] === "upgrade", - target, + command, ), }, 202, ); } if (url.pathname === "/api/installations" && request.method === "POST") { - if (url.search) throw new InstallationError("invalid_metadata"); - const input = await form(request); + if (url.search) return yield* new InvalidMetadata(); + const input = yield* form(request); if ( [...input.keys()].some((key) => key !== "requestId") || input.getAll("requestId").length !== 1 ) - throw new InstallationError("invalid_metadata"); + return yield* new InvalidMetadata(); const requestId = parse(installationId, input.get("requestId")); // Fresh account/grant authorization is needed to reserve deployment intent. // The credential returned by this seam stays outside metadata/DO arguments. - const { principal } = await selectedDeploymentGrant( + const { principal } = yield* selectedDeploymentGrant( request, env, network, ); - const record = unwrap( - await installationRegistry(env, principal.subject).reserve( - principal.subject, - principal.selectedAccountId!, - requestId, - ), - ); - return json({ installation: parseInstallation(record) }); + const record = yield* installationRegistry( + env, + principal.subject, + ).reserve(principal.subject, principal.selectedAccountId!, requestId); + return json({ installation: record }); } if (url.pathname === "/api/installations" && request.method === "GET") { if ( [...url.searchParams.keys()].some((key) => key !== "cursor") || url.searchParams.getAll("cursor").length > 1 ) - throw new InstallationError("invalid_metadata"); + return yield* new InvalidMetadata(); const cursor = url.searchParams.has("cursor") ? parse(installationId, url.searchParams.get("cursor")) : null; - const principal = await authenticatedPrincipal(request, env); - const page = unwrap( - await installationRegistry(env, principal.subject).list( - principal.subject, - cursor, - ), + const principal = yield* authenticatedPrincipal(request, env); + const page = yield* installationRegistry(env, principal.subject).list( + principal.subject, + cursor, + ); + const latest = yield* artifactLoader().pipe( + Effect.catch(() => Effect.succeed(null)), ); - const latest = await artifactLoader().catch(() => null); return json({ ownerSubject: principal.subject, - installations: page.installations.map(parseInstallation), + installations: page.installations, nextCursor: page.nextCursor, latestRelease: latest?.identity ?? null, upgradeFrom: latest?.compatibility.fromArtifacts ?? [], }); } if (request.method === "GET") { - if (url.search) throw new InstallationError("invalid_metadata"); + if (url.search) return yield* new InvalidMetadata(); const id = url.pathname.slice("/api/installations/".length); - const installation = await ownedInstallation(request, env, id); - const latest = await artifactLoader().catch(() => null); + const installation = yield* ownedInstallation(request, env, id); + const latest = yield* artifactLoader().pipe( + Effect.catch(() => Effect.succeed(null)), + ); return json({ installation, latestRelease: latest?.identity ?? null, @@ -197,50 +182,55 @@ export async function handleInstallations( }); } return json({ error: "not_found" }, 404); - } catch (error) { - if (error instanceof DomainError) - return json( - { error: error.code }, - error.code === "reauthorization_required" - ? 401 - : error.code === "account_denied" - ? 403 - : error.code === "invalid_hostname" - ? 400 - : ["resource_conflict", "attachment_outcome_unknown"].includes( - error.code, - ) - ? 409 - : 503, - ); - if (error instanceof DeploymentError) - return json( - { error: error.code }, - error.code === "account_denied" + }).pipe( + Effect.catch((error) => Effect.succeed(installationFailure(error))), + Effect.catchDefect((error) => Effect.succeed(installationFailure(error))), + ); +} + +function installationFailure(error: unknown) { + if (isDomainFailure(error)) + return json( + { error: error.code }, + error.code === "reauthorization_required" + ? 401 + : error.code === "account_denied" ? 403 - : error.code === "reauthorization_required" - ? 401 - : 503, - ); - if (error instanceof InstallationError) - return json( - { error: error.code, message: metadataMessages[error.code] }, - error.code === "installation_not_found" - ? 404 - : error.code === "installation_conflict" - ? 409 - : 400, - ); - const code = safeCode(error); + : error.code === "invalid_hostname" + ? 400 + : ["resource_conflict", "attachment_outcome_unknown"].includes( + error.code, + ) + ? 409 + : 503, + ); + if (isDeploymentFailure(error)) return json( - { error: code, message: messages[code] }, - code === "forbidden" + { error: error.code }, + error.code === "account_denied" ? 403 - : code === "reauthorization_required" + : error.code === "reauthorization_required" ? 401 - : code === "account_denied" || code === "invalid_request" - ? 400 - : 503, + : 503, + ); + if (isRegistryFailure(error)) + return json( + { error: error.code, message: metadataMessages[error.code] }, + error.code === "installation_not_found" + ? 404 + : error.code === "installation_conflict" + ? 409 + : 400, ); - } + const code = safeCode(error); + return json( + { error: code, message: messages[code] }, + code === "forbidden" + ? 403 + : code === "reauthorization_required" + ? 401 + : code === "account_denied" || code === "invalid_request" + ? 400 + : 503, + ); } diff --git a/control-plane/model-gateway.ts b/control-plane/model-gateway.ts new file mode 100644 index 0000000..c448727 --- /dev/null +++ b/control-plane/model-gateway.ts @@ -0,0 +1,45 @@ +import { + createAiGateway, + GatewayNotFound, + getAiGateway, +} from "@distilled.cloud/cloudflare/ai-gateway"; +import { NotFound } from "@distilled.cloud/cloudflare/Errors"; +import * as Effect from "effect/Effect"; +import type { CloudflareAccount } from "./cloudflare-account.ts"; +import { ResourceConflict } from "./deployment-errors.ts"; +import { object } from "./deployment-values.ts"; +export function ensureModelGateway(client: CloudflareAccount) { + return Effect.gen(function* () { + const gatewayId = "default"; + let gateway = yield* client.sdk( + getAiGateway({ accountId: client.accountId, id: gatewayId }).pipe( + Effect.catchIf( + (error) => + error instanceof NotFound || error instanceof GatewayNotFound, + () => Effect.succeed(null), + ), + ), + ); + if (gateway === null) { + yield* client.sdk( + createAiGateway({ + accountId: client.accountId, + id: gatewayId, + cacheInvalidateOnUpdate: true, + cacheTtl: 0, + collectLogs: false, + rateLimitingInterval: 0, + rateLimitingLimit: 0, + authentication: true, + }), + ); + gateway = yield* client.sdk( + getAiGateway({ accountId: client.accountId, id: gatewayId }), + ); + } + if (!object(gateway) || gateway.id !== gatewayId) + return yield* new ResourceConflict(); + // Gateways are account-shared. Preserve existing billing, logging, limits + // and authentication settings rather than replacing them with our defaults. + }); +} diff --git a/control-plane/oauth-authorization.ts b/control-plane/oauth-authorization.ts new file mode 100644 index 0000000..8c1a186 --- /dev/null +++ b/control-plane/oauth-authorization.ts @@ -0,0 +1,57 @@ +import * as Effect from "effect/Effect"; +import { endpoints } from "./cloudflare.ts"; +import { configuration, type Env } from "./config.ts"; +import { hash, random } from "./crypto.ts"; +import { redirect } from "./http-response.ts"; +import { + cookie, + readCookie, + SESSION_COOKIE, + TRANSACTION_COOKIE, + vault, +} from "./session.ts"; +import type { BridgeRequest } from "./vault.ts"; + +export function beginOAuth( + request: Request, + env: Env, + bridge?: BridgeRequest, + providerInstallationId?: string, + domainInstallationId?: string, +) { + return Effect.gen(function* () { + const config = yield* configuration(env); + const state = random(); + const binding = random(); + const verifier = random(); + const continuationRef = bridge ? random() : undefined; + if (bridge && continuationRef) + yield* vault(env, "continuation", continuationRef).createContinuation({ + ...bridge, + bindingHash: yield* hash(binding), + }); + yield* vault(env, "transaction", state).createTransaction({ + bindingHash: yield* hash(binding), + verifier, + previousSession: readCookie(request, SESSION_COOKIE), + returnTo: "/connect", + ...(providerInstallationId ? { providerInstallationId } : {}), + ...(domainInstallationId ? { domainInstallationId } : {}), + ...(continuationRef ? { bridgeContinuation: continuationRef } : {}), + expiresAt: Date.now() + 10 * 60_000, + }); + const destination = new URL(endpoints.authorization); + destination.search = new URLSearchParams({ + response_type: "code", + client_id: config.oauthClientId, + redirect_uri: config.oauthRedirectUri, + scope: config.oauthScopes.join(" "), + state, + code_challenge: yield* hash(verifier), + code_challenge_method: "S256", + }).toString(); + return redirect(destination.href, [ + cookie(TRANSACTION_COOKIE, binding, 600), + ]); + }); +} diff --git a/control-plane/operation.ts b/control-plane/operation.ts index 3c5cc98..749fc5e 100644 --- a/control-plane/operation.ts +++ b/control-plane/operation.ts @@ -16,7 +16,7 @@ export const upgradeBaseline = z.strictObject({ containerFingerprint: fingerprint, targetContainerFingerprint: fingerprint, }); -export type UpgradeBaseline = z.infer; +export interface UpgradeBaseline extends z.infer {} export const operationSchema = z.strictObject({ operationId: installationId, deadline: z.number().int().positive(), @@ -38,7 +38,9 @@ export const operationSchema = z.strictObject({ .regex(/^[a-f0-9]{32}$/) .nullable(), }); -export type InstallationOperation = z.infer; +export interface InstallationOperation extends z.infer< + typeof operationSchema +> {} // Mutation input must not reuse defaulted storage fields: Zod defaults inside // optional fields would clear an existing upgrade/rollout pin on unrelated writes. export const operationChanges = z.strictObject({ @@ -48,3 +50,11 @@ export const operationChanges = z.strictObject({ rolloutIntent: installationId.nullable().optional(), rolloutId: remoteId.nullable().optional(), }); + +export const installationRecovery = z.strictObject({ + expectedRevision: z.number().int().positive(), + clearWorker: z.boolean(), + clearContainer: z.boolean(), + clearRollout: z.boolean(), +}); +export type InstallationRecovery = z.infer; diff --git a/control-plane/provider-setup.ts b/control-plane/provider-setup.ts index a67eae7..f0f794b 100644 --- a/control-plane/provider-setup.ts +++ b/control-plane/provider-setup.ts @@ -1,11 +1,20 @@ +import * as Effect from "effect/Effect"; import { PROVIDER_PURPOSE } from "../shared/bridge.ts"; import { signBridgeAssertion } from "./bridge.ts"; +import { CloudflareAccount } from "./cloudflare-account.ts"; import { configuration, type Env } from "./config.ts"; -import { DeploymentAPI } from "./deployment-api.ts"; -import { fail } from "./deployment-errors.ts"; -import { form, privateResponse } from "./http.ts"; -import { ownedInstallation, installationRegistry } from "./installations.ts"; -import { unwrap } from "./installation-metadata.ts"; +import { + AccountDenied, + ReauthorizationRequired, + SetupRequired, + TemporarilyUnavailable, +} from "./deployment-errors.ts"; +import { form, privateResponse } from "./http-response.ts"; +import { ownedInstallation } from "./installation-access.ts"; +import { ensureModelGateway } from "./model-gateway.ts"; +import { installationRegistry } from "./registry-client.ts"; +import { bodyJson, withResponse } from "./transport.ts"; + import { authenticatedPrincipal, authorizedGrant, @@ -13,96 +22,92 @@ import { } from "./session.ts"; // Invoked only behind handleInstallations' exact-Origin mutation guard. -export async function setupOpenRouter( +export function setupOpenRouter( request: Request, env: Env, installationId: string, network: typeof fetch, ) { - const record = await ownedInstallation(request, env, installationId); - if (!record.installedRelease || !record.resources.runtimeOrigin) - fail("setup_required"); - const domain = unwrap( - await installationRegistry(env, record.ownerSubject).getDomain( + return Effect.gen(function* () { + const record = yield* ownedInstallation(request, env, installationId); + if (!record.installedRelease || !record.resources.runtimeOrigin) + return yield* new SetupRequired(); + const domain = yield* installationRegistry( + env, record.ownerSubject, - installationId, - ), - ); - const publicOrigin = - domain?.status === "active" - ? domain.origin! - : record.resources.runtimeOrigin; - if (request.method === "GET") - return privateResponse( - Response.json({ - installationId: record.installationId, - accountId: record.accountId, - runtimeOrigin: publicOrigin, - }), + ).getDomain(record.ownerSubject, installationId); + const publicOrigin = + domain?.status === "active" + ? domain.origin! + : record.resources.runtimeOrigin; + if (request.method === "GET") + return privateResponse( + Response.json({ + installationId: record.installationId, + accountId: record.accountId, + runtimeOrigin: publicOrigin, + }), + ); + if ([...(yield* form(request)).keys()].length) + return yield* new SetupRequired(); + const principal = yield* authenticatedPrincipal(request, env); + const grant = yield* authorizedGrant(env, principal); + const config = yield* configuration(env); + const required = config.oauthCapabilities!.scopes.filter((scope) => + scope.capabilities.includes("model-gateway"), ); - if ([...(await form(request)).keys()].length) fail("setup_required"); - const principal = await authenticatedPrincipal(request, env); - const grant = await authorizedGrant(env, principal); - const config = configuration(env); - const required = config.oauthCapabilities!.scopes.filter((scope) => - scope.capabilities.includes("model-gateway"), - ); - if (required.some((scope) => !grant.scopes.includes(scope.id))) - fail("reauthorization_required"); - // The installation, not the account-picker's current selection, fixes the - // destination. Fresh account membership is still required after reconnect. - const accounts = await grantedAccounts(env, principal, network); - if (!accounts.some((account) => account.id === record.accountId)) - fail("account_denied"); - await new DeploymentAPI( - grant.accessToken, - record, - network, - ).enableOpenRouter(); - const assertion = await signBridgeAssertion(env, { - aud: record.resources.runtimeOrigin, - sub: record.ownerSubject, - installationId: record.installationId, - purpose: PROVIDER_PURPOSE, - state: "openrouter", - challenge: "enabled", - }); - // The management token and provider keys never enter this notification or - // the browser. A signed receipt only enables this installation's provider. - try { - const response = await network( + if (required.some((scope) => !grant.scopes.includes(scope.id))) + return yield* new ReauthorizationRequired(); + // The installation, not the account-picker's current selection, fixes the + // destination. Fresh account membership is still required after reconnect. + const accounts = yield* grantedAccounts(env, principal, network); + if (!accounts.some((account) => account.id === record.accountId)) + return yield* new AccountDenied(); + yield* ensureModelGateway( + new CloudflareAccount(grant.accessToken, record.accountId, network), + ); + const assertion = yield* signBridgeAssertion(env, { + aud: record.resources.runtimeOrigin, + sub: record.ownerSubject, + installationId: record.installationId, + purpose: PROVIDER_PURPOSE, + state: "openrouter", + challenge: "enabled", + }); + // The management token and provider keys never enter this notification or + // the browser. A signed receipt only enables this installation's provider. + yield* withResponse( + network, new URL("/auth/provider-enabled", record.resources.runtimeOrigin), { method: "POST", - redirect: "manual", headers: { Authorization: `Bearer ${assertion}` }, - signal: AbortSignal.timeout(15_000), }, + 15_000, + new TemporarilyUnavailable(), + (response) => + Effect.gen(function* () { + if (!response.ok) return yield* new TemporarilyUnavailable(); + const receipt = yield* bodyJson( + response, + 1024, + new TemporarilyUnavailable(), + ); + if ( + !receipt || + typeof receipt !== "object" || + !("provider" in receipt) || + !("enabled" in receipt) || + receipt.provider !== "openrouter" || + receipt.enabled !== true + ) + return yield* new TemporarilyUnavailable(); + }), + ); + return privateResponse( + Response.json({ + returnTo: new URL("/settings", publicOrigin).href, + }), ); - if (!response.ok || !response.body) fail("temporarily_unavailable"); - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let text = ""; - let size = 0; - for (;;) { - const { value, done } = await reader.read(); - if (done) break; - size += value.length; - if (size > 1024) { - await reader.cancel(); - fail("temporarily_unavailable"); - } - text += decoder.decode(value, { stream: true }); - } - const receipt = JSON.parse(text + decoder.decode()); - if (receipt.provider !== "openrouter" || receipt.enabled !== true) - fail("temporarily_unavailable"); - } catch { - fail("temporarily_unavailable"); - } - return privateResponse( - Response.json({ - returnTo: new URL("/settings", publicOrigin).href, - }), - ); + }); } diff --git a/control-plane/registry-client.ts b/control-plane/registry-client.ts new file mode 100644 index 0000000..31d9c9b --- /dev/null +++ b/control-plane/registry-client.ts @@ -0,0 +1,109 @@ +import * as Effect from "effect/Effect"; +import type { Env } from "./config.ts"; +import { + registryFailure, + type RegistryFailure, +} from "./installation-errors.ts"; +import { registryName } from "./installation-metadata.ts"; +import type { InstallationRegistry } from "./installation-registry.ts"; +import type { RegistryResult } from "./registry-result.ts"; + +type RegistryMethods = Pick< + InstallationRegistry, + | "reserve" + | "get" + | "getDomain" + | "domainReplay" + | "startDomain" + | "updateDomain" + | "list" + | "start" + | "replay" + | "operation" + | "active" + | "intent" + | "update" +>; + +// Derive application types from the server contract, before Wrangler's RPC +// transformation adds pipelining and disposal to the transport return values. +export type InstallationRegistryClient = { + [K in keyof RegistryMethods]: RegistryMethods[K] extends ( + ...args: infer Args + ) => Promise> + ? (...args: Args) => Effect.Effect + : never; +}; + +export const installationRegistryStub = ( + env: Env, + subject: string, +): DurableObjectStub => + env.INSTALLATIONS.get(env.INSTALLATIONS.idFromName(registryName(subject))); + +// Calling a client method only describes the RPC. The response becomes a typed +// failure when executed; transport exceptions remain defects. No RPC is retried. +function rpc( + call: (...args: Args) => Promise>, +) { + return (...args: Args) => + Effect.promise(() => call(...args)).pipe( + Effect.flatMap((result) => + result.ok + ? Effect.succeed(result.value) + : Effect.fail(registryFailure(result.error)), + ), + ); +} + +export function registryClient( + stub: DurableObjectStub, +): InstallationRegistryClient { + return { + reserve: rpc((...args: Parameters) => + stub.reserve(...args), + ), + get: rpc((...args: Parameters) => + stub.get(...args), + ), + getDomain: rpc((...args: Parameters) => + stub.getDomain(...args), + ), + domainReplay: rpc((...args: Parameters) => + stub.domainReplay(...args), + ), + startDomain: rpc((...args: Parameters) => + stub.startDomain(...args), + ), + updateDomain: rpc((...args: Parameters) => + stub.updateDomain(...args), + ), + list: rpc((...args: Parameters) => + stub.list(...args), + ), + start: rpc((...args: Parameters) => + stub.start(...args), + ), + replay: rpc((...args: Parameters) => + stub.replay(...args), + ), + operation: rpc((...args: Parameters) => + stub.operation(...args), + ), + active: rpc((...args: Parameters) => + stub.active(...args), + ), + intent: rpc((...args: Parameters) => + stub.intent(...args), + ), + update: rpc((...args: Parameters) => + stub.update(...args), + ), + }; +} + +export const installationRegistry = ( + env: Env, + subject: string, +): InstallationRegistryClient => + registryClient(installationRegistryStub(env, subject)); diff --git a/control-plane/registry-result.ts b/control-plane/registry-result.ts new file mode 100644 index 0000000..9ab6c1f --- /dev/null +++ b/control-plane/registry-result.ts @@ -0,0 +1,30 @@ +import * as Effect from "effect/Effect"; +import { + isRegistryFailure, + type RegistryError, + type RegistryFailure, +} from "./installation-errors.ts"; + +// Only plain values cross Durable Object RPC; expected errors retain their +// declared codes, and defects still abort native storage transactions. +export type RegistryResult = + { ok: true; value: T } | { ok: false; error: RegistryError }; + +export function registryResult( + program: Effect.Effect, +): Promise> { + return Effect.runPromise( + program.pipe( + Effect.catchDefect((error) => + isRegistryFailure(error) ? Effect.fail(error) : Effect.die(error), + ), + Effect.match({ + onSuccess: (value): RegistryResult => ({ ok: true, value }), + onFailure: (error): RegistryResult => ({ + ok: false, + error: error.code, + }), + }), + ), + ); +} diff --git a/control-plane/session.ts b/control-plane/session.ts index 4d953e3..6a4fe91 100644 --- a/control-plane/session.ts +++ b/control-plane/session.ts @@ -1,7 +1,9 @@ +import * as Effect from "effect/Effect"; +import { accounts, type CloudflareFetch } from "./cloudflare.ts"; import type { Env } from "./config.ts"; import { opaque } from "./crypto.ts"; import { OAuthError } from "./errors.ts"; -import { accounts, type CloudflareFetch } from "./cloudflare.ts"; +import { vaultClient } from "./vault-client.ts"; import type { Principal } from "./vault.ts"; export const SESSION_COOKIE = "__Host-flarebot-control-session"; export const TRANSACTION_COOKIE = "__Host-flarebot-oauth"; @@ -21,53 +23,57 @@ export const vault = ( kind: "transaction" | "session" | "grant" | "continuation" | "code" | "operation", ref: string, -) => env.AUTH_VAULT.get(env.AUTH_VAULT.idFromName(`${kind}:${ref}`)); -export async function authenticatedPrincipal( - request: Request, - env: Env, -): Promise { - const ref = readCookie(request, SESSION_COOKIE); - const principal = ref ? await vault(env, "session", ref).session() : null; - if (!principal) throw new OAuthError("reauthorization_required"); - return principal; +) => + vaultClient(env.AUTH_VAULT.get(env.AUTH_VAULT.idFromName(`${kind}:${ref}`))); +export function authenticatedPrincipal(request: Request, env: Env) { + return Effect.gen(function* () { + const ref = readCookie(request, SESSION_COOKIE); + const principal = ref ? yield* vault(env, "session", ref).session() : null; + if (!principal) return yield* new OAuthError("reauthorization_required"); + return principal; + }); } -export async function authorizedGrant(env: Env, principal: Principal) { - const grant = await vault(env, "grant", principal.grantRef).grant( - principal.subject, - ); - if (!grant) throw new OAuthError("reauthorization_required"); - return grant; +export function authorizedGrant(env: Env, principal: Principal) { + return Effect.gen(function* () { + const grant = yield* vault(env, "grant", principal.grantRef).grant( + principal.subject, + ); + if (!grant) return yield* new OAuthError("reauthorization_required"); + return grant; + }); } -export async function grantedAccounts( +export function grantedAccounts( env: Env, principal: Principal, network: CloudflareFetch = fetch, ) { - const grant = await authorizedGrant(env, principal); - try { - return await accounts(grant.accessToken, network); - } catch (error) { - if ( - error instanceof OAuthError && + return authorizedGrant(env, principal).pipe( + Effect.flatMap((grant) => accounts(grant.accessToken, network)), + Effect.tapError((error) => error.code === "reauthorization_required" - ) - await vault(env, "grant", principal.grantRef).destroy(); - throw error; - } + ? vault(env, "grant", principal.grantRef).destroy() + : Effect.void, + ), + ); } // Server-only handoff for FLA11/9. Resolve the principal from a real browser // session, then ownership metadata; never accept a browser-supplied principal. // Revalidate account membership on every new privileged operation, and keep the // returned token within its trusted call stack (never Workflow inputs/results). -export async function selectedDeploymentGrant( +export function selectedDeploymentGrant( request: Request, env: Env, network: CloudflareFetch = fetch, ) { - const principal = await authenticatedPrincipal(request, env); - if (!principal.selectedAccountId) throw new OAuthError("account_denied"); - const available = await grantedAccounts(env, principal, network); - if (!available.some((account) => account.id === principal.selectedAccountId)) - throw new OAuthError("account_denied"); - return { principal, grant: await authorizedGrant(env, principal) }; + return Effect.gen(function* () { + const principal = yield* authenticatedPrincipal(request, env); + if (!principal.selectedAccountId) + return yield* new OAuthError("account_denied"); + const available = yield* grantedAccounts(env, principal, network); + if ( + !available.some((account) => account.id === principal.selectedAccountId) + ) + return yield* new OAuthError("account_denied"); + return { principal, grant: yield* authorizedGrant(env, principal) }; + }); } diff --git a/control-plane/start-installation.ts b/control-plane/start-installation.ts index 4f0456e..dad02b8 100644 --- a/control-plane/start-installation.ts +++ b/control-plane/start-installation.ts @@ -1,87 +1,101 @@ -import type { Env } from "./config.ts"; -import type { CloudflareFetch } from "./cloudflare.ts"; -import { customerArtifact } from "./catalog.ts"; -import type { Artifact } from "./artifact-types.ts"; +import * as Effect from "effect/Effect"; import { loadControlPlaneConfig } from "../configuration/control-plane.ts"; -import { signBridgeAssertion } from "./bridge.ts"; import { HEALTH_PURPOSE } from "../shared/bridge.ts"; -import { random } from "./crypto.ts"; -import { fail, DeploymentError } from "./deployment-errors.ts"; import { verifyUpgradeIdentity } from "./artifact.ts"; -import { DeploymentAPI } from "./deployment-api.ts"; +import { signBridgeAssertion } from "./bridge.ts"; +import { customerArtifact, type ArtifactLoader } from "./catalog.ts"; +import type { CloudflareFetch } from "./cloudflare.ts"; +import type { Env } from "./config.ts"; +import { random } from "./crypto.ts"; import { runtimeConfiguration } from "./deployment-config.ts"; -import { installationRegistry } from "./installations.ts"; import { - unwrap, - InstallationError, - sameRelease, - type ReleaseIdentity, -} from "./installation-metadata.ts"; + AccountDenied, + ArtifactUnavailable, + ReauthorizationRequired, + RecoveryRequired, + ResourceConflict, + SetupRequired, + TemporarilyUnavailable, +} from "./deployment-errors.ts"; +import { Deployment } from "./deployment.ts"; +import { + InstallationConflict, + InstallationNotFound, +} from "./installation-errors.ts"; +import { sameRelease, type ReleaseIdentity } from "./installation-metadata.ts"; +import { installationRegistry } from "./registry-client.ts"; import { selectedDeploymentGrant, vault } from "./session.ts"; +import { ensureWorkflow, terminateWorkflow } from "./workflow-instance.ts"; + +export type InstallationCommand = + | { action: "start" } + | { action: "recover" } + | { action: "upgrade"; target: ReleaseIdentity }; // This request only starts/repairs native background execution. The browser // never holds the deployment lease and never has to poll to keep it running. -export async function startInstallation( +export function startInstallation( request: Request, env: Env, id: string, requestId: string, network: CloudflareFetch = fetch, - artifactLoader: ( - pinned?: ReleaseIdentity, - ) => Promise = customerArtifact, - recover = false, - upgrade = false, - target: ReleaseIdentity | null = null, + artifactLoader: ArtifactLoader = customerArtifact, + command: InstallationCommand = { action: "start" }, ) { - const { principal, grant } = await selectedDeploymentGrant( - request, - env, - network, - ); - const registry = installationRegistry(env, principal.subject); - let current = unwrap(await registry.get(principal.subject, id)); - if (!current) throw new InstallationError("installation_not_found"); - if (current.accountId !== principal.selectedAccountId) fail("account_denied"); - if (recover && current.status === "ready") return current; - const cp = loadControlPlaneConfig(env).config; - if (!cp.bridge) fail("setup_required"); - const replay = unwrap( - await registry.replay(principal.subject, id, requestId), - ); - if (replay) { - const previous = unwrap(await registry.operation(principal.subject, id)); + return Effect.gen(function* () { + const recover = command.action === "recover"; + const upgrade = command.action === "upgrade"; + const target = command.action === "upgrade" ? command.target : null; + const { principal, grant } = yield* selectedDeploymentGrant( + request, + env, + network, + ); + const registry = installationRegistry(env, principal.subject); + let current = yield* registry.get(principal.subject, id); + if (!current) return yield* new InstallationNotFound(); + if (current.accountId !== principal.selectedAccountId) + return yield* new AccountDenied(); + if (recover && current.status === "ready") return current; + const cp = loadControlPlaneConfig(env).config; + if (!cp.bridge) return yield* new SetupRequired(); + const replay = yield* registry.replay(principal.subject, id, requestId); + if (replay) { + const previous = yield* registry.operation(principal.subject, id); + if ( + !!previous?.upgrade !== + (upgrade || !!(recover && current.installedRelease)) || + (target && !sameRelease(target, current.desiredRelease)) + ) + return yield* new InstallationConflict(); + if (current.status === "ready") return current; + } + const upgrading = upgrade || !!(recover && current.installedRelease); + if (upgrading && !current.installedRelease) + return yield* new InstallationConflict(); + if (!upgrading && current.installedRelease) + return yield* new InstallationConflict(); + const freshUpgrade = upgrading && current.status === "ready" && !replay; + const artifact = yield* artifactLoader( + freshUpgrade + ? (target ?? undefined) + : (current.desiredRelease ?? undefined), + ); + if ( + freshUpgrade && + sameRelease(current.installedRelease, artifact.identity) + ) + return current; if ( - !!previous?.upgrade !== - (upgrade || !!(recover && current.installedRelease)) || - (target && !sameRelease(target, current.desiredRelease)) + !freshUpgrade && + current.desiredRelease && + !sameRelease(current.desiredRelease, artifact.identity) ) - throw new InstallationError("installation_conflict"); - if (current.status === "ready") return current; - } - const upgrading = upgrade || !!(recover && current.installedRelease); - if (upgrading && !current.installedRelease) - throw new InstallationError("installation_conflict"); - if (!upgrading && current.installedRelease) - throw new InstallationError("installation_conflict"); - const freshUpgrade = upgrading && current.status === "ready" && !replay; - const artifact = await artifactLoader( - freshUpgrade - ? (target ?? undefined) - : (current.desiredRelease ?? undefined), - ); - if (freshUpgrade && sameRelease(current.installedRelease, artifact.identity)) - return current; - if ( - !freshUpgrade && - current.desiredRelease && - !sameRelease(current.desiredRelease, artifact.identity) - ) - fail("artifact_unavailable"); - if (target && !sameRelease(target, artifact.identity)) - throw new InstallationError("installation_conflict"); - try { - await signBridgeAssertion(env, { + return yield* new ArtifactUnavailable(); + if (target && !sameRelease(target, artifact.identity)) + return yield* new InstallationConflict(); + yield* signBridgeAssertion(env, { aud: cp.publicOrigin, sub: principal.subject, installationId: id, @@ -91,186 +105,171 @@ export async function startInstallation( operationId: id, artifactDigest: artifact.identity.artifactDigest, version: artifact.identity.version, - }); - } catch { - fail("setup_required"); - } - if (grant.expiresAt <= Date.now() + 120_000) fail("reauthorization_required"); - if (upgrading) { - verifyUpgradeIdentity(current.installedRelease!, artifact); - } - if ( - recover && - !replay && - ["installing", "updating"].includes(current.status) - ) { - const interruptedId = current.operationId!; - try { - const instance = await env.INSTALLATION_WORKFLOW.get(interruptedId); - await instance.terminate(); - if ((await instance.status()).status !== "terminated") - fail("temporarily_unavailable"); - } catch (error) { - const completed = unwrap(await registry.get(principal.subject, id)); - if ( - completed?.operationId === interruptedId && - completed.status === "ready" - ) - return completed; - const message = (error as Error)?.message; - // The live binding decorates the absence code; local workerd returns it - // bare (sometimes with an RPC Error prefix). Every other failure remains - // unavailable and cannot authorize recovery of an unknown execution. - if ( - message !== "instance.not_found" && - message !== "Error: instance.not_found" && - message !== "(instance.not_found) Instance not found" - ) - fail("temporarily_unavailable"); - } - // Termination cannot retract a remote write. Preserve all intent; only the - // native execution is stopped. A ready commit racing termination wins. - current = unwrap(await registry.get(principal.subject, id)); - if (!current || current.operationId !== interruptedId) - throw new InstallationError("installation_conflict"); - if (current.status === "ready") return current; - if (["installing", "updating"].includes(current.status)) - current = unwrap( - await registry.update(principal.subject, id, current.revision, { - operationId: interruptedId, - status: "failed", - errorCode: "recovery_required", - }), - ); - } - const previousOperation = unwrap( - await registry.operation(principal.subject, id), - ); - let baseline = upgrading ? (previousOperation?.upgrade ?? null) : null; - if (upgrading) { - const source = current.installedRelease!; - verifyUpgradeIdentity(source, artifact); - if (freshUpgrade) { - const oldIntent = previousOperation?.workerIntent; - if (!oldIntent?.configDigest) fail("resource_conflict"); - baseline = await new DeploymentAPI( - grant.accessToken, - current, - network, - ).baseline( - source, - artifact, - oldIntent.operationId, - oldIntent.configDigest, - ); + }).pipe(Effect.mapError(() => new SetupRequired())); + if (grant.expiresAt <= Date.now() + 120_000) + return yield* new ReauthorizationRequired(); + if (upgrading) { + yield* verifyUpgradeIdentity(current.installedRelease!, artifact); } if ( - !baseline || - !sameRelease(baseline.fromRelease, source) || - !sameRelease(baseline.toRelease, artifact.identity) - ) - fail("resource_conflict"); - } - const previousAuthorization = current.operationId - ? await vault(env, "operation", current.operationId).operation({ - subject: principal.subject, - accountId: current.accountId, - installationId: id, - operationId: current.operationId, - }) - : null; - let recovery: { - expectedRevision: number; - clearWorker: boolean; - clearContainer: boolean; - clearRollout: boolean; - } | null = null; - if (recover && current.status === "failed") { - if (current.errorCode !== "recovery_required" || !previousOperation) - fail("recovery_required"); - recovery = { - expectedRevision: current.revision, - clearWorker: false, - clearContainer: false, - clearRollout: false, - }; - const api = new DeploymentAPI(grant.accessToken, current, network); - const settings = await api.settings(); - if (upgrading) { - if (!settings || !baseline) fail("resource_conflict"); - const configured = api.configuration(settings); - if ( - configured.release?.artifactDigest === - baseline.fromRelease.artifactDigest - ) { - const observed = await api.baseline( - baseline.fromRelease, - artifact, - baseline.deployedOperationId, - baseline.configDigest, - ); + recover && + !replay && + ["installing", "updating"].includes(current.status) + ) { + const interruptedId = current.operationId!; + const termination = yield* terminateWorkflow(env, interruptedId).pipe( + Effect.as(null), + Effect.catch((error) => Effect.succeed(error)), + ); + if (termination) { + const completed = yield* registry.get(principal.subject, id); if ( - observed.sourceCodeHash !== baseline.sourceCodeHash || - JSON.stringify(observed) !== JSON.stringify(baseline) + completed?.operationId === interruptedId && + completed.status === "ready" ) - fail("resource_conflict"); - recovery.clearWorker = !!previousOperation.workerIntent; - } else { - if (!previousOperation.workerIntent) fail("resource_conflict"); - const observed = await api.observe( - artifact, - previousOperation.workerIntent.operationId, - previousOperation.workerIntent.configDigest, - ); - if (observed.fingerprint !== baseline.fingerprint) - fail("resource_conflict"); + return completed; + if (!termination.missing) return yield* new TemporarilyUnavailable(); } - } else if (settings) { - if (!previousOperation.workerIntent) fail("resource_conflict"); - api.verifyWorker( - settings, - runtimeConfiguration( - env, + // Termination cannot retract a remote write. Preserve all intent; only the + // native execution is stopped. A ready commit racing termination wins. + current = yield* registry.get(principal.subject, id); + if (!current || current.operationId !== interruptedId) + return yield* new InstallationConflict(); + if (current.status === "ready") return current; + if (["installing", "updating"].includes(current.status)) + current = yield* registry.update( + principal.subject, + id, + current.revision, + { + operationId: interruptedId, + status: "failed", + errorCode: "recovery_required", + }, + ); + } + const previousOperation = yield* registry.operation(principal.subject, id); + let baseline = upgrading ? (previousOperation?.upgrade ?? null) : null; + if (upgrading) { + const source = current.installedRelease!; + yield* verifyUpgradeIdentity(source, artifact); + if (freshUpgrade) { + const oldIntent = previousOperation?.workerIntent; + if (!oldIntent?.configDigest) return yield* new ResourceConflict(); + baseline = yield* new Deployment( + grant.accessToken, current, + network, + ).baseline( + source, artifact, - previousOperation.workerIntent.operationId, - ), - ); - await api.verifyContent(artifact); - } else if (previousOperation.workerIntent) { + oldIntent.operationId, + oldIntent.configDigest, + ); + } if ( - current.resources.personalAgentNamespaceId || - current.resources.sandboxNamespaceId + !baseline || + !sameRelease(baseline.fromRelease, source) || + !sameRelease(baseline.toRelease, artifact.identity) ) - fail("resource_conflict"); - recovery.clearWorker = true; + return yield* new ResourceConflict(); } - if (current.resources.sandboxNamespaceId) { - const app = await api.findApplication(); - if (!app && previousOperation.containerIntent) - recovery.clearContainer = true; - if (app && previousOperation.rolloutIntent) { - try { - await api.rollout( - app, + const previousAuthorization = current.operationId + ? yield* vault(env, "operation", current.operationId).operation({ + subject: principal.subject, + accountId: current.accountId, + installationId: id, + operationId: current.operationId, + }) + : null; + let recovery: { + expectedRevision: number; + clearWorker: boolean; + clearContainer: boolean; + clearRollout: boolean; + } | null = null; + if (recover && current.status === "failed") { + if (current.errorCode !== "recovery_required" || !previousOperation) + return yield* new RecoveryRequired(); + recovery = { + expectedRevision: current.revision, + clearWorker: false, + clearContainer: false, + clearRollout: false, + }; + const api = new Deployment(grant.accessToken, current, network); + const settings = yield* api.worker.settings(); + if (upgrading) { + if (!settings || !baseline) return yield* new ResourceConflict(); + const configured = yield* api.worker.configuration(settings); + if ( + configured.release?.artifactDigest === + baseline.fromRelease.artifactDigest + ) { + const observed = yield* api.baseline( + baseline.fromRelease, artifact, - previousOperation.rolloutIntent, - false, - previousOperation.rolloutId, + baseline.deployedOperationId, + baseline.configDigest, ); - } catch (error) { if ( - error instanceof DeploymentError && - error.code === "recovery_required" + observed.sourceCodeHash !== baseline.sourceCodeHash || + JSON.stringify(observed) !== JSON.stringify(baseline) ) - recovery.clearRollout = true; - else throw error; + return yield* new ResourceConflict(); + recovery.clearWorker = !!previousOperation.workerIntent; + } else { + if (!previousOperation.workerIntent) + return yield* new ResourceConflict(); + const observed = yield* api.observe( + artifact, + previousOperation.workerIntent.operationId, + previousOperation.workerIntent.configDigest, + ); + if (observed.fingerprint !== baseline.fingerprint) + return yield* new ResourceConflict(); + } + } else if (settings) { + if (!previousOperation.workerIntent) + return yield* new ResourceConflict(); + yield* runtimeConfiguration( + env, + current, + artifact, + previousOperation.workerIntent.operationId, + ).pipe( + Effect.flatMap((config) => api.worker.verifyWorker(settings, config)), + ); + yield* api.worker.verifyContent(artifact); + } else if (previousOperation.workerIntent) { + if ( + current.resources.personalAgentNamespaceId || + current.resources.sandboxNamespaceId + ) + return yield* new ResourceConflict(); + recovery.clearWorker = true; + } + if (current.resources.sandboxNamespaceId) { + const app = yield* api.containers.findApplication(); + if (!app && previousOperation.containerIntent) + recovery.clearContainer = true; + if (app && previousOperation.rolloutIntent) { + recovery.clearRollout = yield* api.containers + .rollout( + app, + artifact, + previousOperation.rolloutIntent, + false, + previousOperation.rolloutId, + ) + .pipe( + Effect.as(false), + Effect.catchTag("RecoveryRequired", () => Effect.succeed(true)), + ); } } } - } - const { installation, operation } = unwrap( - await registry.start( + const { installation, operation } = yield* registry.start( principal.subject, id, principal.selectedAccountId!, @@ -279,40 +278,27 @@ export async function startInstallation( Math.min(Date.now() + 20 * 60_000, grant.expiresAt - 60_000), recovery, baseline, - ), - ); - if (!["installing", "updating"].includes(installation.status)) + ); + if (!["installing", "updating"].includes(installation.status)) + return installation; + // Native services cannot share a transaction. The registry replay survives + // both missing vault creation and an ambiguous Workflow.create response. + yield* vault(env, "operation", operation.operationId).createOperation({ + subject: principal.subject, + accountId: installation.accountId, + installationId: id, + operationId: operation.operationId, + grantRef: principal.grantRef, + expiresAt: operation.deadline, + bootstrapSecret: upgrading + ? null + : (previousAuthorization?.bootstrapSecret ?? random()), + }); + yield* ensureWorkflow(env, operation.operationId, { + ownerSubject: principal.subject, + installationId: id, + operationId: operation.operationId, + }).pipe(Effect.mapError(() => new TemporarilyUnavailable())); return installation; - // Native services cannot share a transaction. The registry replay survives - // both missing vault creation and an ambiguous Workflow.create response. - await vault(env, "operation", operation.operationId).createOperation({ - subject: principal.subject, - accountId: installation.accountId, - installationId: id, - operationId: operation.operationId, - grantRef: principal.grantRef, - expiresAt: operation.deadline, - bootstrapSecret: upgrading - ? null - : (previousAuthorization?.bootstrapSecret ?? random()), }); - try { - await env.INSTALLATION_WORKFLOW.create({ - id: operation.operationId, - params: { - ownerSubject: principal.subject, - installationId: id, - operationId: operation.operationId, - }, - }); - } catch { - try { - await ( - await env.INSTALLATION_WORKFLOW.get(operation.operationId) - ).status(); - } catch { - fail("temporarily_unavailable"); - } - } - return installation; } diff --git a/control-plane/storage.ts b/control-plane/storage.ts new file mode 100644 index 0000000..6b46ba8 --- /dev/null +++ b/control-plane/storage.ts @@ -0,0 +1,30 @@ +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; + +// Cloudflare must see a rejected callback to roll back. Carry the local Effect +// cause through that Promise boundary without flattening typed failures into +// defects or committing a failed program's partial writes. +export function transaction( + storage: DurableObjectStorage, + action: (tx: DurableObjectTransaction) => Effect.Effect, +) { + class Rollback { + constructor(readonly cause: Cause.Cause) {} + } + return Effect.tryPromise({ + try: (signal) => + storage.transaction(async (tx) => { + const exit = await Effect.runPromiseExit(action(tx), { signal }); + if (Exit.isSuccess(exit)) return exit.value; + throw new Rollback(exit.cause); + }), + catch: (error) => error, + }).pipe( + Effect.catch((error) => + error instanceof Rollback + ? Effect.failCause(error.cause) + : Effect.die(error), + ), + ); +} diff --git a/control-plane/transport.ts b/control-plane/transport.ts new file mode 100644 index 0000000..448da68 --- /dev/null +++ b/control-plane/transport.ts @@ -0,0 +1,100 @@ +import * as Effect from "effect/Effect"; + +// A request owns its body as well as its headers. Native cancellation and the +// Effect deadline cover slow body reads and release the connection on failure. +export function withResponse( + network: typeof fetch, + input: string | URL | Request, + init: RequestInit, + timeout: number, + unavailable: E, + read: (response: Response) => Effect.Effect, +) { + return Effect.acquireUseRelease( + Effect.sync(() => new AbortController()), + (controller) => + Effect.tryPromise({ + try: (signal) => + network(input, { + ...init, + redirect: "manual", + signal: AbortSignal.any([ + signal, + controller.signal, + ...(init.signal ? [init.signal] : []), + ...(input instanceof Request ? [input.signal] : []), + ]), + }), + catch: () => unavailable, + }).pipe( + Effect.flatMap(read), + Effect.timeoutOrElse({ + duration: timeout, + orElse: () => Effect.fail(unavailable), + }), + ), + (controller) => Effect.sync(() => controller.abort()), + ); +} + +export function bodyBytes( + response: Response, + limit: number, + invalid: E, + unavailable: E | F = invalid, +) { + return Effect.gen(function* () { + if ( + !response.body || + Number(response.headers.get("Content-Length")) > limit + ) + return yield* Effect.fail(invalid); + return yield* Effect.acquireUseRelease( + Effect.sync(() => response.body!.getReader()), + (reader) => + Effect.gen(function* () { + const chunks: Uint8Array[] = []; + let size = 0; + for (;;) { + const chunk = yield* Effect.tryPromise({ + try: () => reader.read(), + catch: () => unavailable, + }); + if (chunk.done) break; + size += chunk.value.byteLength; + if (size > limit) return yield* Effect.fail(invalid); + chunks.push(chunk.value); + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; + }), + (reader) => + Effect.tryPromise({ + try: async () => { + try { + await reader.cancel(); + } finally { + reader.releaseLock(); + } + }, + catch: () => undefined, + }).pipe(Effect.ignore), + ); + }); +} + +export function bodyJson(response: Response, limit: number, invalid: E) { + return bodyBytes(response, limit, invalid).pipe( + Effect.flatMap((bytes) => + Effect.try({ + try: (): unknown => JSON.parse(new TextDecoder().decode(bytes)), + catch: () => invalid, + }), + ), + ); +} diff --git a/control-plane/vault-client.ts b/control-plane/vault-client.ts new file mode 100644 index 0000000..2c09ce4 --- /dev/null +++ b/control-plane/vault-client.ts @@ -0,0 +1,73 @@ +import * as Effect from "effect/Effect"; +import type { AuthVault } from "./vault.ts"; + +// Preserve each RPC receiver and derive values from the server before native +// pipelining/disposable envelopes are added. RPC transport failures are defects. +export function vaultClient(stub: DurableObjectStub) { + return { + createContinuation: ( + ...args: Parameters + ) => + Effect.promise((): ReturnType => + stub.createContinuation(...args), + ), + createCode: (...args: Parameters) => + Effect.promise((): ReturnType => + stub.createCode(...args), + ), + claimContinuation: (...args: Parameters) => + Effect.promise((): ReturnType => + stub.claimContinuation(...args), + ), + claimCode: (...args: Parameters) => + Effect.promise((): ReturnType => + stub.claimCode(...args), + ), + createOperation: (...args: Parameters) => + Effect.promise((): ReturnType => + stub.createOperation(...args), + ), + operation: (...args: Parameters) => + Effect.promise((): ReturnType => + stub.operation(...args), + ), + retireBootstrap: (...args: Parameters) => + Effect.promise((): ReturnType => + stub.retireBootstrap(...args), + ), + createTransaction: (...args: Parameters) => + Effect.promise((): ReturnType => + stub.createTransaction(...args), + ), + createSession: (...args: Parameters) => + Effect.promise((): ReturnType => + stub.createSession(...args), + ), + createGrant: (...args: Parameters) => + Effect.promise((): ReturnType => + stub.createGrant(...args), + ), + claimTransaction: (...args: Parameters) => + Effect.promise((): ReturnType => + stub.claimTransaction(...args), + ), + session: (...args: Parameters) => + Effect.promise((): ReturnType => + stub.session(...args), + ), + grant: (...args: Parameters) => + Effect.promise((): ReturnType => stub.grant(...args)), + selectAccount: (...args: Parameters) => + Effect.promise((): ReturnType => + stub.selectAccount(...args), + ), + retireSession: (...args: Parameters) => + Effect.promise((): ReturnType => + stub.retireSession(...args), + ), + destroy: (...args: Parameters) => + Effect.promise((): ReturnType => + stub.destroy(...args), + ), + }; +} diff --git a/control-plane/vault.ts b/control-plane/vault.ts index e523a96..01db08d 100644 --- a/control-plane/vault.ts +++ b/control-plane/vault.ts @@ -1,7 +1,9 @@ import { DurableObject } from "cloudflare:workers"; +import * as Effect from "effect/Effect"; import { loadControlPlaneSecrets } from "../configuration/control-plane.ts"; import type { Env } from "./config.ts"; import { decrypt, encrypt } from "./crypto.ts"; +import { transaction } from "./storage.ts"; export interface Transaction { bindingHash: string; @@ -79,265 +81,352 @@ export class AuthVault extends DurableObject { #encryptionKey() { return loadControlPlaneSecrets(this.env).credentialEncryptionKey.reveal(); } - async #open(record: Stored): Promise { - const secret = this.#encryptionKey(); - try { - return await decrypt( + #open(record: Stored) { + return Effect.gen({ self: this }, function* () { + const secret = this.#encryptionKey(); + return yield* decrypt( secret, record, `${this.ctx.id}:${record.kind}:${record.expiresAt}`, + ).pipe( + Effect.catchDefect((error) => { + if ( + error instanceof DOMException && + ["OperationError", "DataError", "InvalidCharacterError"].includes( + error.name, + ) + ) + return Effect.succeed(null); + return Effect.die(error); + }), ); - } catch (error) { - // A rotated key or failed ciphertext authentication invalidates this - // credential. Configuration/storage failures are not authentication results. - if ( - error instanceof DOMException && - ["OperationError", "DataError", "InvalidCharacterError"].includes( - error.name, - ) - ) - return null; - throw error; - } + }); } - async #put(record: RecordValue) { - const expiresAt = record.value.expiresAt; - const sealed = await encrypt( - this.#encryptionKey(), - record.value, - `${this.ctx.id}:${record.kind}:${expiresAt}`, - ); - await this.ctx.storage.transaction(async (tx) => { - if (await tx.get("record")) - throw new Error("Vault record already exists"); - await tx.put("record", { kind: record.kind, expiresAt, ...sealed }); - await tx.setAlarm(expiresAt); + #put(record: RecordValue) { + return Effect.gen({ self: this }, function* () { + const expiresAt = record.value.expiresAt; + const sealed = yield* encrypt( + this.#encryptionKey(), + record.value, + `${this.ctx.id}:${record.kind}:${expiresAt}`, + ); + yield* transaction(this.ctx.storage, (tx) => + Effect.gen({ self: this }, function* () { + if (yield* Effect.promise(() => tx.get("record"))) + return yield* Effect.die(new Error("Vault record already exists")); + yield* Effect.promise(() => + tx.put("record", { kind: record.kind, expiresAt, ...sealed }), + ); + yield* Effect.promise(() => tx.setAlarm(expiresAt)); + }), + ); }); } - async createContinuation(value: BridgeContinuation) { - await this.#put({ kind: "continuation", value }); + createContinuation(value: BridgeContinuation) { + return Effect.runPromise(this.#put({ kind: "continuation", value })); } - async createCode(value: BridgeCode) { - await this.#put({ kind: "code", value }); + createCode(value: BridgeCode) { + return Effect.runPromise(this.#put({ kind: "code", value })); } - async claimContinuation( - bindingHash: string, - ): Promise { - return this.ctx.storage.transaction(async (tx) => { - const stored = await tx.get("record"); - if ( - !stored || - stored.kind !== "continuation" || - stored.expiresAt <= Date.now() - ) - return null; - const value = await this.#open(stored); - if (!value || value.bindingHash !== bindingHash) return null; - await tx.delete("record"); - return value; - }); + claimContinuation(bindingHash: string): Promise { + return Effect.runPromise( + transaction(this.ctx.storage, (tx) => + Effect.gen({ self: this }, function* () { + const stored = yield* Effect.promise(() => tx.get("record")); + if ( + !stored || + stored.kind !== "continuation" || + stored.expiresAt <= Date.now() + ) + return null; + const value = yield* this.#open(stored); + if (!value || value.bindingHash !== bindingHash) return null; + yield* Effect.promise(() => tx.delete("record")); + return value; + }), + ), + ); } - async claimCode(expected: { + claimCode(expected: { installationId: string; audience: string; state: string; challenge: string; }): Promise { - return this.ctx.storage.transaction(async (tx) => { - const stored = await tx.get("record"); - if (!stored || stored.kind !== "code" || stored.expiresAt <= Date.now()) - return null; - const value = await this.#open(stored); - if ( - !value || - Object.entries(expected).some( - ([key, item]) => value[key as keyof BridgeCode] !== item, - ) - ) - return null; - await tx.delete("record"); - return value; - }); - } - async createOperation( - value: ProtectedOperation, - ): Promise { - if ( - !value.subject || - value.subject.length > 512 || - !/^[a-f0-9]{32}$/.test(value.accountId) || - !/^[a-f0-9]{32}$/.test(value.installationId) || - !/^[a-f0-9]{32}$/.test(value.operationId) || - !/^[A-Za-z0-9_-]{43}$/.test(value.grantRef) || - !Number.isSafeInteger(value.expiresAt) || - value.expiresAt <= Date.now() || - value.expiresAt > Date.now() + 8 * 60 * 60_000 || - (value.bootstrapSecret !== null && - !/^[A-Za-z0-9_-]{43}$/.test(value.bootstrapSecret)) - ) - throw new Error("Invalid protected operation"); - const grant = await this.env.AUTH_VAULT.get( - this.env.AUTH_VAULT.idFromName(`grant:${value.grantRef}`), - ).grant(value.subject); - if (!grant || value.expiresAt > grant.expiresAt) - throw new Error("Protected operation authorization unavailable"); - const sealed = await encrypt( - this.#encryptionKey(), - value, - `${this.ctx.id}:operation:${value.expiresAt}`, + return Effect.runPromise( + transaction(this.ctx.storage, (tx) => + Effect.gen({ self: this }, function* () { + const stored = yield* Effect.promise(() => tx.get("record")); + if ( + !stored || + stored.kind !== "code" || + stored.expiresAt <= Date.now() + ) + return null; + const value = yield* this.#open(stored); + if ( + !value || + Object.entries(expected).some( + ([key, item]) => value[key as keyof BridgeCode] !== item, + ) + ) + return null; + yield* Effect.promise(() => tx.delete("record")); + return value; + }), + ), ); - return this.ctx.storage.transaction(async (tx) => { - const existing = await tx.get("record"); - if (existing) { - if (existing.kind !== "operation" || existing.expiresAt <= Date.now()) - throw new Error("Protected operation unavailable"); - const previous = await this.#open(existing); + } + createOperation(value: ProtectedOperation): Promise { + return Effect.runPromise( + Effect.gen({ self: this }, function* () { if ( - !previous || - !matchesOperation(previous, value) || - previous.grantRef !== value.grantRef || - previous.expiresAt !== value.expiresAt + !value.subject || + value.subject.length > 512 || + !/^[a-f0-9]{32}$/.test(value.accountId) || + !/^[a-f0-9]{32}$/.test(value.installationId) || + !/^[a-f0-9]{32}$/.test(value.operationId) || + !/^[A-Za-z0-9_-]{43}$/.test(value.grantRef) || + !Number.isSafeInteger(value.expiresAt) || + value.expiresAt <= Date.now() || + value.expiresAt > Date.now() + 8 * 60 * 60_000 || + (value.bootstrapSecret !== null && + !/^[A-Za-z0-9_-]{43}$/.test(value.bootstrapSecret)) ) - throw new Error("Protected operation conflict"); - return previous; - } - await tx.put("record", { - kind: "operation", - expiresAt: value.expiresAt, - ...sealed, - }); - await tx.setAlarm(value.expiresAt); - return value; - }); + return yield* Effect.die(new Error("Invalid protected operation")); + const grant = yield* Effect.promise(() => + this.env.AUTH_VAULT.get( + this.env.AUTH_VAULT.idFromName(`grant:${value.grantRef}`), + ).grant(value.subject), + ); + if (!grant || value.expiresAt > grant.expiresAt) + return yield* Effect.die( + new Error("Protected operation authorization unavailable"), + ); + const sealed = yield* encrypt( + this.#encryptionKey(), + value, + `${this.ctx.id}:operation:${value.expiresAt}`, + ); + return yield* transaction(this.ctx.storage, (tx) => + Effect.gen({ self: this }, function* () { + const existing = yield* Effect.promise(() => + tx.get("record"), + ); + if (existing) { + if ( + existing.kind !== "operation" || + existing.expiresAt <= Date.now() + ) + return yield* Effect.die( + new Error("Protected operation unavailable"), + ); + const previous = yield* this.#open(existing); + if ( + !previous || + !matchesOperation(previous, value) || + previous.grantRef !== value.grantRef || + previous.expiresAt !== value.expiresAt + ) + return yield* Effect.die( + new Error("Protected operation conflict"), + ); + return previous; + } + yield* Effect.promise(() => + tx.put("record", { + kind: "operation", + expiresAt: value.expiresAt, + ...sealed, + }), + ); + yield* Effect.promise(() => tx.setAlarm(value.expiresAt)); + return value; + }), + ); + }), + ); } - async operation( - expected: OperationBinding, - ): Promise { - const stored = await this.ctx.storage.get("record"); - if ( - !stored || - stored.kind !== "operation" || - stored.expiresAt <= Date.now() - ) - return null; - const value = await this.#open(stored); - return value && matchesOperation(value, expected) ? value : null; + operation(expected: OperationBinding): Promise { + return Effect.runPromise( + Effect.gen({ self: this }, function* () { + const stored = yield* Effect.promise(() => + this.ctx.storage.get("record"), + ); + if ( + !stored || + stored.kind !== "operation" || + stored.expiresAt <= Date.now() + ) + return null; + const value = yield* this.#open(stored); + return value && matchesOperation(value, expected) ? value : null; + }), + ); } - async retireBootstrap(expected: OperationBinding): Promise { - return this.ctx.storage.transaction(async (tx) => { - const stored = await tx.get("record"); - if ( - !stored || - stored.kind !== "operation" || - stored.expiresAt <= Date.now() - ) - return false; - const value = await this.#open(stored); - if (!value || !matchesOperation(value, expected)) return false; - value.bootstrapSecret = null; - const sealed = await encrypt( - this.#encryptionKey(), - value, - `${this.ctx.id}:operation:${value.expiresAt}`, - ); - await tx.put("record", { - kind: "operation", - expiresAt: value.expiresAt, - ...sealed, - }); - return true; - }); + retireBootstrap(expected: OperationBinding): Promise { + return Effect.runPromise( + transaction(this.ctx.storage, (tx) => + Effect.gen({ self: this }, function* () { + const stored = yield* Effect.promise(() => tx.get("record")); + if ( + !stored || + stored.kind !== "operation" || + stored.expiresAt <= Date.now() + ) + return false; + const value = yield* this.#open(stored); + if (!value || !matchesOperation(value, expected)) return false; + value.bootstrapSecret = null; + const sealed = yield* encrypt( + this.#encryptionKey(), + value, + `${this.ctx.id}:operation:${value.expiresAt}`, + ); + yield* Effect.promise(() => + tx.put("record", { + kind: "operation", + expiresAt: value.expiresAt, + ...sealed, + }), + ); + return true; + }), + ), + ); } - async createTransaction(value: Transaction) { - await this.#put({ kind: "transaction", value }); + createTransaction(value: Transaction) { + return Effect.runPromise(this.#put({ kind: "transaction", value })); } - async createSession(value: Principal) { - await this.#put({ kind: "session", value }); + createSession(value: Principal) { + return Effect.runPromise(this.#put({ kind: "session", value })); } - async createGrant(value: Grant) { - await this.#put({ kind: "grant", value }); + createGrant(value: Grant) { + return Effect.runPromise(this.#put({ kind: "grant", value })); } - async claimTransaction( + claimTransaction( bindingHash: string, previousSession: string | null, ): Promise { - // Read/compare/delete are one native storage transaction. The code exchange - // happens only after this commit; failures cannot reopen a claimed state. - return this.ctx.storage.transaction(async (tx) => { - const stored = await tx.get("record"); - if ( - !stored || - stored.kind !== "transaction" || - stored.expiresAt <= Date.now() - ) - return null; - const value = await this.#open(stored); - if ( - !value || - value.bindingHash !== bindingHash || - value.previousSession !== previousSession - ) - return null; - await tx.delete("record"); - return value; - }); + return Effect.runPromise( + // Read/compare/delete are one native storage transaction. The code exchange + // happens only after this commit; failures cannot reopen a claimed state. + transaction(this.ctx.storage, (tx) => + Effect.gen({ self: this }, function* () { + const stored = yield* Effect.promise(() => tx.get("record")); + if ( + !stored || + stored.kind !== "transaction" || + stored.expiresAt <= Date.now() + ) + return null; + const value = yield* this.#open(stored); + if ( + !value || + value.bindingHash !== bindingHash || + value.previousSession !== previousSession + ) + return null; + yield* Effect.promise(() => tx.delete("record")); + return value; + }), + ), + ); } - async session(): Promise { - const stored = await this.ctx.storage.get("record"); - if (!stored || stored.kind !== "session" || stored.expiresAt <= Date.now()) - return null; - return this.#open(stored); + session(): Promise { + return Effect.runPromise( + Effect.gen({ self: this }, function* () { + const stored = yield* Effect.promise(() => + this.ctx.storage.get("record"), + ); + if ( + !stored || + stored.kind !== "session" || + stored.expiresAt <= Date.now() + ) + return null; + return yield* this.#open(stored); + }), + ); } - async grant(subject: string): Promise { - const stored = await this.ctx.storage.get("record"); - if (!stored || stored.kind !== "grant" || stored.expiresAt <= Date.now()) - return null; - const grant = await this.#open(stored); - return grant?.subject === subject ? grant : null; + grant(subject: string): Promise { + return Effect.runPromise( + Effect.gen({ self: this }, function* () { + const stored = yield* Effect.promise(() => + this.ctx.storage.get("record"), + ); + if ( + !stored || + stored.kind !== "grant" || + stored.expiresAt <= Date.now() + ) + return null; + const grant = yield* this.#open(stored); + return grant?.subject === subject ? grant : null; + }), + ); } - async selectAccount( + selectAccount( subject: string, grantRef: string, accountId: string, ): Promise { - return this.ctx.storage.transaction(async (tx) => { - const stored = await tx.get("record"); - if ( - !stored || - stored.kind !== "session" || - stored.expiresAt <= Date.now() - ) - return false; - const value = await this.#open(stored); - if (!value || value.subject !== subject || value.grantRef !== grantRef) - return false; - value.selectedAccountId = accountId; - const sealed = await encrypt( - this.#encryptionKey(), - value, - `${this.ctx.id}:session:${stored.expiresAt}`, - ); - await tx.put("record", { - kind: "session", - expiresAt: stored.expiresAt, - ...sealed, - }); - return true; - }); + return Effect.runPromise( + transaction(this.ctx.storage, (tx) => + Effect.gen({ self: this }, function* () { + const stored = yield* Effect.promise(() => tx.get("record")); + if ( + !stored || + stored.kind !== "session" || + stored.expiresAt <= Date.now() + ) + return false; + const value = yield* this.#open(stored); + if ( + !value || + value.subject !== subject || + value.grantRef !== grantRef + ) + return false; + value.selectedAccountId = accountId; + const sealed = yield* encrypt( + this.#encryptionKey(), + value, + `${this.ctx.id}:session:${stored.expiresAt}`, + ); + yield* Effect.promise(() => + tx.put("record", { + kind: "session", + expiresAt: stored.expiresAt, + ...sealed, + }), + ); + return true; + }), + ), + ); } - async retireSession(): Promise { - return this.ctx.storage.transaction(async (tx) => { - const stored = await tx.get("record"); - if (!stored || stored.kind !== "session") return null; - const principal = await this.#open(stored); - await tx.delete("record"); - return principal; - }); + retireSession(): Promise { + return Effect.runPromise( + transaction(this.ctx.storage, (tx) => + Effect.gen({ self: this }, function* () { + const stored = yield* Effect.promise(() => tx.get("record")); + if (!stored || stored.kind !== "session") return null; + const principal = yield* this.#open(stored); + yield* Effect.promise(() => tx.delete("record")); + return principal; + }), + ), + ); } - async destroy() { - await this.ctx.storage.deleteAll(); + destroy() { + return Effect.runPromise( + Effect.promise(() => this.ctx.storage.deleteAll()), + ); } - async alarm() { - await this.ctx.storage.deleteAll(); + alarm() { + return Effect.runPromise( + Effect.promise(() => this.ctx.storage.deleteAll()), + ); } } diff --git a/control-plane/worker-api.ts b/control-plane/worker-api.ts new file mode 100644 index 0000000..201a2d5 --- /dev/null +++ b/control-plane/worker-api.ts @@ -0,0 +1,577 @@ +import { + createAssetUpload, + createScriptAssetUpload, + createScriptSubdomain, + getScriptSubdomain, + getSubdomain, + listScriptDeployments, + listScriptVersions, +} from "@distilled.cloud/cloudflare/workers"; +import * as Effect from "effect/Effect"; +import type { InstallationConfig } from "../configuration/customer.ts"; +import { parseInstallationConfig } from "../configuration/customer.ts"; +import type { Artifact } from "./artifact-types.ts"; +import { digest } from "./artifact.ts"; +import { CloudflareAccount } from "./cloudflare-account.ts"; +import { + ReauthorizationRequired, + ResourceConflict, + SetupRequired, + TemporarilyUnavailable, +} from "./deployment-errors.ts"; +import { eq, fingerprint, id, object } from "./deployment-values.ts"; +import type { Installation } from "./installation-metadata.ts"; +import { bodyBytes } from "./transport.ts"; +export type Binding = { + type: string; + name: string; + class_name?: string; + script_name?: string; + namespace_id?: string; + text?: string; + json?: string; +}; +export type Settings = { bindings: Binding[]; [key: string]: unknown }; + +const token = (value: unknown): value is string => + typeof value === "string" && + value.length > 0 && + value.length <= 16_384 && + /^[A-Za-z0-9_.-]+$/.test(value); + +export type WorkerUploadBindings = + | { kind: "bootstrap"; secret: string } + | { + kind: "inherit"; + versionId: string; + bindings: Binding[]; + metadata: Record; + }; + +export class WorkerAPI { + constructor( + private readonly client: CloudflareAccount, + private readonly record: Installation, + ) {} + private get worker() { + return { + accountId: this.record.accountId, + scriptName: this.record.resources.workerName, + }; + } + private get script() { + return `workers/scripts/${this.record.resources.workerName}`; + } + origin() { + return Effect.gen({ self: this }, function* () { + const result = yield* this.client.sdk( + getSubdomain({ accountId: this.record.accountId }), + ); + if ( + !object(result) || + typeof result.subdomain !== "string" || + !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(result.subdomain) + ) + return yield* new SetupRequired(); + return `https://${this.record.resources.workerName}.${result.subdomain}.workers.dev`; + }); + } + settings() { + return Effect.gen({ self: this }, function* () { + const result = yield* this.client.request(`${this.script}/settings`, { + allowNotFound: true, + }); + if (result === null) return null; + if ( + !object(result) || + !Array.isArray(result.bindings) || + result.bindings.length > 256 || + result.bindings.some( + (b) => + !object(b) || + typeof b.type !== "string" || + typeof b.name !== "string", + ) + ) + return yield* new ResourceConflict(); + return result as Settings; + }); + } + verifyWorker(settings: Settings, installationConfig: InstallationConfig) { + return Effect.try({ + try: () => { + const bindings = settings.bindings; + if (new Set(bindings.map((b) => b.name)).size !== bindings.length) + throw new ResourceConflict(); + const byName = (name: string, type: string) => { + const binding = bindings.find((b) => b.name === name); + if (binding?.type !== type) throw new ResourceConflict(); + return binding; + }; + const installed: unknown = JSON.parse( + byName("FLAREBOT_INSTALLATION", "plain_text").text!, + ); + if ( + !eq(installed, installationConfig) || + byName("FLAREBOT_MODE", "plain_text").text !== "customer-runtime" || + byName("FLAREBOT_ENV", "plain_text").text !== "production" + ) + throw new ResourceConflict(); + for (const [name, type] of [ + ["ASSETS", "assets"], + ["AI", "ai"], + ["BROWSER", "browser"], + ["LOADER", "worker_loader"], + ["FLAREBOT_SESSION_SECRET", "secret_text"], + ]) + byName(name, type); + for (const name of ["PersonalAgent", "Sandbox"]) { + const b = byName(name, "durable_object_namespace"); + if ( + b.class_name !== name || + (b.script_name !== undefined && + b.script_name !== this.record.resources.workerName) + ) + throw new ResourceConflict(); + } + }, + catch: () => new ResourceConflict(), + }); + } + configuration(settings: Settings) { + return Effect.try({ + try: () => { + return parseInstallationConfig( + JSON.parse( + settings.bindings.find((b) => b.name === "FLAREBOT_INSTALLATION") + ?.text ?? "null", + ), + ); + }, + catch: () => new ResourceConflict(), + }); + } + activeDeployment() { + return Effect.gen({ self: this }, function* () { + const deployments = yield* this.client.sdk( + listScriptDeployments(this.worker), + ); + const latest = + object(deployments) && Array.isArray(deployments.deployments) + ? deployments.deployments[0] + : undefined; + if ( + !object(latest) || + !id(latest.id) || + !Array.isArray(latest.versions) || + latest.versions.length !== 1 || + !object(latest.versions[0]) || + latest.versions[0].percentage !== 100 || + !id(latest.versions[0].versionId) + ) + return yield* new ResourceConflict(); + return { + deploymentId: latest.id, + versionId: latest.versions[0].versionId, + }; + }); + } + latestVersion() { + return Effect.gen({ self: this }, function* () { + // The API returns newest uploads first, including undeployed versions. + const versions = yield* this.client.sdk( + listScriptVersions({ ...this.worker, page: 1, perPage: 1 }), + ); + if ( + !object(versions) || + !Array.isArray(versions.items) || + versions.items.length !== 1 || + !object(versions.items[0]) || + !id(versions.items[0].id) + ) + return yield* new ResourceConflict(); + return versions.items[0].id; + }); + } + endpoint() { + return Effect.gen({ self: this }, function* () { + const current = yield* this.client.sdk(getScriptSubdomain(this.worker)); + if ( + !object(current) || + current.enabled !== true || + current.previewsEnabled !== false + ) + return yield* new ResourceConflict(); + }); + } + upload( + artifact: Artifact, + installationConfig: InstallationConfig, + source: WorkerUploadBindings, + beforeUpload: Effect.Effect, + ) { + return Effect.gen({ self: this }, function* () { + const assets = artifact.files.filter((f) => f.assetHash); + const manifest = Object.fromEntries( + assets.map((f) => [ + `/${f.path.slice("assets/".length)}`, + { hash: f.assetHash!, size: f.size }, + ]), + ); + const session = yield* this.client.sdk( + createScriptAssetUpload({ ...this.worker, manifest }), + ); + if ( + !object(session) || + !token(session.jwt) || + !Array.isArray(session.buckets) || + session.buckets.length > assets.length + ) + return yield* new TemporarilyUnavailable(); + const byHash = new Map(assets.map((f) => [f.assetHash!, f])); + const seen = new Set(); + let completion = session.jwt; + let completed = session.buckets.length === 0; + for (const bucket of session.buckets) { + if ( + !Array.isArray(bucket) || + !bucket.length || + bucket.length > assets.length + ) + return yield* new TemporarilyUnavailable(); + const parts: Record = {}; + for (const hash of bucket) { + const file = byHash.get(hash); + if (!file || seen.has(hash)) + return yield* new TemporarilyUnavailable(); + seen.add(hash); + // Base64 encoding is transient per bucket; source bytes remain opaque. + const bytes = new Uint8Array(artifact.bytes[file.path]); + let binary = ""; + for (let i = 0; i < bytes.length; i += 8192) + binary += String.fromCharCode(...bytes.subarray(i, i + 8192)); + parts[hash] = new File([btoa(binary)], hash, { type: file.mime }); + } + const result = yield* this.client.sdk( + createAssetUpload({ + accountId: this.record.accountId, + base64: true, + jwtToken: session.jwt, + body: parts, + }), + ); + if (!object(result)) return yield* new TemporarilyUnavailable(); + if (result.jwt !== undefined) { + if (!token(result.jwt)) return yield* new TemporarilyUnavailable(); + completion = result.jwt; + completed = true; + } + } + if (!completed) return yield* new TemporarilyUnavailable(); + const d = artifact.deployment; + // Inheritance retains unreadable secrets and every verified customer binding. + const bindings: Record[] = + source.kind === "inherit" + ? [ + { type: "assets", name: "ASSETS" }, + { + type: "plain_text", + name: "FLAREBOT_INSTALLATION", + text: JSON.stringify(installationConfig), + }, + ...source.bindings + .filter( + (b) => !["ASSETS", "FLAREBOT_INSTALLATION"].includes(b.name), + ) + .map((b) => ({ + name: b.name, + type: "inherit", + version_id: "latest", + })), + ] + : [ + { type: "assets", name: "ASSETS" }, + ...d.durable_objects.bindings.map((b) => ({ + type: "durable_object_namespace", + ...b, + })), + { type: "ai", name: "AI" }, + { type: "browser", name: "BROWSER" }, + { type: "worker_loader", name: "LOADER" }, + { + type: "plain_text", + name: "FLAREBOT_MODE", + text: "customer-runtime", + }, + { type: "plain_text", name: "FLAREBOT_ENV", text: "production" }, + { + type: "plain_text", + name: "FLAREBOT_INSTALLATION", + text: JSON.stringify(installationConfig), + }, + { + type: "secret_text", + name: "FLAREBOT_SESSION_SECRET", + text: source.secret, + }, + ]; + if (source.kind === "bootstrap" && !source.secret) + return yield* new ReauthorizationRequired(); + const form = new FormData(); + form.append( + "metadata", + new Blob( + [ + JSON.stringify({ + main_module: "index.js", + compatibility_date: d.compatibility_date, + compatibility_flags: d.compatibility_flags, + bindings, + exports: d.exports, + containers: [ + { + name: this.record.resources.sandboxApplicationName, + class_name: "Sandbox", + }, + ], + keep_bindings: [ + "secret_text", + "secret_key", + "plain_text", + "json", + ], + observability: d.observability, + ...(source.kind === "inherit" ? source.metadata : {}), + assets: { jwt: completion, config: {} }, + }), + ], + { type: "application/json" }, + ), + ); + for (const file of artifact.files.filter((f) => + f.path.startsWith("worker/"), + )) { + const name = file.path.slice("worker/".length); + form.append( + name, + new Blob([artifact.bytes[file.path]], { + type: "application/javascript+module", + }), + name, + ); + } + yield* beforeUpload; + // Script PUT only accepts the literal latest for inheritance. Confirm that + // it still identifies the verified source immediately before writing. + if ( + source.kind === "inherit" && + (yield* this.latestVersion()) !== source.versionId + ) + return yield* new ResourceConflict(); + yield* this.client.request( + this.script + + (source.kind === "inherit" ? "?bindings_inherit=strict" : ""), + { method: "PUT", body: form }, + ); + }); + } + verifyContent(artifact?: Artifact) { + return Effect.gen({ self: this }, function* () { + const files = + artifact?.files.filter((file) => file.path.startsWith("worker/")) ?? []; + const limit = artifact + ? files.reduce((sum, file) => sum + file.size, 0) + 65_536 + : 16 * 1024 * 1024 + 65_536; + return yield* this.client.response( + `${this.script}/content/v2`, + {}, + (response) => + Effect.gen(function* () { + if ( + !response.ok || + !response.headers.get("Content-Type")?.startsWith("multipart/") || + response.headers.get("cf-entrypoint") !== "index.js" || + !response.body + ) + return yield* new ResourceConflict(); + const bytes = yield* bodyBytes( + response, + limit, + new ResourceConflict(), + new TemporarilyUnavailable(), + ); + const form = yield* Effect.tryPromise({ + try: () => + new Response(bytes, { + headers: { + "Content-Type": response.headers.get("Content-Type")!, + }, + }).formData(), + catch: () => new TemporarilyUnavailable(), + }); + const keys = [...form.keys()]; + if (artifact && keys.length !== files.length) + return yield* new ResourceConflict(); + if ( + !artifact && + (!keys.includes("index.js") || + keys.length > 256 || + new Set(keys).size !== keys.length || + keys.some( + (name) => + !/^[A-Za-z0-9_./-]+\.js$/.test(name) || + name + .split("/") + .some((part) => !part || part === ".." || part === "."), + )) + ) + return yield* new ResourceConflict(); + const observed: [string, string][] = []; + for (const key of keys.sort()) { + const parts = form.getAll(key); + if (parts.length !== 1) return yield* new ResourceConflict(); + const value = parts[0]; + const content = + typeof value === "string" + ? (new TextEncoder().encode(value).buffer as ArrayBuffer) + : yield* Effect.promise(() => value.arrayBuffer()); + observed.push([key, yield* digest(content)]); + } + for (const file of files) { + const parts = form.getAll(file.path.slice("worker/".length)); + if (parts.length !== 1) return yield* new ResourceConflict(); + const value = parts[0]; + const content = + typeof value === "string" + ? (new TextEncoder().encode(value).buffer as ArrayBuffer) + : yield* Effect.promise(() => value.arrayBuffer()); + if ( + content.byteLength !== file.size || + (yield* digest(content)) !== file.sha256 + ) + return yield* new ResourceConflict(); + } + return yield* fingerprint(observed); + }), + ); + }); + } + namespaces( + installationConfig: InstallationConfig, + artifact: Artifact, + supportedSource = false, + ) { + return Effect.gen({ self: this }, function* () { + const deployments = yield* this.client.sdk( + listScriptDeployments(this.worker), + ); + const latest = + object(deployments) && Array.isArray(deployments.deployments) + ? deployments.deployments[0] + : undefined; + if ( + !object(latest) || + !Array.isArray(latest.versions) || + latest.versions.length !== 1 || + !object(latest.versions[0]) || + latest.versions[0].percentage !== 100 || + !id(latest.versions[0].versionId) + ) + return yield* new ResourceConflict(); + const version = yield* this.client.request( + `${this.script}/versions/${latest.versions[0].versionId}`, + ); + const resources = + object(version) && object(version.resources) + ? version.resources + : undefined; + const runtime = resources?.script_runtime; + if ( + !object(runtime) || + (supportedSource + ? typeof runtime.compatibility_date !== "string" || + !/^\d{4}-\d{2}-\d{2}$/.test(runtime.compatibility_date) || + (!eq(runtime.compatibility_flags, ["nodejs_compat"]) && + !eq(runtime.compatibility_flags, [ + "nodejs_compat", + "global_fetch_strictly_public", + ])) + : runtime.compatibility_date !== + artifact.deployment.compatibility_date || + !eq( + runtime.compatibility_flags, + artifact.deployment.compatibility_flags, + )) || + !object(runtime.exports) + ) + return yield* new ResourceConflict(); + for (const name of ["PersonalAgent", "Sandbox"]) { + const exported = runtime.exports[name]; + if ( + !object(exported) || + exported.type !== "durable-object" || + exported.storage !== "sqlite" || + (exported.state !== undefined && exported.state !== "created") || + (exported.container !== undefined && + (name !== "Sandbox" || + exported.container !== + this.record.resources.sandboxApplicationName)) + ) + return yield* new ResourceConflict(); + } + if ( + Object.entries(runtime.exports).some( + ([name, value]) => + object(value) && + value.type === "durable-object" && + name !== "PersonalAgent" && + name !== "Sandbox", + ) + ) + return yield* new ResourceConflict(); + const bindings = resources?.bindings; + if (Array.isArray(bindings)) + yield* this.verifyWorker({ bindings }, installationConfig); + if (!Array.isArray(bindings)) return yield* new TemporarilyUnavailable(); + const namespace = (name: string) => + Effect.try({ + try: () => { + const matches = bindings.filter( + (b) => + b.type === "durable_object_namespace" && + b.name === name && + b.class_name === name && + (b.script_name === undefined || + b.script_name === this.record.resources.workerName), + ); + if (matches.length !== 1 || !id(matches[0].namespace_id)) + throw new ResourceConflict(); + return matches[0].namespace_id as string; + }, + catch: () => new ResourceConflict(), + }); + return { + personalAgentNamespaceId: yield* namespace("PersonalAgent"), + sandboxNamespaceId: yield* namespace("Sandbox"), + }; + }); + } + publish() { + return Effect.gen({ self: this }, function* () { + const current = yield* this.client.sdk(getScriptSubdomain(this.worker)); + if ( + object(current) && + current.enabled === true && + current.previewsEnabled === false + ) + return; + yield* this.client.sdk( + createScriptSubdomain({ + ...this.worker, + enabled: true, + previewsEnabled: false, + }), + ); + }); + } +} diff --git a/control-plane/worker-deployment.ts b/control-plane/worker-deployment.ts new file mode 100644 index 0000000..6039e13 --- /dev/null +++ b/control-plane/worker-deployment.ts @@ -0,0 +1,158 @@ +import * as Effect from "effect/Effect"; +import type { InstallationConfig } from "../configuration/customer.ts"; +import type { Artifact } from "./artifact-types.ts"; +import { + ReauthorizationRequired, + RecoveryRequired, + ResourceConflict, + type DeploymentFailure, +} from "./deployment-errors.ts"; +import { eq } from "./deployment-values.ts"; +import type { Deployment } from "./deployment.ts"; +import type { RegistryFailure } from "./installation-errors.ts"; +import type { InstallationOperation } from "./operation.ts"; +import type { WorkerAPI } from "./worker-api.ts"; + +type WorkerOperations = Pick & { + worker: Pick< + WorkerAPI, + | "settings" + | "configuration" + | "activeDeployment" + | "upload" + | "verifyWorker" + | "verifyContent" + | "namespaces" + >; +}; + +interface WorkerUpload { + artifact: Artifact; + config: InstallationConfig; + operation: InstallationOperation; + bootstrapSecret: string | null; + recordIntent: Effect.Effect; +} + +export function uploadWorker(api: WorkerOperations, input: WorkerUpload) { + return Effect.gen(function* () { + const { artifact, config, operation, bootstrapSecret, recordIntent } = + input; + const settings = yield* api.worker.settings(); + const baseline = operation.upgrade; + if (!baseline) { + if (settings) { + // Adoption requires the intent written before the original upload. + if (!operation.workerIntent) return yield* new ResourceConflict(); + yield* api.worker.verifyWorker(settings, config); + yield* api.worker.verifyContent(artifact); + return; + } + if (operation.workerIntent) return yield* new RecoveryRequired(); + if (!bootstrapSecret) return yield* new ReauthorizationRequired(); + yield* api.worker.upload( + artifact, + config, + { kind: "bootstrap", secret: bootstrapSecret }, + recordIntent, + ); + return; + } + + if (!settings) return yield* new ResourceConflict(); + const existing = yield* api.worker.configuration(settings); + if (existing.release?.artifactDigest === artifact.identity.artifactDigest) { + if (!operation.workerIntent) return yield* new ResourceConflict(); + const adopted = yield* api.observe( + artifact, + operation.workerIntent.operationId, + operation.workerIntent.configDigest, + ); + if (adopted.fingerprint !== baseline.fingerprint) + return yield* new ResourceConflict(); + return; + } + const source = { ...artifact, identity: baseline.fromRelease }; + const observeSource = () => + api.observe(source, baseline.deployedOperationId, baseline.configDigest, { + kind: "source", + hash: baseline.sourceCodeHash, + }); + const observed = yield* observeSource(); + if ( + !eq( + { + versionId: observed.versionId, + deploymentId: observed.deploymentId, + fingerprint: observed.fingerprint, + containerFingerprint: observed.containerFingerprint, + sourceCodeHash: observed.codeHash, + }, + { + versionId: baseline.versionId, + deploymentId: baseline.deploymentId, + fingerprint: baseline.fingerprint, + containerFingerprint: baseline.containerFingerprint, + sourceCodeHash: baseline.sourceCodeHash, + }, + ) + ) + return yield* new ResourceConflict(); + if (operation.workerIntent) return yield* new RecoveryRequired(); + + // The native adapter stages assets before this callback and checks the + // latest uploaded version again before PUT. Keep the intent at that boundary. + const beforeUpload = Effect.gen(function* () { + const checked = yield* observeSource(); + if ( + checked.fingerprint !== baseline.fingerprint || + checked.containerFingerprint !== baseline.containerFingerprint || + checked.deploymentId !== baseline.deploymentId || + checked.versionId !== baseline.versionId + ) + return yield* new ResourceConflict(); + yield* recordIntent; + if ( + !eq(yield* api.worker.activeDeployment(), { + versionId: baseline.versionId, + deploymentId: baseline.deploymentId, + }) + ) + return yield* new ResourceConflict(); + }); + yield* api.worker.upload( + artifact, + config, + { + kind: "inherit", + versionId: baseline.versionId, + bindings: observed.settings.bindings, + metadata: observed.metadata, + }, + beforeUpload, + ); + }); +} + +export function verifyWorkerNamespaces( + api: WorkerOperations, + artifact: Artifact, + config: InstallationConfig, + operation: InstallationOperation, +) { + return Effect.gen(function* () { + if (operation.upgrade) { + if (!operation.workerIntent?.configDigest) + return yield* new ResourceConflict(); + const observed = yield* api.observe( + artifact, + operation.workerIntent.operationId, + operation.workerIntent.configDigest, + ); + if (observed.fingerprint !== operation.upgrade.fingerprint) + return yield* new ResourceConflict(); + } + yield* api.worker.verifyContent(artifact); + return yield* api.worker.namespaces(config, artifact); + }); +} diff --git a/control-plane/workflow-boundary.ts b/control-plane/workflow-boundary.ts new file mode 100644 index 0000000..c8e505d --- /dev/null +++ b/control-plane/workflow-boundary.ts @@ -0,0 +1,64 @@ +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import { + deploymentFailure, + isDeploymentFailure, + TemporarilyUnavailable, + type DeploymentCode, + type DeploymentFailure, +} from "./deployment-errors.ts"; +import { + domainErrorCode, + type DomainErrorCode, + type DomainFailure, +} from "./domain-errors.ts"; +import type { RegistryFailure } from "./installation-errors.ts"; + +// Only declared codes cross native Workflow persistence/retry boundaries. +// Provider bodies, RPC causes and credentials never become Workflow history. +export async function runDeploymentEffect( + program: Effect.Effect, +): Promise { + const exit = await Effect.runPromiseExit(program); + if (Exit.isSuccess(exit)) return exit.value; + const error = Exit.findErrorOption(exit); + throw Option.isSome(error) && isDeploymentFailure(error.value) + ? deploymentFailure(error.value.code) + : new TemporarilyUnavailable(); +} + +export async function runDeploymentStep( + program: Effect.Effect, +): Promise< + | { complete: true; errorCode: null } + | { complete: false; errorCode: DeploymentCode } +> { + try { + await runDeploymentEffect(program); + return { complete: true, errorCode: null }; + } catch (error) { + if (isDeploymentFailure(error) && error.code !== "temporarily_unavailable") + return { complete: false, errorCode: error.code }; + throw new Error("temporarily_unavailable"); + } +} + +export function domainExitCode( + exit: Exit.Failure, +): DomainErrorCode { + const error = Exit.findErrorOption(exit); + return Option.isSome(error) + ? domainErrorCode(error.value) + : "temporarily_unavailable"; +} + +export async function runDomainStep( + program: Effect.Effect, +): Promise<{ error: DomainErrorCode | null }> { + const exit = await Effect.runPromiseExit(program); + if (Exit.isSuccess(exit)) return { error: null }; + const code = domainExitCode(exit); + if (code === "temporarily_unavailable") throw new Error(code); + return { error: code }; +} diff --git a/control-plane/workflow-instance.ts b/control-plane/workflow-instance.ts new file mode 100644 index 0000000..c0f9788 --- /dev/null +++ b/control-plane/workflow-instance.ts @@ -0,0 +1,50 @@ +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import type { Env } from "./config.ts"; +import type { InstallationParams } from "./installation-workflow.ts"; + +export class WorkflowUnavailable extends Data.TaggedError( + "WorkflowUnavailable", +)<{ missing: boolean }> {} + +function native(call: () => Promise) { + return Effect.tryPromise({ + try: call, + catch: (error) => + new WorkflowUnavailable({ + missing: + error instanceof Error && + [ + "instance.not_found", + "Error: instance.not_found", + "(instance.not_found) Instance not found", + ].includes(error.message), + }), + }); +} + +export function terminateWorkflow(env: Env, id: string) { + return Effect.gen(function* () { + const instance = yield* native(() => env.INSTALLATION_WORKFLOW.get(id)); + yield* native(() => instance.terminate()); + if ((yield* native(() => instance.status())).status !== "terminated") + return yield* new WorkflowUnavailable({ missing: false }); + }); +} + +// A lost create reply is reconciled against the same durable reservation. +export function ensureWorkflow( + env: Env, + id: string, + params: InstallationParams, +) { + return native(() => env.INSTALLATION_WORKFLOW.create({ id, params })).pipe( + Effect.asVoid, + Effect.catch(() => + Effect.gen(function* () { + const instance = yield* native(() => env.INSTALLATION_WORKFLOW.get(id)); + yield* native(() => instance.status()); + }), + ), + ); +} diff --git a/docs/effect-control-plane-review.md b/docs/effect-control-plane-review.md new file mode 100644 index 0000000..f7ef79e --- /dev/null +++ b/docs/effect-control-plane-review.md @@ -0,0 +1,119 @@ +# Control-plane structural review + +Reviewed the uncommitted Effect migration against branch base `3d3e35c`, then +refactored it under the thermo-nuclear code-quality review standard. The initial +implementation did not meet the structural approval bar. The findings below +describe the problems found and the changes applied. + +## 1. Deployment transport and policy had accumulated in one class + +**Blocking.** `deployment-api.ts` was already 1,034 lines at the branch base and +grew to 1,201 during migration. Account gateway setup, Worker uploads, container +rollouts, wire-format normalization and cross-resource upgrade checks shared one +class. Adding Effect did not resolve that ownership problem. + +Removed the class and file. `WorkerAPI` and `ContainerAPI` own their respective +provider operations. `CloudflareAccount` owns credentials, bounded transport and +SDK error translation. `Deployment` performs observations spanning both resources +and verifies upgrade baselines. Gateway reconciliation is an independent +account operation; provider setup no longer constructs an entire deployment +client. There is no forwarding facade retaining the old API. + +The generated SDK still cannot represent every required Cloudflare contract. +Raw observations retain unknown wire fields where fingerprints and preservation +checks require them. Container observations now declare only the fields actually +validated instead of asserting the entire document as `Application`. + +## 2. Execution plumbing obscured state transitions + +**Blocking.** Registry methods embedded lifecycle rules inside nested Effect and +storage callbacks. The migration expanded the registry from 664 to 809 lines. +Domain retries reconstructed individual fields through repeated nullable branches, +making preservation of uncertain attachment state difficult to check. + +Moved installation and domain transitions into storage-independent functions. +Domain startup dispatches directly on attach, retry and remove; retry retains +the existing domain evidence. The native registry continues to own authorization, +replay and atomic writes. Its owner check and owned-record reads are centralized +behind private methods, so the helpers cannot become public RPC entry points. +HTTP callers use the registry's validated contracts instead of parsing the same +records again. + +Tests now cover preservation and explicit clearance of ambiguous write intents, +promotion of a release only after verified completion, and domain removal being +blocked until uncertain attachment evidence is resolved. Persisted schemas, keys, +revision checks and transaction boundaries remain unchanged. + +## 3. The migration retained circular dependencies + +**Blocking missed simplification.** OAuth and bridge routing imported each other. +The installation router imported setup handlers that imported owner lookup from +that same router. These existing cycles made helper reuse pull routing policy +back into lower-level modules. + +Moved HTTP primitives, OAuth authorization startup and installation access into +their own cohesive modules. An import-graph check of direct runtime imports among +backend TypeScript modules found two cyclic groups before this review and none +afterward. Type-only imports are excluded from that check. + +## 4. Effect and mode contracts added avoidable indirection + +**Blocking.** The one-off `WorkerDeployment` Context service and Layer simply +wrapped a client already constructed inside each Workflow attempt. Other backend +programs passed their dependencies directly. The Workflow also decided whether +configuration existed by comparing a persisted step-name string, then exposed +that configuration as `unknown`. + +Removed the Context service and Layer. Worker orchestration takes its typed client +directly. Origin preparation and configured deployment steps are explicit, and +configured steps receive `InstallationConfig`. Durable step names and their order +are unchanged. Redundant generator wrappers around vault transactions were removed. + +Installation commands now distinguish start, recover and upgrade, with a target +required only for upgrade. Worker upload bindings distinguish bootstrap credentials +from inheritance. Source-code observation uses an explicit mode instead of giving +`undefined`, `null` and strings different hidden meanings. Compile-time checks +reject mixed command and binding modes. Request options are named, and the unused +credential-override parameters were removed. + +## Measurements + +These compare the working tree immediately before and after this review, excluding +the frontend and declaration files. + +| Measure | Before | After | +| --------------------------------------- | ----------: | --------: | +| Largest backend file | 1,201 lines | 613 lines | +| Installation registry | 809 lines | 613 lines | +| Installation Workflow | 465 lines | 447 lines | +| Runtime import cycles, as cyclic groups | 2 | 0 | +| Backend source files | 38 | 48 | +| Total backend lines | 7,189 | 7,361 | + +The refactor adds 172 lines overall. Its gains are explicit responsibilities, +fewer dependency cycles and fewer invalid input combinations. Provider ordering, +reconciliation checks and fingerprint rules remain substantial because they +protect installed resources after ambiguous writes; collapsing those checks +would change behavior. + +## Validation + +Passed after the refactor: + +- Typecheck, formatting checks and `git diff --check`. +- Control-plane fixture build and Wrangler dry run. +- Focused Effect, metadata and lifecycle tests: 30 tests. +- Native deployment orchestration: 21 tests, including upgrades, ambiguous provider + replies, authorization expiry and process restart. +- Domain provisioning, recovery and browser setup: 16 tests. +- OAuth: 4 tests; ownership and registry: 17 tests. +- Deployment artifacts and Effect tests: 14 tests; native network and gateway + tests: 9 tests. +- Independent publisher bridge server test; update-on-visit tests; complete + installation status UI test. + +Some named suites include the same focused tests; these counts are not additive. +No persisted-data migration, remote deployment, dependency change or SDK patch was +part of this review. The pre-existing browser bridge timeout was not rerun during +this structural pass, and Docker is unavailable for the golden-path suite; those +limits are described in [the migration notes](effect-control-plane.md). diff --git a/docs/effect-control-plane.md b/docs/effect-control-plane.md new file mode 100644 index 0000000..b16f9eb --- /dev/null +++ b/docs/effect-control-plane.md @@ -0,0 +1,156 @@ +# Effect control plane + +The publisher backend uses **Effect 4.0.0-rc.112** and +**@distilled.cloud/cloudflare 1.0.0-rc.8**, pinned exactly. The SDK's published +peer requirement includes this exact Effect release. Customer Agents, Think, +model streaming and scheduled tasks retain their Cloudflare SDK implementations. + +## Execution and persistence + +Application functions return Effects throughout OAuth, sessions and grants, +bridge signing and health checks, provider setup, installation commands, +artifact verification, deployment and custom domains. Callers compose these +programs directly; there are no Promise compatibility implementations alongside +them. Configuration errors, authorization failures, resource conflicts and +registry failures retain their application error types. + +Promise execution is confined to native boundaries: + +- `index.ts` runs the HTTP program with the incoming request's abort signal. +- Durable Object RPC methods run storage programs and return serializable values. + `registry-result.ts` transports only declared registry codes; `registry-client.ts` + and `vault-client.ts` preserve native receivers and derive types from server + method contracts before Wrangler adds RPC transport types. +- `storage.ts` runs each native transaction callback and rejects it on Effect + failure, defect or interruption. This rolls back partial writes while retaining + the original local failure channel. Native storage remains authoritative for + atomicity, ordering, alarms and persistence. Existing strict Zod metadata + parsers remain synchronous, including validation of persisted records. +- `workflow-boundary.ts` converts step results to safe durable codes. Each native + step attempt rereads authorization and registry state. Cloudflare owns step + names, retries, timeouts and durable sleeps. Tokens and Effect runtimes are never + persisted in Workflow state. +- `cloudflare-sdk.ts` provides request-local OAuth credentials and a bounded fetch + transport to Distilled. `Retry.none` disables SDK retries, including retries of + accepted mutations whose replies were lost. The native fetch boundary forwards + caller cancellation directly and keeps deadlines active through body reads. + +Artifact caching retains only successfully verified immutable values. It does +not share running fibers or rejected Promises across requests. Upload intents +still commit after asset staging and before Worker PUT. Container and domain +intent records survive ambiguous writes, and recovery observes their outcome +before authorizing another mutation. Existing metadata formats and fingerprints +remain compatible with installed releases. + +The old `deployment-effect.ts`, `domain-effect.ts`, `domainCall` and throwing +`fail` helper have been removed. Workflow result handling lives in one module. + +## Responsibilities + +The former `deployment-api.ts` has been removed. Provider transport, resource +operations and upgrade policy have separate homes: + +| Module | Responsibility | +| ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| `cloudflare-account.ts` | Account-scoped SDK execution and bounded requests for unsupported native endpoints | +| `worker-api.ts` | Worker settings, versions, assets, uploads and verification | +| `container-api.ts` | Container observations, normalization, creation, patching and rollout reconciliation | +| `deployment.ts` | Consistent observations across Worker and container resources; upgrade baseline verification | +| `model-gateway.ts` | Shared account gateway reconciliation | +| `installation-lifecycle.ts`, `domain-lifecycle.ts` | State transitions over validated records, independent of storage | +| `installation-registry.ts` | Owner validation, atomic storage, replay and native RPC | +| `http-response.ts`, `oauth-authorization.ts`, `installation-access.ts` | HTTP primitives, authorization startup and owner lookup without router dependency cycles | + +Worker orchestration takes its attempt's client explicitly. No additional Context +service or Layer wraps that client. Installation commands and upload bindings use +discriminated unions: recovery cannot select a new target release, and a fresh +upload cannot also inherit an existing Worker version. Configured Workflow steps +receive `InstallationConfig`; behavior does not depend on comparing step-name +strings. Persisted step names and record formats remain unchanged. + +The [structural review](effect-control-plane-review.md) records the findings, +refactoring decisions and validation limits. + +## Distilled coverage and limits + +Generated SDK operations supply request, response, failure and service types for: + +| Integration | SDK operations | +| ----------------------- | ---------------------------------------------------------------------------------------------- | +| Account authorization | `listAccounts`, with bounded pagination | +| AI Gateway | `getAiGateway`, `createAiGateway` | +| Worker publication | `getSubdomain`, `getScriptSubdomain`, `createScriptSubdomain` | +| Deployment observations | `listScriptDeployments`, `listScriptVersions` | +| Asset staging | `createScriptAssetUpload`, `createAssetUpload`, including session JWT and multipart file parts | +| Containers | `createContainerApplication`, followed by metadata verification | +| Domains | `listZones`, `getZone`, `listRecords`, `listDomains`, `deleteDomain` | + +**The SDK cannot supply complete types for every integration in this release.** +The following narrow Effect adapters remain deliberately explicit: + +- Cloudflare's authorization-code exchange, UserInfo and revocation endpoints + are on `dash.cloudflare.com/oauth2`, outside the generated client-v4 SDK. +- `PutScriptMetadata` does not declare the `exports` metadata used for this + application's native SQLite Durable Objects. Worker PUT retains its complete + multipart metadata, including exports, containers and inherited bindings. +- The SDK does not expose the script `/domains/records` attachment operation with + its three false override guards. Substituting `putDomain` would change the + existing protection against taking over another Worker or DNS record. +- Container rollout get/list operations are absent. Container observations and + patches retain complete wire configuration because that data participates in + persisted fingerprints and must preserve customer configuration. +- Worker settings, version resources and content verification need complete wire + observations to detect unknown fields and retain existing fingerprint meaning. + Although `getScriptScriptAndVersionSetting` includes bindings (unlike the shorter + `getScriptSetting` operation), a generated projection does not replace those + preservation checks. + +The SDK protocol can also produce generic HTTP errors absent from an operation's +declared failure union: `listAccounts` omits `Forbidden`, and `getAiGateway` +omits generic `NotFound`. The adapters recognize the SDK's exported error classes +and translate them to application failures; they do not assert exhaustive +coverage of those generated unions. + +There are no SDK patches, generated-schema overrides or casts pretending these +unsupported contracts are covered. SDK responses still undergo the application's +ownership, shape and size checks; a dependency type is not proof of authorization. +Provider bodies and errors are sanitized before HTTP or Workflow history. + +## Validation + +```sh +pnpm typecheck +pnpm build:release +pnpm build:control-plane:fixture +pnpm test:control-plane-effect +pnpm test:deployment +pnpm test:deployment-network +pnpm test:oauth +pnpm test:bridge-server +pnpm test:ownership +pnpm test:orchestrator +pnpm test:domains +pnpm test:updates +pnpm test:installation-status +pnpm test:upgrade-state +pnpm test:bridge +pnpm test:catalog +``` + +The fixture build is local validation and must not be published as a release. +The focused Effect suites cover transaction commit/rollback, interruption, +credential isolation, SDK pagination and retry suppression, safe errors, +response-body cancellation/deadlines and mutation intent ordering. Native suites +exercise OAuth replay and expiry, encryption, owner isolation, upgrades, ambiguous +provider replies, process restart, domain reconciliation and browser onboarding. + +The independent bridge server test exercises native publisher OAuth continuation, +single-use exchange, signed provider notifications, health checks and encrypted +operation storage. It verifies customer assertions with a local HTTP responder; +it does not boot the customer Sandbox. + +Local validation limits: the existing `test:bridge` browser case still times out +at its first customer `/auth/login` navigation, as recorded on the untouched +baseline before this migration. The Docker golden-path suite could not run +because Docker is not installed in this environment. Neither check is disabled +in CI. diff --git a/package.json b/package.json index a1afe56..aaa4ad5 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "dev": "vite", "build": "vite build", "dev:worker": "pnpm build && wrangler dev", + "generate:types": "wrangler types", "preview": "pnpm dev:worker", "deploy": "pnpm build && wrangler deploy", "typecheck": "tsrx-tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.worker.json", @@ -16,7 +17,7 @@ "test:config": "node --test tests/configuration.test.mjs", "test:providers": "node --test tests/model-provider.test.mjs", "build:release": "pnpm build && node scripts/build-release.mjs", - "test:deployment": "node --test tests/deployment.test.mjs", + "test:deployment": "node --test tests/deployment.test.mjs tests/deployment-effect.test.mjs", "test:deployment-network": "node --test tests/deployment-network.test.mjs tests/gateway-provisioning.test.mjs", "test:think": "node --test tests/think.test.mjs", "test:settings": "node --test tests/settings-ui.test.mjs", @@ -33,17 +34,19 @@ "test:chat-ui": "node --test --test-reporter=tap tests/chat-ui.test.mjs", "build:control-plane": "node scripts/build-catalog.mjs && vite build --config control-plane/ui/vite.config.ts && wrangler deploy --config wrangler.control-plane.jsonc --dry-run --outdir dist/control-plane/worker", "test:oauth": "node --test tests/oauth.test.mjs", - "test:ownership": "node --test --test-reporter=tap tests/ownership.test.mjs", + "test:ownership": "node --test --test-reporter=tap tests/installation-metadata.test.mjs tests/ownership.test.mjs", "build:control-plane:fixture": "node scripts/build-catalog.mjs --development-fixture && vite build --config control-plane/ui/vite.config.ts && wrangler deploy --config wrangler.control-plane.jsonc --dry-run --outdir dist/control-plane/worker", "test:orchestrator": "node --test --test-reporter=tap tests/orchestrator.test.mjs", "test:bridge": "node --test tests/bridge.test.mjs", - "test:domains": "node --test --test-concurrency=1 tests/domain-auth.test.mjs tests/domain-provisioning.test.mjs tests/domain-workflow.test.mjs tests/domain-ui.test.mjs", + "test:domains": "node --test --test-concurrency=1 tests/domain-effect.test.mjs tests/domain-auth.test.mjs tests/domain-provisioning.test.mjs tests/domain-workflow.test.mjs tests/domain-ui.test.mjs", "test:installation-status": "node --test --test-reporter=tap tests/installation-status.test.mjs", "test:updates": "node --test tests/update-on-visit.test.mjs tests/update-on-visit-ui.test.mjs", "test:upgrade-state": "node --test tests/upgrade-state.test.mjs", "test:catalog": "node --test tests/catalog.test.mjs", "test:diagnostics": "node --test --test-concurrency=1 tests/diagnostics.test.mjs tests/diagnostics-native.test.mjs", - "test:golden-path": "node --test --test-reporter=tap tests/golden-path.test.mjs" + "test:golden-path": "node --test --test-reporter=tap tests/golden-path.test.mjs", + "test:control-plane-effect": "node --test tests/control-plane-effect.test.mjs", + "test:bridge-server": "node --test tests/bridge-server.test.mjs" }, "dependencies": { "@ai-sdk/anthropic": "4.0.49", @@ -51,6 +54,7 @@ "@ai-sdk/openai-compatible": "3.0.44", "@cloudflare/sandbox": "0.12.9", "@cloudflare/think": "0.17.0", + "@distilled.cloud/cloudflare": "1.0.0-rc.8", "@octanejs/adapter-cloudflare": "^0.0.42", "@octanejs/aria": "0.0.45", "@octanejs/phosphor-icons": "^0.0.31", @@ -59,6 +63,7 @@ "agents": "0.22.0", "ai": "7.0.93", "cron-schedule": "6.0.0", + "effect": "4.0.0-rc.112", "entities": "7.0.1", "marked": "18.0.11", "octane": "^0.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9d7f49e..fb13569 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -127,31 +127,37 @@ importers: version: 0.12.9 '@cloudflare/think': specifier: 0.17.0 - version: 0.17.0(patch_hash=aa1d46c3883cf09a89670ecf7eeebc650ef72a7731b140a240f5d973db43cf85)(@ai-sdk/provider@4.0.10)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(agents@0.22.0(@babel/core@8.0.1)(@babel/runtime@7.29.7)(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(@modelcontextprotocol/server@2.0.0)(ai@7.0.93(zod@4.4.3))(react@19.2.8)(rolldown@1.2.7)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1))(zod@4.4.3))(ai@7.0.93(zod@4.4.3))(react@19.2.8)(supports-color@10.2.2)(zod@4.4.3) + version: 0.17.0(patch_hash=aa1d46c3883cf09a89670ecf7eeebc650ef72a7731b140a240f5d973db43cf85)(@ai-sdk/provider@4.0.10)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(agents@0.22.0(@babel/core@8.0.1)(@babel/runtime@7.29.7)(@cloudflare/codemode@0.5.1(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.93(zod@4.4.3))(zod@4.4.3))(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(@modelcontextprotocol/server@2.0.0)(ai@7.0.93(zod@4.4.3))(chat@4.39.0(ai@7.0.93(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3))(just-bash@3.4.2(supports-color@10.2.2))(react@19.2.8)(rolldown@1.2.7)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0))(zod@4.4.3))(ai@7.0.93(zod@4.4.3))(react@19.2.8)(supports-color@10.2.2)(zod@4.4.3) + '@distilled.cloud/cloudflare': + specifier: 1.0.0-rc.8 + version: 1.0.0-rc.8(effect@4.0.0-rc.112) '@octanejs/adapter-cloudflare': specifier: ^0.0.42 - version: 0.0.42(@octanejs/app-core@0.0.48(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)))) + version: 0.0.42(@octanejs/app-core@0.0.48(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0)))) '@octanejs/aria': specifier: 0.0.45 - version: 0.0.45(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)))(react@19.2.8) + version: 0.0.45(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0)))(react@19.2.8) '@octanejs/phosphor-icons': specifier: ^0.0.31 - version: 0.0.31(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1))) + version: 0.0.31(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0))) '@octanejs/tanstack-router': specifier: ^0.1.52 - version: 0.1.52(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1))) + version: 0.1.52(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0))) '@octanejs/vite-plugin': specifier: ^0.1.52 - version: 0.1.52(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)))(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)) + version: 0.1.52(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0)))(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0)) agents: specifier: 0.22.0 - version: 0.22.0(@babel/core@8.0.1)(@babel/runtime@7.29.7)(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(@modelcontextprotocol/server@2.0.0)(ai@7.0.93(zod@4.4.3))(react@19.2.8)(rolldown@1.2.7)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1))(zod@4.4.3) + version: 0.22.0(@babel/core@8.0.1)(@babel/runtime@7.29.7)(@cloudflare/codemode@0.5.1(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.93(zod@4.4.3))(zod@4.4.3))(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(@modelcontextprotocol/server@2.0.0)(ai@7.0.93(zod@4.4.3))(chat@4.39.0(ai@7.0.93(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3))(just-bash@3.4.2(supports-color@10.2.2))(react@19.2.8)(rolldown@1.2.7)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0))(zod@4.4.3) ai: specifier: 7.0.93 version: 7.0.93(zod@4.4.3) cron-schedule: specifier: 6.0.0 version: 6.0.0 + effect: + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112 entities: specifier: 7.0.1 version: 7.0.1 @@ -160,10 +166,10 @@ importers: version: 18.0.11 octane: specifier: ^0.2.2 - version: 0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)) + version: 0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0)) octane-kumo: specifier: github:NathanBeddoeWebDev/octane-kumo#b86a46a4ed720eda9571d8940caa6f5ae6644fe3&path:/packages/octane-kumo - version: https://codeload.github.com/NathanBeddoeWebDev/octane-kumo/tar.gz/b86a46a4ed720eda9571d8940caa6f5ae6644fe3#path:/packages/octane-kumo(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)))(react@19.2.8) + version: https://codeload.github.com/NathanBeddoeWebDev/octane-kumo/tar.gz/b86a46a4ed720eda9571d8940caa6f5ae6644fe3#path:/packages/octane-kumo(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0)))(react@19.2.8) workers-ai-provider: specifier: 4.0.0 version: 4.0.0(patch_hash=5f0670c81673a4c40038ff1cf2c3d0534a53f050acbcc27e0b89c00c3ead9864)(@ai-sdk/anthropic@4.0.49(zod@4.4.3))(@ai-sdk/openai@4.0.59(zod@4.4.3))(@ai-sdk/provider@4.0.10)(ai@7.0.93(zod@4.4.3)) @@ -179,7 +185,7 @@ importers: version: 0.3.131(prettier@3.9.6) '@tsrx/typescript-plugin': specifier: ^0.3.130 - version: 0.3.131(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)))(typescript@5.9.3) + version: 0.3.131(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0)))(typescript@5.9.3) '@types/node': specifier: ^26.4.1 version: 26.4.1 @@ -206,7 +212,7 @@ importers: version: 5.9.3 vite: specifier: ^8.2.2 - version: 8.2.2(@types/node@26.4.1)(esbuild@0.28.1) + version: 8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0) wrangler: specifier: ^4.128.0 version: 4.128.0(@cloudflare/workers-types@5.20260904.1) @@ -474,6 +480,16 @@ packages: '@date-fns/tz@1.5.0': resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} + '@distilled.cloud/cloudflare@1.0.0-rc.8': + resolution: {integrity: sha512-nhEyGOaupi/JmT0PS1DEMH5Ebwu004pvQv3Emzt9MJz3mF3LTdiuAnxP86rf3jsFtfa826TDokKArVMq55OKRA==} + peerDependencies: + effect: '>=4.0.0-rc.112 || >=4.0.0' + + '@distilled.cloud/core@1.0.0-rc.8': + resolution: {integrity: sha512-baZMSaBh9kEyAqdhO+UbkvVDK+wqyI8d0UW0paSp5CqVResOl7ydHU4tCbBUOLoxOrqLRWfmImKrhW9SY1R8qw==} + peerDependencies: + effect: '>=4.0.0-rc.112 || >=4.0.0' + '@emnapi/runtime@1.11.3': resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} @@ -879,6 +895,36 @@ packages: resolution: {integrity: sha512-mQ2s0pYYiav+tzCDR05Zptem8Ey2v8s11lri5RKGhTtL4COVCvVCk5vtyRYNT+9L8qSfyOqqefF9UtnW8mC5jA==} engines: {node: '>= 20.19.0'} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} + cpu: [x64] + os: [win32] + '@noble/hashes@2.4.0': resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==} engines: {node: '>= 20.19.0'} @@ -1884,6 +1930,9 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + effect@4.0.0-rc.112: + resolution: {integrity: sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A==} + electron-to-chromium@1.5.422: resolution: {integrity: sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==} @@ -1984,6 +2033,10 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-check@4.9.0: + resolution: {integrity: sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==} + engines: {node: '>=12.17.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2467,6 +2520,13 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.4: + resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} + hasBin: true + + msgpackr@2.1.0: + resolution: {integrity: sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==} + nanoid@3.3.18: resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -2492,6 +2552,10 @@ packages: resolution: {integrity: sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==} engines: {node: ^18 || ^20 || >= 21} + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + node-gyp-build@4.8.4: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true @@ -2686,6 +2750,9 @@ packages: pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + pure-rand@8.4.2: + resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} + qs@6.16.0: resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} engines: {node: '>=0.6'} @@ -3136,9 +3203,6 @@ packages: zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} - zod@4.5.4: - resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==} - zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -3355,13 +3419,13 @@ snapshots: - ai - zod - '@cloudflare/think@0.17.0(patch_hash=aa1d46c3883cf09a89670ecf7eeebc650ef72a7731b140a240f5d973db43cf85)(@ai-sdk/provider@4.0.10)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(agents@0.22.0(@babel/core@8.0.1)(@babel/runtime@7.29.7)(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(@modelcontextprotocol/server@2.0.0)(ai@7.0.93(zod@4.4.3))(react@19.2.8)(rolldown@1.2.7)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1))(zod@4.4.3))(ai@7.0.93(zod@4.4.3))(react@19.2.8)(supports-color@10.2.2)(zod@4.4.3)': + '@cloudflare/think@0.17.0(patch_hash=aa1d46c3883cf09a89670ecf7eeebc650ef72a7731b140a240f5d973db43cf85)(@ai-sdk/provider@4.0.10)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(agents@0.22.0(@babel/core@8.0.1)(@babel/runtime@7.29.7)(@cloudflare/codemode@0.5.1(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.93(zod@4.4.3))(zod@4.4.3))(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(@modelcontextprotocol/server@2.0.0)(ai@7.0.93(zod@4.4.3))(chat@4.39.0(ai@7.0.93(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3))(just-bash@3.4.2(supports-color@10.2.2))(react@19.2.8)(rolldown@1.2.7)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0))(zod@4.4.3))(ai@7.0.93(zod@4.4.3))(react@19.2.8)(supports-color@10.2.2)(zod@4.4.3)': dependencies: '@ai-sdk/anthropic': 4.0.49(zod@4.4.3) '@ai-sdk/openai': 4.0.59(zod@4.4.3) '@cloudflare/codemode': 0.5.1(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.93(zod@4.4.3))(zod@4.4.3) '@cloudflare/shell': 0.4.3(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.93(zod@4.4.3))(zod@4.4.3) - agents: 0.22.0(@babel/core@8.0.1)(@babel/runtime@7.29.7)(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(@modelcontextprotocol/server@2.0.0)(ai@7.0.93(zod@4.4.3))(react@19.2.8)(rolldown@1.2.7)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1))(zod@4.4.3) + agents: 0.22.0(@babel/core@8.0.1)(@babel/runtime@7.29.7)(@cloudflare/codemode@0.5.1(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.93(zod@4.4.3))(zod@4.4.3))(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(@modelcontextprotocol/server@2.0.0)(ai@7.0.93(zod@4.4.3))(chat@4.39.0(ai@7.0.93(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3))(just-bash@3.4.2(supports-color@10.2.2))(react@19.2.8)(rolldown@1.2.7)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0))(zod@4.4.3) ai: 7.0.93(zod@4.4.3) chat: 4.39.0(ai@7.0.93(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3) just-bash: 3.4.2(supports-color@10.2.2) @@ -3406,6 +3470,15 @@ snapshots: '@date-fns/tz@1.5.0': {} + '@distilled.cloud/cloudflare@1.0.0-rc.8(effect@4.0.0-rc.112)': + dependencies: + '@distilled.cloud/core': 1.0.0-rc.8(effect@4.0.0-rc.112) + effect: 4.0.0-rc.112 + + '@distilled.cloud/core@1.0.0-rc.8(effect@4.0.0-rc.112)': + dependencies: + effect: 4.0.0-rc.112 + '@emnapi/runtime@1.11.3': dependencies: tslib: 2.8.1 @@ -3669,11 +3742,11 @@ snapshots: eventsource-parser: 3.1.1 jose: 6.2.11 pkce-challenge: 5.0.1 - zod: 4.5.4 + zod: 4.4.3 '@modelcontextprotocol/core@2.0.0': dependencies: - zod: 4.5.4 + zod: 4.4.3 '@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3)': dependencies: @@ -3702,7 +3775,7 @@ snapshots: '@modelcontextprotocol/server@2.0.0': dependencies: '@modelcontextprotocol/core': 2.0.0 - zod: 4.5.4 + zod: 4.4.3 '@mongodb-js/zstd@7.0.0': dependencies: @@ -3710,70 +3783,88 @@ snapshots: prebuild-install: 7.1.3 optional: true + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + optional: true + '@noble/hashes@2.4.0': {} '@nodable/entities@3.0.0': {} - '@octanejs/adapter-cloudflare@0.0.42(@octanejs/app-core@0.0.48(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1))))': + '@octanejs/adapter-cloudflare@0.0.42(@octanejs/app-core@0.0.48(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0))))': dependencies: - '@octanejs/app-core': 0.0.48(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1))) + '@octanejs/app-core': 0.0.48(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0))) - '@octanejs/app-core@0.0.48(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)))': + '@octanejs/app-core@0.0.48(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0)))': dependencies: '@ripple-ts/adapter': 0.3.125 esbuild: 0.28.1 - octane: 0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)) + octane: 0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0)) - '@octanejs/aria@0.0.45(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)))(react@19.2.8)': + '@octanejs/aria@0.0.45(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0)))(react@19.2.8)': dependencies: '@internationalized/date': 3.12.4 '@internationalized/number': 3.6.8 '@internationalized/string': 3.2.10 '@react-types/shared': 3.36.1(react@19.2.8) clsx: 2.1.1 - octane: 0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)) + octane: 0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0)) transitivePeerDependencies: - react - '@octanejs/base-ui@0.1.49(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)))': + '@octanejs/base-ui@0.1.49(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0)))': dependencies: '@floating-ui/utils': 0.2.12 - '@octanejs/floating-ui': 0.1.50(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1))) + '@octanejs/floating-ui': 0.1.50(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0))) aria-hidden: 1.2.6 - octane: 0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)) + octane: 0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0)) tabbable: 6.5.0 - '@octanejs/day-picker@0.0.18(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)))': + '@octanejs/day-picker@0.0.18(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0)))': dependencies: '@date-fns/tz': 1.5.0 date-fns: 4.4.0 - octane: 0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)) + octane: 0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0)) - '@octanejs/floating-ui@0.1.50(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)))': + '@octanejs/floating-ui@0.1.50(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0)))': dependencies: '@floating-ui/dom': 1.8.0 '@floating-ui/utils': 0.2.12 - octane: 0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)) + octane: 0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0)) tabbable: 6.5.0 - '@octanejs/phosphor-icons@0.0.31(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)))': + '@octanejs/phosphor-icons@0.0.31(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0)))': dependencies: - octane: 0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)) + octane: 0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0)) - '@octanejs/tanstack-router@0.1.52(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)))': + '@octanejs/tanstack-router@0.1.52(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0)))': dependencies: '@tanstack/history': 1.162.0 '@tanstack/router-core': 1.171.15 '@tanstack/store': 0.9.3 isbot: 5.2.2 - octane: 0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)) + octane: 0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0)) - '@octanejs/vite-plugin@0.1.52(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)))(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1))': + '@octanejs/vite-plugin@0.1.52(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0)))(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0))': dependencies: - '@octanejs/app-core': 0.0.48(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1))) + '@octanejs/app-core': 0.0.48(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0))) '@ripple-ts/adapter': 0.3.125 - octane: 0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)) - vite: 8.2.2(@types/node@26.4.1)(esbuild@0.28.1) + octane: 0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0)) + vite: 8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0) '@oxc-project/types@0.140.0': {} @@ -3998,14 +4089,14 @@ snapshots: '@rolldown/binding-win32-x64-msvc@1.2.7': optional: true - '@rolldown/plugin-babel@0.2.3(@babel/core@8.0.1)(@babel/runtime@7.29.7)(rolldown@1.2.7)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1))': + '@rolldown/plugin-babel@0.2.3(@babel/core@8.0.1)(@babel/runtime@7.29.7)(rolldown@1.2.7)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0))': dependencies: '@babel/core': 8.0.1 picomatch: 4.0.7 rolldown: 1.2.7 optionalDependencies: '@babel/runtime': 7.29.7 - vite: 8.2.2(@types/node@26.4.1)(esbuild@0.28.1) + vite: 8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0) '@rolldown/pluginutils@1.0.1': {} @@ -4108,13 +4199,13 @@ snapshots: '@tsrx/runtime@0.1.4': {} - '@tsrx/typescript-plugin@0.3.131(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)))(typescript@5.9.3)': + '@tsrx/typescript-plugin@0.3.131(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0)))(typescript@5.9.3)': dependencies: '@volar/language-core': 2.4.28 '@volar/typescript': 2.4.28(typescript@5.9.3) typescript: 5.9.3 optionalDependencies: - octane: 0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)) + octane: 0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0)) '@types/debug@4.1.13': dependencies: @@ -4185,14 +4276,14 @@ snapshots: acorn@8.18.0: {} - agents@0.22.0(@babel/core@8.0.1)(@babel/runtime@7.29.7)(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(@modelcontextprotocol/server@2.0.0)(ai@7.0.93(zod@4.4.3))(react@19.2.8)(rolldown@1.2.7)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1))(zod@4.4.3): + agents@0.22.0(@babel/core@8.0.1)(@babel/runtime@7.29.7)(@cloudflare/codemode@0.5.1(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.93(zod@4.4.3))(zod@4.4.3))(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(@modelcontextprotocol/server@2.0.0)(ai@7.0.93(zod@4.4.3))(chat@4.39.0(ai@7.0.93(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3))(just-bash@3.4.2(supports-color@10.2.2))(react@19.2.8)(rolldown@1.2.7)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0))(zod@4.4.3): dependencies: '@babel/plugin-proposal-decorators': 8.0.2(@babel/core@8.0.1) '@cfworker/json-schema': 4.1.1 '@modelcontextprotocol/client': 2.0.0 '@modelcontextprotocol/sdk': 1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3) '@modelcontextprotocol/server': 2.0.0 - '@rolldown/plugin-babel': 0.2.3(@babel/core@8.0.1)(@babel/runtime@7.29.7)(rolldown@1.2.7)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)) + '@rolldown/plugin-babel': 0.2.3(@babel/core@8.0.1)(@babel/runtime@7.29.7)(rolldown@1.2.7)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0)) cron-schedule: 6.0.0 esbuild: 0.28.1 mimetext: 3.0.28 @@ -4201,9 +4292,12 @@ snapshots: yaml: 2.9.0 zod: 4.4.3 optionalDependencies: + '@cloudflare/codemode': 0.5.1(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.93(zod@4.4.3))(zod@4.4.3) ai: 7.0.93(zod@4.4.3) + chat: 4.39.0(ai@7.0.93(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3) + just-bash: 3.4.2(supports-color@10.2.2) react: 19.2.8 - vite: 8.2.2(@types/node@26.4.1)(esbuild@0.28.1) + vite: 8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0) transitivePeerDependencies: - '@babel/core' - '@babel/plugin-transform-runtime' @@ -4441,6 +4535,11 @@ snapshots: ee-first@1.1.1: {} + effect@4.0.0-rc.112: + dependencies: + fast-check: 4.9.0 + msgpackr: 2.1.0 + electron-to-chromium@1.5.422: {} empathic@2.0.1: {} @@ -4565,6 +4664,10 @@ snapshots: extend@3.0.2: {} + fast-check@4.9.0: + dependencies: + pure-rand: 8.4.2 + fast-deep-equal@3.1.3: {} fast-uri@3.1.7: {} @@ -5215,6 +5318,22 @@ snapshots: ms@2.1.3: {} + msgpackr-extract@3.0.4: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 + optional: true + + msgpackr@2.1.0: + optionalDependencies: + msgpackr-extract: 3.0.4 + nanoid@3.3.18: {} nanoid@5.1.16: {} @@ -5234,6 +5353,11 @@ snapshots: node-addon-api@8.9.2: optional: true + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + node-gyp-build@4.8.4: optional: true @@ -5251,21 +5375,21 @@ snapshots: obug@2.1.4: {} - octane-kumo@https://codeload.github.com/NathanBeddoeWebDev/octane-kumo/tar.gz/b86a46a4ed720eda9571d8940caa6f5ae6644fe3#path:/packages/octane-kumo(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)))(react@19.2.8): + octane-kumo@https://codeload.github.com/NathanBeddoeWebDev/octane-kumo/tar.gz/b86a46a4ed720eda9571d8940caa6f5ae6644fe3#path:/packages/octane-kumo(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0)))(react@19.2.8): dependencies: - '@octanejs/aria': 0.0.45(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)))(react@19.2.8) - '@octanejs/base-ui': 0.1.49(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1))) - '@octanejs/day-picker': 0.0.18(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1))) - '@octanejs/phosphor-icons': 0.0.31(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1))) + '@octanejs/aria': 0.0.45(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0)))(react@19.2.8) + '@octanejs/base-ui': 0.1.49(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0))) + '@octanejs/day-picker': 0.0.18(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0))) + '@octanejs/phosphor-icons': 0.0.31(octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0))) '@shikijs/langs': 4.0.0 '@shikijs/themes': 4.0.0 cnfast: 0.0.8 - octane: 0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)) + octane: 0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0)) shiki: 4.0.0 transitivePeerDependencies: - react - octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)): + octane@0.2.2(react@19.2.8)(typescript@5.9.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0)): dependencies: '@tsrx/core': 0.1.65 '@types/react': 19.2.18 @@ -5279,7 +5403,7 @@ snapshots: optionalDependencies: react: 19.2.8 typescript: 5.9.3 - vite: 8.2.2(@types/node@26.4.1)(esbuild@0.28.1) + vite: 8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0) transitivePeerDependencies: - '@typescript-eslint/types' - svelte @@ -5457,6 +5581,8 @@ snapshots: once: 1.4.0 optional: true + pure-rand@8.4.2: {} + qs@6.16.0: dependencies: es-define-property: 1.0.1 @@ -5893,7 +6019,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1): + vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -5904,6 +6030,7 @@ snapshots: '@types/node': 26.4.1 esbuild: 0.28.1 fsevents: 2.3.3 + yaml: 2.9.0 vscode-uri@3.2.0: {} @@ -5985,6 +6112,4 @@ snapshots: zod@4.4.3: {} - zod@4.5.4: {} - zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1ff7e7c..b611d37 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,8 @@ allowBuilds: "@mongodb-js/zstd": false core-js-pure: false esbuild: true + # Effect's optional Node MessagePack accelerator is unused by Workers. + msgpackr-extract: false node-liblzma: false workerd: true minimumReleaseAgeExclude: diff --git a/tests/bridge-server.test.mjs b/tests/bridge-server.test.mjs new file mode 100644 index 0000000..cebd660 --- /dev/null +++ b/tests/bridge-server.test.mjs @@ -0,0 +1,378 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { generateKeyPairSync, randomBytes, createHash } from "node:crypto"; +import { createServer, request as httpRequest } from "node:http"; +import { mkdtemp, readFile, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { unstable_dev } from "wrangler"; +import { parse } from "jsonc-parser"; +import { oauthBindings, oauthConfig } from "./fixtures/oauth-config.mjs"; +import { + LOGIN_PURPOSE, + HEALTH_PURPOSE, + PROVIDER_PURPOSE, + verifyAssertion, +} from "../shared/bridge.ts"; + +const opaque = () => randomBytes(32).toString("base64url"); +const cookie = (response, name) => + response.headers + .getSetCookie() + .find((value) => value.startsWith(name + "=")) + ?.split(";")[0]; + +test( + "native publisher bridge composes OAuth, one-use exchange, provider receipts and encrypted operations", + { timeout: 30000 }, + async () => { + const origin = "https://publisher.test"; + const owner = "verified-userinfo-subject", + installationId = "1".repeat(32), + operationId = "2".repeat(32); + const runtime = `https://flarebot-${installationId}.bridgefixture.workers.dev`; + const pair = generateKeyPairSync("ed25519"); + const bridge = { + keyId: "server-fixture", + publicKey: pair.publicKey.export({ format: "jwk" }).x, + }; + const temporary = await mkdtemp(join(tmpdir(), "flarebot-bridge-server-")); + const now = Date.now(); + const identity = { + version: "0.1.0-fixture.1", + artifactDigest: "d".repeat(64), + sourceRevision: "e".repeat(40), + }; + const record = { + schemaVersion: 1, + installationId, + ownerSubject: owner, + accountId: "a".repeat(32), + createdAt: now, + updatedAt: now, + revision: 3, + resources: { + workerName: `flarebot-${installationId}`, + sandboxApplicationName: `flarebot-shell-${installationId}`, + runtimeOrigin: runtime, + personalAgentNamespaceId: "3".repeat(32), + sandboxNamespaceId: "4".repeat(32), + sandboxApplicationId: "5".repeat(32), + }, + desiredRelease: identity, + installedRelease: { ...identity, installedAt: now }, + status: "ready", + errorCode: null, + operationId, + }; + let receiptCount = 0, + badHealth = false; + const customer = createServer(async (request, response) => { + try { + const assertion = request.headers.authorization?.slice(7); + const payload = JSON.parse( + Buffer.from(assertion.split(".")[1], "base64url"), + ); + const purpose = + request.url === "/auth/provider-enabled" + ? PROVIDER_PURPOSE + : HEALTH_PURPOSE; + await verifyAssertion(assertion, bridge, { + iss: origin, + aud: runtime, + sub: owner, + installationId, + purpose, + state: payload.state, + challenge: payload.challenge, + ...(purpose === HEALTH_PURPOSE + ? { + operationId, + artifactDigest: identity.artifactDigest, + version: identity.version, + } + : {}), + }); + assert.equal(request.method, "POST"); + response.setHeader("Content-Type", "application/json"); + if (purpose === PROVIDER_PURPOSE) { + receiptCount++; + response.end( + JSON.stringify({ provider: "openrouter", enabled: true }), + ); + } else + response.end( + JSON.stringify({ + installationId, + operationId, + artifactDigest: identity.artifactDigest, + version: identity.version, + state: badHealth ? "wrong" : payload.state, + challenge: payload.challenge, + identity: "ready", + nativeParent: "ready", + sandbox: "booted-and-destroyed", + bindings: "present", + assets: "ready", + authentication: "required", + }), + ); + } catch { + response.writeHead(403); + response.end(); + } + }); + await new Promise((resolve) => customer.listen(0, "127.0.0.1", resolve)); + let worker; + try { + const { + routes: _routes, + workers_dev: _workersDev, + ...base + } = parse(await readFile("wrangler.control-plane.jsonc", "utf8")); + const config = join(temporary, "wrangler.json"); + await writeFile( + config, + JSON.stringify({ + ...base, + name: "flarebot-bridge-server-test", + main: resolve("tests/fixtures/bridge-control-worker.ts"), + assets: { + directory: resolve("dist/control-plane/client"), + binding: "ASSETS", + }, + }), + ); + worker = await unstable_dev("tests/fixtures/bridge-control-worker.ts", { + config, + local: true, + ip: "127.0.0.1", + port: 0, + inspectorPort: 0, + logLevel: "error", + vars: { + ...oauthBindings(origin), + FLAREBOT_CONTROL_PLANE: JSON.stringify({ + ...oauthConfig(origin), + bridge, + }), + FLAREBOT_BRIDGE_SIGNING_KEY: pair.privateKey + .export({ type: "pkcs8", format: "der" }) + .toString("base64url"), + TEST_CUSTOMER_PORT: String(customer.address().port), + }, + experimental: { disableExperimentalWarning: true, watch: false }, + }); + const call = (path, init = {}) => + new Promise((resolve, reject) => { + const outgoing = httpRequest( + { + hostname: worker.address, + port: worker.port, + path, + method: init.method ?? "GET", + headers: { "Sec-Fetch-Mode": "navigate", ...init.headers }, + }, + (incoming) => { + const chunks = []; + incoming.on("data", (chunk) => chunks.push(chunk)); + incoming.on("end", () => { + const headers = new Headers(); + for (let i = 0; i < incoming.rawHeaders.length; i += 2) + headers.append( + incoming.rawHeaders[i], + incoming.rawHeaders[i + 1], + ); + resolve( + new Response(Buffer.concat(chunks), { + status: incoming.statusCode, + headers, + }), + ); + }); + }, + ); + outgoing.on("error", reject); + outgoing.end(init.body === undefined ? undefined : String(init.body)); + }); + const post = (path, value) => + call(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(value), + }); + assert.equal((await post("/__bridge__/seed", record)).status, 200); + const verifier = opaque(), + state = opaque(); + const challenge = createHash("sha256") + .update(verifier) + .digest("base64url"); + const bridgePath = `/auth/bridge?${new URLSearchParams({ installationId, state, challenge, audience: runtime })}`; + const start = await call(bridgePath); + assert.equal(start.status, 303); + const authorization = new URL(start.headers.get("Location")); + assert.equal(authorization.origin, "https://dash.cloudflare.com"); + const oauthCode = opaque(); + await post("/__test__/code", { + code: oauthCode, + challenge: authorization.searchParams.get("code_challenge"), + mode: "normal", + }); + const callbackPath = `/auth/callback?${new URLSearchParams({ state: authorization.searchParams.get("state"), code: oauthCode })}`; + const callback = await call(callbackPath, { + headers: { Cookie: cookie(start, "__Host-flarebot-oauth") }, + }); + const session = cookie(callback, "__Host-flarebot-control-session"); + assert.ok( + session, + new URL(callback.headers.get("Location"), origin).searchParams.get( + "error", + ), + ); + const destination = new URL(callback.headers.get("Location")); + assert.equal(destination.origin, runtime); + const exchange = () => + call("/auth/bridge/exchange", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + installationId, + audience: runtime, + state, + verifier, + code: destination.searchParams.get("code"), + }), + }); + const replies = await Promise.all([exchange(), exchange()]); + assert.deepEqual(replies.map((r) => r.status).sort(), [200, 403]); + const assertion = (await replies.find((r) => r.status === 200).json()) + .assertion; + await verifyAssertion(assertion, bridge, { + iss: origin, + aud: runtime, + sub: owner, + installationId, + purpose: LOGIN_PURPOSE, + state, + challenge, + }); + assert.equal( + ( + await call(callbackPath, { + headers: { Cookie: cookie(start, "__Host-flarebot-oauth") }, + }) + ).headers.get("Location"), + "/connect?error=oauth_invalid_callback", + ); + + const enable = () => + call(`/api/installations/${installationId}/providers/openrouter`, { + method: "POST", + headers: { + Origin: origin, + Cookie: session, + "Content-Type": "application/x-www-form-urlencoded", + }, + body: "", + }); + assert.deepEqual(await (await enable()).json(), { + returnTo: `${runtime}/settings`, + }); + assert.equal(receiptCount, 1); + await post("/__bridge__/provider", { failNotification: true }); + assert.equal((await enable()).status, 503); + assert.equal((await post("/__bridge__/health", record)).status, 200); + badHealth = true; + assert.equal((await post("/__bridge__/health", record)).status, 403); + + const principal = ( + await ( + await call("/__test__/inspect", { headers: { Cookie: session } }) + ).json() + ).principal; + await post("/__test__/expire", { + kind: "grant", + ref: principal.grantRef, + }); + assert.equal( + new URL( + ( + await call(bridgePath, { headers: { Cookie: session } }) + ).headers.get("Location"), + ).origin, + runtime, + ); + assert.equal( + ( + await call( + bridgePath.replace( + encodeURIComponent(runtime), + encodeURIComponent("https://foreign.example.com"), + ), + { headers: { Cookie: session } }, + ) + ).status, + 403, + ); + + const value = { + subject: owner, + accountId: record.accountId, + installationId, + operationId, + grantRef: opaque(), + expiresAt: Date.now() + 300000, + bootstrapSecret: opaque(), + }; + const expected = { + subject: owner, + accountId: record.accountId, + installationId, + operationId, + }; + assert.equal( + ( + await post("/__bridge__/operation", { + action: "grant", + value, + grantExpiresAt: value.expiresAt + 1000, + }) + ).status, + 200, + ); + assert.deepEqual( + await ( + await post("/__bridge__/operation", { action: "create", value }) + ).json(), + value, + ); + assert.equal( + ( + await post("/__bridge__/operation", { + action: "create", + value: { ...value, subject: "wrong-owner" }, + }) + ).status, + 409, + ); + assert.equal( + await ( + await post("/__bridge__/operation", { action: "retire", expected }) + ).json(), + true, + ); + assert.equal( + ( + await ( + await post("/__bridge__/operation", { action: "read", expected }) + ).json() + ).bootstrapSecret, + null, + ); + } finally { + await worker?.stop(); + await new Promise((resolve) => customer.close(resolve)); + await rm(temporary, { recursive: true, force: true }); + } + }, +); diff --git a/tests/control-plane-effect.test.mjs b/tests/control-plane-effect.test.mjs new file mode 100644 index 0000000..0833e1a --- /dev/null +++ b/tests/control-plane-effect.test.mjs @@ -0,0 +1,200 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { build } from "esbuild"; + +const bundle = await build({ + stdin: { + contents: ` + export * as Effect from 'effect/Effect'; + export * as Exit from 'effect/Exit'; + export * as Cause from 'effect/Cause'; + export { transaction } from './control-plane/storage.ts'; + export { accounts } from './control-plane/cloudflare.ts'; + export { bodyJson, withResponse } from './control-plane/transport.ts'; + export { InstallationConflict } from './control-plane/installation-errors.ts'; + `, + resolveDir: process.cwd(), + }, + bundle: true, + platform: "node", + format: "esm", + write: false, +}); +const { + Effect, + Exit, + Cause, + transaction, + accounts, + withResponse, + bodyJson, + InstallationConflict, +} = await import( + `data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].text).toString("base64")}` +); + +test("native transactions commit successful Effects and roll back typed failures and defects", async () => { + let committed = new Map(); + const storage = { + async transaction(action) { + const staged = new Map(committed); + const result = await action({ + put: async (key, value) => { + staged.set(key, value); + }, + }); + committed = staged; + return result; + }, + }; + const write = (tail) => + transaction(storage, (tx) => + Effect.promise(() => tx.put("revision", 2)).pipe(Effect.andThen(tail)), + ); + const conflict = new InstallationConflict(); + const rejected = await Effect.runPromiseExit(write(Effect.fail(conflict))); + assert.ok(Exit.isFailure(rejected)); + assert.equal(Exit.findErrorOption(rejected).value, conflict); + assert.ok(!Cause.hasDies(rejected.cause)); + assert.equal(committed.size, 0); + const defect = await Effect.runPromiseExit( + write(Effect.die(new Error("private-storage-sentinel"))), + ); + assert.ok(Cause.hasDies(defect.cause)); + assert.equal(committed.size, 0); + assert.equal( + await Effect.runPromise(write(Effect.succeed("committed"))), + "committed", + ); + assert.equal(committed.get("revision"), 2); +}); + +test( + "interrupting a storage program rejects the native callback before it can commit", + { timeout: 2000 }, + async () => { + const started = Promise.withResolvers(); + const rolledBack = Promise.withResolvers(); + let commits = 0; + const storage = { + async transaction(action) { + try { + const value = await action({}); + commits++; + return value; + } catch (error) { + rolledBack.resolve(); + throw error; + } + }, + }; + const controller = new AbortController(); + const running = Effect.runPromiseExit( + transaction(storage, () => + Effect.sync(() => started.resolve()).pipe(Effect.andThen(Effect.never)), + ), + { signal: controller.signal }, + ); + await started.promise; + controller.abort(); + const exit = await running; + await rolledBack.promise; + assert.ok(Cause.hasInterrupts(exit.cause)); + assert.equal(commits, 0); + }, +); + +test("OAuth account programs are lazy, isolate request credentials and use SDK pagination", async () => { + const calls = []; + const network = async (input, init) => { + const request = new Request(input, init); + const token = request.headers.get("Authorization"); + const page = Number(new URL(request.url).searchParams.get("page")); + calls.push([token, page]); + assert.equal(new URL(request.url).searchParams.get("per_page"), "50"); + assert.equal(init.redirect, "manual"); + return Response.json({ + success: true, + result: [{ id: String(page).repeat(32), name: `${token}:${page}` }], + result_info: { total_pages: 2 }, + }); + }; + const first = accounts("first-fixture-token", network); + const second = accounts("second-fixture-token", network); + assert.equal(calls.length, 0); + const [a, b] = await Promise.all([ + Effect.runPromise(first), + Effect.runPromise(second), + ]); + assert.deepEqual( + a.map((x) => x.name), + ["Bearer first-fixture-token:1", "Bearer first-fixture-token:2"], + ); + assert.deepEqual( + b.map((x) => x.name), + ["Bearer second-fixture-token:1", "Bearer second-fixture-token:2"], + ); + assert.equal(calls.length, 4); +}); + +test("SDK failures do not retry and cannot escape as provider content", async () => { + for (const status of [429, 503]) { + let calls = 0; + const exit = await Effect.runPromiseExit( + accounts("fixture-token", async () => { + calls++; + return Response.json( + { + success: false, + errors: [{ message: "private-provider-sentinel" }], + }, + { status }, + ); + }), + ); + assert.equal(calls, 1); + assert.equal( + Exit.findErrorOption(exit).value.code, + "temporarily_unavailable", + ); + assert.ok(!JSON.stringify(exit).includes("private-provider-sentinel")); + } + const malformed = await Effect.runPromiseExit( + accounts("fixture-token", async () => Response.json({ result: [] })), + ); + assert.equal( + Exit.findErrorOption(malformed).value.code, + "temporarily_unavailable", + ); +}); + +test( + "the transport deadline covers a stalled response body and aborts fetch", + { timeout: 2000 }, + async () => { + let signal; + let cancelled = false; + const unavailable = new Error("bounded-failure"); + const program = withResponse( + async (_input, init) => { + signal = init.signal; + return new Response( + new ReadableStream({ + cancel() { + cancelled = true; + }, + }), + ); + }, + "https://fixture.invalid", + {}, + 20, + unavailable, + (response) => bodyJson(response, 16, unavailable), + ); + const exit = await Effect.runPromiseExit(program); + assert.equal(Exit.findErrorOption(exit).value, unavailable); + assert.equal(signal.aborted, true); + assert.equal(cancelled, true); + }, +); diff --git a/tests/control-plane-effect.types.ts b/tests/control-plane-effect.types.ts new file mode 100644 index 0000000..7e087e0 --- /dev/null +++ b/tests/control-plane-effect.types.ts @@ -0,0 +1,110 @@ +import type * as Effect from "effect/Effect"; +import { + listAccounts, + type ListAccountsResponse, + type ListAccountsError, +} from "@distilled.cloud/cloudflare/accounts"; +import { cloudflare } from "../control-plane/cloudflare-sdk.ts"; +import { accounts, type Account } from "../control-plane/cloudflare.ts"; +import { + configuration, + type Env, + type OAuthConfiguration, +} from "../control-plane/config.ts"; +import { + authenticatedPrincipal, + authorizedGrant, + vault, +} from "../control-plane/session.ts"; +import { startInstallation } from "../control-plane/start-installation.ts"; +import { setupDomain } from "../control-plane/domain-setup.ts"; +import { setupOpenRouter } from "../control-plane/provider-setup.ts"; +import { handleOAuth } from "../control-plane/http.ts"; +import { handleInstallations } from "../control-plane/installations.ts"; +import type { OAuthError } from "../control-plane/errors.ts"; +import type { RegistryFailure } from "../control-plane/installation-errors.ts"; +import type { DeploymentFailure } from "../control-plane/deployment-errors.ts"; +import type { Installation } from "../control-plane/installation-metadata.ts"; +import type { Grant, Principal } from "../control-plane/vault.ts"; +import type { InstallationCommand } from "../control-plane/start-installation.ts"; +import type { WorkerUploadBindings } from "../control-plane/worker-api.ts"; + +const recoveryWithTarget: InstallationCommand = { + action: "recover", + // @ts-expect-error Recovery cannot select a new release. + target: {}, +}; +const mixedUpload: WorkerUploadBindings = { + kind: "bootstrap", + secret: "fixture", + // @ts-expect-error Fresh uploads cannot also inherit an existing version. + versionId: "existing", +}; + +// Compile with the real SDK and native binding types. Application programs must +// retain precise results/failures and have no unsatisfied service requirements. +function contracts( + env: Env, + request: Request, + principal: Principal, + network: typeof fetch, +) { + const sdk: Effect.Effect = + cloudflare( + listAccounts({ page: 1, perPage: 50 }), + "fixture-token", + network, + ); + const config: Effect.Effect = + configuration(env); + const identity: Effect.Effect = authenticatedPrincipal( + request, + env, + ); + const grant: Effect.Effect = authorizedGrant( + env, + principal, + ); + const stored: Effect.Effect = vault( + env, + "grant", + principal.grantRef, + ).grant(principal.subject); + const available: Effect.Effect = accounts( + "fixture-token", + network, + ); + const start: Effect.Effect< + Installation, + RegistryFailure | DeploymentFailure | OAuthError + > = startInstallation(request, env, "id", "request-id", network); + const oauth: Effect.Effect = handleOAuth( + request, + env, + network, + ); + const installations: Effect.Effect = handleInstallations( + request, + env, + network, + ); + return { + sdk, + config, + identity, + grant, + stored, + available, + start, + oauth, + installations, + }; +} + +type AssertNever = T; +type DomainRequirements = AssertNever< + Effect.Services> +>; +type ProviderRequirements = AssertNever< + Effect.Services> +>; diff --git a/tests/deployment-effect.test.mjs b/tests/deployment-effect.test.mjs new file mode 100644 index 0000000..0ca0046 --- /dev/null +++ b/tests/deployment-effect.test.mjs @@ -0,0 +1,418 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { build } from "esbuild"; + +const bundle = await build({ + stdin: { + contents: ` + export * from './control-plane/workflow-boundary.ts'; + export * from './control-plane/deployment-errors.ts'; + export * from './control-plane/deployment-config.ts'; + export * from './control-plane/worker-deployment.ts'; + export { Deployment } from './control-plane/deployment.ts'; + export * as Effect from 'effect/Effect'; + export * as Exit from 'effect/Exit'; + export * as Cause from 'effect/Cause'; + `, + resolveDir: process.cwd(), + }, + bundle: true, + format: "esm", + platform: "node", + write: false, +}); +const { + Effect, + Exit, + Cause, + Deployment, + deploymentFailure, + isDeploymentFailure, + ResourceConflict, + SetupRequired, + RecoveryRequired, + ReauthorizationRequired, + AccountDenied, + TemporarilyUnavailable, + deploymentCodes, + runDeploymentEffect, + runtimeConfiguration, + uploadWorker, + verifyWorkerNamespaces, +} = await import( + `data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].text).toString("base64")}` +); + +const artifact = { identity: { artifactDigest: "target" } }; +const input = { + artifact, + config: {}, + bootstrapSecret: "private-bootstrap-sentinel", + operation: { upgrade: null, workerIntent: null }, + recordIntent: Effect.void, +}; +const controlPlane = { + schemaVersion: 1, + publicOrigin: "https://control.example.com", + oauthClientId: "fixture-client", + oauthRedirectUri: "https://control.example.com/auth/callback", + oauthScopes: ["openid"], + bridge: { keyId: "fixture-key", publicKey: "a".repeat(43) }, +}; +const configEnv = { + FLAREBOT_MODE: "control-plane", + FLAREBOT_CONTROL_PLANE: JSON.stringify(controlPlane), + FLAREBOT_SESSION_SECRET: "private-config-sentinel", +}; +const configRecord = { + installationId: "1".repeat(32), + ownerSubject: "fixture-owner", + resources: { runtimeOrigin: "https://runtime.example.com" }, +}; +const configArtifact = { + identity: { version: "1.0.0", artifactDigest: "a".repeat(64) }, +}; +const deployedOperationId = "2".repeat(32); + +test("runtime configuration is lazy and preserves the pinned deployment identity", async () => { + let reads = 0; + const program = runtimeConfiguration( + { + ...configEnv, + get FLAREBOT_CONTROL_PLANE() { + reads++; + return configEnv.FLAREBOT_CONTROL_PLANE; + }, + }, + configRecord, + configArtifact, + deployedOperationId, + ); + assert.equal(reads, 0); + const config = await runDeploymentEffect(program); + assert.ok(reads > 0); + assert.deepEqual(config, { + schemaVersion: 1, + installationId: configRecord.installationId, + ownerSubject: configRecord.ownerSubject, + runtimeOrigin: configRecord.resources.runtimeOrigin, + controlPlaneOrigin: controlPlane.publicOrigin, + bridge: controlPlane.bridge, + release: { ...configArtifact.identity, operationId: deployedOperationId }, + }); + assert.ok(Object.isFrozen(config)); + assert.ok(!JSON.stringify(config).includes("private-config-sentinel")); +}); + +test("invalid deployment configuration fails as SetupRequired before the next effect", async () => { + for (const [env, record, release, operationId] of [ + [ + { ...configEnv, FLAREBOT_CONTROL_PLANE: "{private-config-sentinel" }, + configRecord, + configArtifact, + deployedOperationId, + ], + [ + { + ...configEnv, + FLAREBOT_CONTROL_PLANE: JSON.stringify({ + ...controlPlane, + bridge: undefined, + }), + }, + configRecord, + configArtifact, + deployedOperationId, + ], + [ + configEnv, + { + ...configRecord, + resources: { runtimeOrigin: "http://unsafe.example.com" }, + }, + configArtifact, + deployedOperationId, + ], + [ + configEnv, + configRecord, + { identity: { ...configArtifact.identity, artifactDigest: "invalid" } }, + deployedOperationId, + ], + [configEnv, configRecord, configArtifact, "invalid"], + ]) { + let continued = false; + const program = runtimeConfiguration( + env, + record, + release, + operationId, + ).pipe( + Effect.flatMap(() => + Effect.sync(() => { + continued = true; + }), + ), + ); + const exit = await Effect.runPromiseExit(program); + assert.ok(Exit.isFailure(exit)); + assert.ok(!Cause.hasDies(exit.cause)); + const error = Exit.findErrorOption(exit).value; + assert.ok(error instanceof SetupRequired); + assert.equal(error.message, "setup_required"); + assert.ok(!JSON.stringify(error).includes("private-config-sentinel")); + assert.equal(continued, false); + await assert.rejects(runDeploymentEffect(program), { + code: "setup_required", + }); + } +}); + +test("Effect preserves every declared deployment code at the Promise boundary", async () => { + for (const code of deploymentCodes) { + const original = deploymentFailure(code); + original.privateProviderBody = "private-provider-sentinel"; + await assert.rejects( + runDeploymentEffect(Effect.fail(original)), + (error) => { + assert.ok(isDeploymentFailure(error)); + assert.equal(error.code, code); + assert.equal(error._tag, original._tag); + assert.equal(error.constructor, original.constructor); + assert.equal(error.message, code); + assert.equal(error.privateProviderBody, undefined); + assert.ok(!JSON.stringify(error).includes("private-provider-sentinel")); + return true; + }, + ); + } +}); + +test("unexpected adapter failures remain defects internally and are safe at the Workflow boundary", async () => { + for (const call of [ + () => { + throw new Error("private-provider-sentinel"); + }, + () => Promise.reject(new Error("private-provider-sentinel")), + ]) { + const program = Effect.promise(async () => call()); + const exit = await Effect.runPromiseExit(program); + assert.ok(Exit.isFailure(exit)); + assert.ok(Cause.hasDies(exit.cause)); + assert.ok(!Cause.hasFails(exit.cause)); + await assert.rejects(runDeploymentEffect(program), { + message: "temporarily_unavailable", + code: "temporarily_unavailable", + }); + } +}); + +test("the API creates distinct errors at the source and catchTag handles only the selected case", async () => { + for (const [status, ErrorType] of [ + [401, ReauthorizationRequired], + [403, AccountDenied], + [503, TemporarilyUnavailable], + [200, ResourceConflict], + ]) { + const api = new Deployment( + "private-token-sentinel", + { accountId: "account", resources: { workerName: "worker" } }, + async () => Response.json({ success: true, result: {} }, { status }), + ); + const exit = await Effect.runPromiseExit(api.worker.settings()); + assert.ok(Exit.isFailure(exit)); + const error = Exit.findErrorOption(exit).value; + assert.ok(error instanceof ErrorType); + assert.ok(!JSON.stringify(error).includes("private-token-sentinel")); + + const handled = await Effect.runPromiseExit( + api.worker + .settings() + .pipe( + Effect.catchTag("ResourceConflict", () => Effect.succeed("conflict")), + ), + ); + if (status === 200) { + assert.ok(Exit.isSuccess(handled)); + assert.equal(handled.value, "conflict"); + } else { + assert.ok(Exit.isFailure(handled)); + assert.ok(Exit.findErrorOption(handled).value instanceof ErrorType); + } + } +}); + +test( + "interrupting body consumption aborts the native request after headers", + { timeout: 5_000 }, + async () => { + let consuming; + const started = new Promise((resolve) => { + consuming = resolve; + }); + let requestSignal; + const api = new Deployment( + "fixture-token", + { accountId: "account", resources: { workerName: "worker" } }, + async (_url, init) => { + requestSignal = init.signal; + return new Response( + new ReadableStream( + { + start(controller) { + init.signal.addEventListener( + "abort", + () => { + controller.error(new Error("fixture request aborted")); + }, + { once: true }, + ); + }, + pull() { + consuming(); + }, + }, + { highWaterMark: 0 }, + ), + ); + }, + ); + const controller = new AbortController(); + const running = Effect.runPromiseExit(api.worker.settings(), { + signal: controller.signal, + }); + await started; + controller.abort(); + const exit = await running; + assert.ok(Exit.isFailure(exit)); + assert.ok(Cause.hasInterrupts(exit.cause)); + assert.equal(requestSignal.aborted, true); + }, +); + +test("upload intent is lazy and remains between staging and PUT; a failed intent prevents PUT", async () => { + for (const denied of [false, true]) { + const events = []; + const worker = { + settings: () => Effect.succeed(null), + upload: (_artifact, _config, secret, beforeUpload) => + Effect.gen(function* () { + assert.deepEqual(secret, { + kind: "bootstrap", + secret: input.bootstrapSecret, + }); + events.push("stage assets"); + yield* beforeUpload; + events.push("PUT"); + }), + }; + const program = uploadWorker( + { worker }, + { + ...input, + recordIntent: Effect.gen(function* () { + events.push("intent"); + if (denied) return yield* new ResourceConflict(); + }), + }, + ); + assert.deepEqual(events, []); + const result = runDeploymentEffect(program); + if (denied) await assert.rejects(result, { code: "resource_conflict" }); + else await result; + assert.deepEqual( + events, + denied ? ["stage assets", "intent"] : ["stage assets", "intent", "PUT"], + ); + } +}); + +test("ambiguous upload outcomes never authorize a second PUT", async () => { + let uploads = 0; + const api = { + settings: () => Effect.succeed(null), + upload: () => + Effect.sync(() => { + uploads++; + }), + }; + await assert.rejects( + runDeploymentEffect( + uploadWorker( + { worker: api }, + { + ...input, + operation: { upgrade: null, workerIntent: { operationId: "prior" } }, + }, + ), + ), + { code: "recovery_required" }, + ); + assert.equal(uploads, 0); +}); + +test("provider retries belong to Cloudflare, not the Effect program", async () => { + let attempts = 0; + await assert.rejects( + runDeploymentEffect( + uploadWorker( + { + worker: { + settings: () => + Effect.gen(function* () { + attempts++; + return yield* new TemporarilyUnavailable(); + }), + }, + }, + input, + ), + ), + { code: "temporarily_unavailable" }, + ); + assert.equal(attempts, 1); +}); + +test("namespace verification stops on content failure and uses each attempt's client", async () => { + const operation = { upgrade: null }; + await assert.rejects( + runDeploymentEffect( + verifyWorkerNamespaces( + { + worker: { + verifyContent: () => Effect.fail(new ResourceConflict()), + namespaces: async () => + assert.fail("must verify content before reading namespaces"), + }, + }, + artifact, + {}, + operation, + ), + ), + { code: "resource_conflict" }, + ); + + const results = await Promise.all( + ["first", "second"].map((id) => + runDeploymentEffect( + verifyWorkerNamespaces( + { + worker: { + verifyContent: () => Effect.void, + namespaces: () => + Effect.succeed({ personalAgentNamespaceId: id }), + }, + }, + artifact, + {}, + operation, + ), + ), + ), + ); + assert.deepEqual(results, [ + { personalAgentNamespaceId: "first" }, + { personalAgentNamespaceId: "second" }, + ]); +}); diff --git a/tests/deployment-network.test.mjs b/tests/deployment-network.test.mjs index 1e7ee2f..f4a4f2f 100644 --- a/tests/deployment-network.test.mjs +++ b/tests/deployment-network.test.mjs @@ -25,16 +25,17 @@ test( const bundled = await build({ stdin: { contents: ` - import { DeploymentAPI } from './control-plane/deployment-api.ts'; + import { Deployment } from './control-plane/deployment.ts'; + import { runDeploymentEffect } from './control-plane/workflow-boundary.ts'; export default { async fetch(request, env) { // Deliberately omit the injected network argument: this must exercise // the receiver-sensitive native Workers fetch, not a JS replacement. - const api = new DeploymentAPI('fixture-token', JSON.parse(env.RECORD)); + const api = new Deployment('fixture-token', JSON.parse(env.RECORD)); try { if (new URL(request.url).pathname === '/origin') { - return Response.json({ origin: await api.origin() }); + return Response.json({ origin: await runDeploymentEffect(api.worker.origin()) }); } - await api.verifyContent(JSON.parse(env.ARTIFACT)); + await runDeploymentEffect(api.worker.verifyContent(JSON.parse(env.ARTIFACT))); return Response.json({ verified: true }); } catch (error) { return Response.json({ error: error.message }, { status: 500 }); diff --git a/tests/deployment.test.mjs b/tests/deployment.test.mjs index 406f29a..c8eef42 100644 --- a/tests/deployment.test.mjs +++ b/tests/deployment.test.mjs @@ -1,3 +1,4 @@ +import * as Effect from "effect/Effect"; import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; @@ -246,25 +247,33 @@ test("public fetch release retains legacy artifacts and requires an explicit for }; } const legacyEntry = await entry(["nodejs_compat"], "0.1.0-fixture.legacy"); - const legacy = await loadArtifact(legacyEntry, legacyEntry.identity); + const legacy = await Effect.runPromise( + loadArtifact(legacyEntry, legacyEntry.identity), + ); assert.deepEqual(legacy.deployment.compatibility_flags, ["nodejs_compat"]); const currentEntry = await entry( source.compatibility_flags, "0.1.0-fixture.public", [legacy.identity.artifactDigest], ); - const current = await loadArtifact(currentEntry, currentEntry.identity); - assert.doesNotThrow(() => verifyUpgradeIdentity(legacy.identity, current)); - assert.throws( - () => verifyUpgradeIdentity(current.identity, legacy), + const current = await Effect.runPromise( + loadArtifact(currentEntry, currentEntry.identity), + ); + await assert.doesNotReject(() => + Effect.runPromise(verifyUpgradeIdentity(legacy.identity, current)), + ); + await assert.rejects( + () => Effect.runPromise(verifyUpgradeIdentity(current.identity, legacy)), /artifact_unavailable/, ); - assert.throws( + await assert.rejects( () => - verifyUpgradeIdentity(legacy.identity, { - ...current, - compatibility: { ...current.compatibility, fromArtifacts: [] }, - }), + Effect.runPromise( + verifyUpgradeIdentity(legacy.identity, { + ...current, + compatibility: { ...current.compatibility, fromArtifacts: [] }, + }), + ), /artifact_unavailable/, ); for (const flags of [ @@ -275,7 +284,9 @@ test("public fetch release retains legacy artifacts and requires an explicit for [...source.compatibility_flags, "unknown_flag"], ]) { await assert.rejects( - loadArtifact(await entry(flags, "0.1.0-fixture.invalid")), + Effect.runPromise( + loadArtifact(await entry(flags, "0.1.0-fixture.invalid")), + ), /artifact_unavailable/, ); } @@ -299,10 +310,12 @@ test("packaged release is accepted by the production artifact validator", async ), ), ); - const artifact = await loadArtifact( - { identity, files, development: manifest.sourceDirty }, - identity, - manifest.sourceDirty, + const artifact = await Effect.runPromise( + loadArtifact( + { identity, files, development: manifest.sourceDirty }, + identity, + manifest.sourceDirty, + ), ); assert.deepEqual(artifact.deployment.ai, { binding: "AI" }); }); diff --git a/tests/domain-effect.test.mjs b/tests/domain-effect.test.mjs new file mode 100644 index 0000000..bf55624 --- /dev/null +++ b/tests/domain-effect.test.mjs @@ -0,0 +1,311 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { build } from "esbuild"; + +const bundle = await build({ + stdin: { + contents: ` + export * as Effect from 'effect/Effect'; + export * as Exit from 'effect/Exit'; + export * as Cause from 'effect/Cause'; + export { DomainAPI } from './control-plane/domain-api.ts'; + export { domainErrors } from './control-plane/domain-metadata.ts'; + export * from './control-plane/domain-errors.ts'; + export * from './control-plane/workflow-boundary.ts'; + export * from './control-plane/domain-operations.ts'; + export { InstallationConflict } from './control-plane/installation-errors.ts'; + `, + resolveDir: process.cwd(), + }, + bundle: true, + format: "esm", + platform: "node", + write: false, +}); +const { + Effect, + Exit, + Cause, + DomainAPI, + domainErrors, + domainFailure, + DomainResourceConflict, + DomainAccountDenied, + DomainReauthorizationRequired, + DomainTemporarilyUnavailable, + runDomainStep, + attachDomain, + checkDomainHttps, + InstallationConflict, +} = await import( + `data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].text).toString("base64")}` +); + +const domain = { + revision: 1, + status: "connecting", + hostname: "bot.example.com", + origin: "https://bot.example.com", + zoneId: "c".repeat(32), + domainId: null, + writeIntent: false, + errorCode: null, +}; +const domainId = "d".repeat(40); +const record = { + accountId: "a".repeat(32), + resources: { workerName: "worker" }, +}; + +test("domain steps persist safe terminal codes and throw only safe transient codes", async () => { + assert.deepEqual(await runDomainStep(Effect.void), { error: null }); + for (const code of domainErrors.options) { + const failure = domainFailure(code); + failure.privateContent = "private-sentinel"; + const program = Effect.fail(failure); + const exit = await Effect.runPromiseExit(program); + assert.ok(Exit.isFailure(exit)); + assert.equal(Exit.findErrorOption(exit).value, failure); + if (code === "temporarily_unavailable") { + await assert.rejects(runDomainStep(program), (error) => { + assert.equal(error.message, code); + assert.equal(error.privateContent, undefined); + return true; + }); + } else assert.deepEqual(await runDomainStep(program), { error: code }); + } + for (const program of [ + Effect.die(new Error("private-sentinel")), + Effect.fail(new InstallationConflict()), + ]) + await assert.rejects(runDomainStep(program), { + message: "temporarily_unavailable", + }); +}); + +test("domain provider operations are lazy, preserve native fetch receiver and classify HTTP failures", async () => { + for (const [status, code] of [ + [401, "reauthorization_required"], + [403, "account_denied"], + [409, "resource_conflict"], + [500, "temporarily_unavailable"], + [200, "temporarily_unavailable"], + ]) { + let calls = 0; + const api = new DomainAPI("private-token", record, async function ( + _url, + init, + ) { + assert.equal(this, undefined); + assert.equal(init.redirect, "manual"); + calls++; + return Response.json({ invalid: "private-sentinel" }, { status }); + }); + const program = api.zones(); + assert.equal(calls, 0); + await assert.rejects(Effect.runPromise(program), (error) => { + assert.equal(error.code, code); + assert.ok(!JSON.stringify(error).includes("private-sentinel")); + return true; + }); + assert.equal(calls, 1); + } +}); + +test( + "interrupting a domain body read aborts the provider request", + { timeout: 5000 }, + async () => { + let consuming; + const started = new Promise((resolve) => { + consuming = resolve; + }); + let requestSignal; + const api = new DomainAPI("token", record, async (_url, init) => { + requestSignal = init.signal; + return new Response( + new ReadableStream( + { + start(controller) { + init.signal.addEventListener( + "abort", + () => controller.error(new Error("aborted")), + { once: true }, + ); + }, + pull() { + consuming(); + }, + }, + { highWaterMark: 0 }, + ), + ); + }); + const controller = new AbortController(); + const running = Effect.runPromiseExit(api.zones(), { + signal: controller.signal, + }); + await started; + controller.abort(); + const exit = await running; + assert.ok(Exit.isFailure(exit)); + assert.ok(Cause.hasInterrupts(exit.cause)); + assert.equal(requestSignal.aborted, true); + }, +); + +function fixture(attach = Effect.void) { + const events = []; + let saved = { ...domain }; + return { + events, + saved: () => saved, + api: { + preflight: () => + Effect.sync(() => { + events.push("preflight"); + }), + attach: (value) => + Effect.gen(function* () { + assert.equal(value.writeIntent, true); + events.push("PUT"); + yield* attach; + }), + find: () => + Effect.sync(() => { + events.push("find"); + return domainId; + }), + }, + save: (value, changes) => + Effect.sync(() => { + events.push({ ...changes }); + saved = { ...value, ...changes, revision: value.revision + 1 }; + return saved; + }), + }; +} + +test("attachment commits intent before PUT and checkpoints only the observed domain", async () => { + const f = fixture(); + const program = attachDomain(f.api, domain, f.save); + assert.deepEqual(f.events, []); + await Effect.runPromise(program); + assert.deepEqual(f.events, [ + "preflight", + { writeIntent: true }, + "PUT", + "find", + { domainId }, + ]); + assert.equal(f.saved().domainId, domainId); +}); + +test("only definitive attachment rejections clear intent", async () => { + for (const ErrorType of [ + DomainResourceConflict, + DomainAccountDenied, + DomainReauthorizationRequired, + ]) { + const failure = new ErrorType(); + const f = fixture(Effect.fail(failure)); + await assert.rejects( + Effect.runPromise(attachDomain(f.api, domain, f.save)), + { code: failure.code }, + ); + assert.deepEqual(f.events, [ + "preflight", + { writeIntent: true }, + "PUT", + { writeIntent: false, status: "failed", errorCode: failure.code }, + ]); + } + for (const failure of [ + Effect.fail(new DomainTemporarilyUnavailable()), + Effect.die(new Error("lost-reply")), + Effect.interrupt, + ]) { + const f = fixture(failure); + const exit = await Effect.runPromiseExit( + attachDomain(f.api, domain, f.save), + ); + assert.ok(Exit.isFailure(exit)); + assert.equal(f.saved().writeIntent, true); + assert.deepEqual(f.events, ["preflight", { writeIntent: true }, "PUT"]); + } +}); + +test("failed intent never PUTs and uncertain attachments are observed without a second PUT", async () => { + const failed = fixture(); + await assert.rejects( + Effect.runPromise( + attachDomain(failed.api, domain, () => + Effect.fail(new InstallationConflict()), + ), + ), + { code: "installation_conflict" }, + ); + assert.deepEqual(failed.events, ["preflight"]); + for (const found of [null, domainId]) { + const f = fixture(); + f.api.find = () => Effect.succeed(found); + const program = attachDomain( + f.api, + { ...domain, writeIntent: true }, + f.save, + ); + if (found) await Effect.runPromise(program); + else + await assert.rejects(Effect.runPromise(program), { + code: "attachment_outcome_unknown", + }); + assert.ok(!f.events.includes("PUT")); + assert.ok(!f.events.includes("preflight")); + } +}); + +test("HTTPS readiness tolerates health failures but never hides mapping failures", async () => { + const saved = { ...domain, domainId }; + for (const health of [ + Effect.fail(new DomainTemporarilyUnavailable()), + Effect.die(new Error("TLS pending")), + ]) { + assert.equal( + await Effect.runPromise( + checkDomainHttps( + { find: () => Effect.succeed(domainId) }, + saved, + health, + ), + ), + false, + ); + } + assert.equal( + await Effect.runPromise( + checkDomainHttps( + { find: () => Effect.succeed(domainId) }, + saved, + Effect.void, + ), + ), + true, + ); + for (const lookup of [ + Effect.succeed("foreign-domain"), + Effect.fail(new DomainReauthorizationRequired()), + ]) { + let checkedHealth = false; + const exit = await Effect.runPromiseExit( + checkDomainHttps( + { find: () => lookup }, + saved, + Effect.sync(() => { + checkedHealth = true; + }), + ), + ); + assert.ok(Exit.isFailure(exit)); + assert.equal(checkedHealth, false); + } +}); diff --git a/tests/fixtures/bridge-control-worker.ts b/tests/fixtures/bridge-control-worker.ts index 48f729d..6ad68c1 100644 --- a/tests/fixtures/bridge-control-worker.ts +++ b/tests/fixtures/bridge-control-worker.ts @@ -1,12 +1,14 @@ +import * as Effect from "effect/Effect"; export { InstallationWorkflow } from "../../control-plane/installation-workflow.ts"; // Explicit local test transport/admin seams. Never a production entry. import oauth from "./ownership-worker.ts"; import { InstallationRegistry } from "./ownership-worker.ts"; import { AuthVault, network as cloudflareNetwork } from "./oauth-worker.ts"; import { - installationRegistry, - handleInstallations, -} from "../../control-plane/installations.ts"; + installationRegistryStub as installationRegistry, + registryClient, +} from "../../control-plane/registry-client.ts"; +import { handleInstallations } from "../../control-plane/installations.ts"; import { vault } from "../../control-plane/session.ts"; import { signBridgeAssertion, @@ -18,7 +20,7 @@ import type { OperationBinding, } from "../../control-plane/vault.ts"; import type { BridgeClaims } from "../../shared/bridge.ts"; -import { unwrap } from "../../control-plane/installation-metadata.ts"; + export { InstallationRegistry, AuthVault }; const providerState: { gateway?: any; @@ -41,37 +43,39 @@ export default { return Response.json(providerState); } if (/\/providers\/openrouter$/.test(input.pathname)) { - return (await handleInstallations(request, env, async (input, init) => { - const outgoing = new Request(input, init); - const url = new URL(outgoing.url); - providerState.trace.push(`${outgoing.method} ${url.pathname}`); - const ok = (result: unknown) => - Response.json({ success: true, result }); - if (url.pathname.includes("/ai-gateway/")) { - if (providerState.denyGateway) - return new Response(null, { status: 403 }); - if (url.pathname.endsWith("/gateways/default")) - return providerState.gateway - ? ok(providerState.gateway) - : new Response(null, { status: 404 }); - if (url.pathname.endsWith("/gateways")) { - providerState.gateway = await outgoing.json(); - return ok(providerState.gateway); + return (await Effect.runPromise( + handleInstallations(request, env, async (input, init) => { + const outgoing = new Request(input, init); + const url = new URL(outgoing.url); + providerState.trace.push(`${outgoing.method} ${url.pathname}`); + const ok = (result: unknown) => + Response.json({ success: true, result }); + if (url.pathname.includes("/ai-gateway/")) { + if (providerState.denyGateway) + return new Response(null, { status: 403 }); + if (url.pathname.endsWith("/gateways/default")) + return providerState.gateway + ? ok(providerState.gateway) + : new Response(null, { status: 404 }); + if (url.pathname.endsWith("/gateways")) { + providerState.gateway = await outgoing.json(); + return ok(providerState.gateway); + } + throw new Error(`unexpected AI Gateway request: ${url.pathname}`); } - throw new Error(`unexpected AI Gateway request: ${url.pathname}`); - } - if (url.pathname === "/auth/provider-enabled") { - if (providerState.failNotification) - return new Response(null, { status: 503 }); - return fetch( - new Request( - `http://127.0.0.1:${(env as Env & { TEST_CUSTOMER_PORT: string }).TEST_CUSTOMER_PORT}${url.pathname}`, - outgoing, - ), - ); - } - return cloudflareNetwork(input, init); - }))!; + if (url.pathname === "/auth/provider-enabled") { + if (providerState.failNotification) + return new Response(null, { status: 503 }); + return fetch( + new Request( + `http://127.0.0.1:${(env as Env & { TEST_CUSTOMER_PORT: string }).TEST_CUSTOMER_PORT}${url.pathname}`, + outgoing, + ), + ); + } + return cloudflareNetwork(input, init); + }), + ))!; } if (input.pathname === "/__bridge__/seed") { const record = (await request.json()) as { @@ -91,9 +95,9 @@ export default { id: string; remove?: boolean; }; - const stub = installationRegistry(env, body.owner); - const domain = unwrap( - await stub.startDomain( + const stub = registryClient(installationRegistry(env, body.owner)); + const domain = await Effect.runPromise( + stub.startDomain( body.owner, body.id, crypto.randomUUID().replaceAll("-", ""), @@ -107,8 +111,8 @@ export default { ), ); return Response.json( - unwrap( - await stub.updateDomain( + await Effect.runPromise( + stub.updateDomain( body.owner, body.id, domain.operationId, @@ -127,16 +131,18 @@ export default { try { const record = (await request.json()) as import("../../control-plane/installation-metadata.ts").Installation; - await healthInstallation(env, record, async (outgoing) => { - const source = new Request(outgoing); - const url = new URL(source.url); - return fetch( - new Request( - `http://127.0.0.1:${(env as Env & { TEST_CUSTOMER_PORT: string }).TEST_CUSTOMER_PORT}${url.pathname}`, - source, - ), - ); - }); + await Effect.runPromise( + healthInstallation(env, record, async (outgoing, init) => { + const source = new Request(outgoing, init); + const url = new URL(source.url); + return fetch( + new Request( + `http://127.0.0.1:${(env as Env & { TEST_CUSTOMER_PORT: string }).TEST_CUSTOMER_PORT}${url.pathname}`, + source, + ), + ); + }), + ); return Response.json({ ok: true }); } catch { return new Response("health denied", { status: 403 }); @@ -148,7 +154,7 @@ export default { "iat" | "exp" | "jti" | "iss" >; return Response.json({ - assertion: await signBridgeAssertion(env, claims), + assertion: await Effect.runPromise(signBridgeAssertion(env, claims)), }); } if (input.pathname === "/__bridge__/operation") { @@ -159,12 +165,14 @@ export default { grantExpiresAt: number; }; if (body.action === "grant") { - await vault(env, "grant", body.value.grantRef).createGrant({ - subject: body.value.subject, - accessToken: "protected-token-sentinel", - scopes: [], - expiresAt: body.grantExpiresAt, - }); + await Effect.runPromise( + vault(env, "grant", body.value.grantRef).createGrant({ + subject: body.value.subject, + accessToken: "protected-token-sentinel", + scopes: [], + expiresAt: body.grantExpiresAt, + }), + ); return Response.json({ ok: true }); } const stub = vault( @@ -174,10 +182,16 @@ export default { ); try { if (body.action === "create") - return Response.json(await stub.createOperation(body.value)); + return Response.json( + await Effect.runPromise(stub.createOperation(body.value)), + ); if (body.action === "retire") - return Response.json(await stub.retireBootstrap(body.expected)); - return Response.json(await stub.operation(body.expected)); + return Response.json( + await Effect.runPromise(stub.retireBootstrap(body.expected)), + ); + return Response.json( + await Effect.runPromise(stub.operation(body.expected)), + ); } catch { return new Response("conflict", { status: 409 }); } diff --git a/tests/fixtures/domain-worker.ts b/tests/fixtures/domain-worker.ts index 552faf5..1c67b07 100644 --- a/tests/fixtures/domain-worker.ts +++ b/tests/fixtures/domain-worker.ts @@ -1,5 +1,6 @@ import { DomainAPI } from "../../control-plane/domain-api.ts"; -import { DomainError } from "../../control-plane/domain-metadata.ts"; +import * as Effect from "effect/Effect"; +import { isDomainFailure } from "../../control-plane/domain-errors.ts"; import { InstallationRegistry as NativeRegistry } from "../../control-plane/installation-registry.ts"; export { AuthVault } from "./oauth-worker.ts"; export { InstallationWorkflow } from "../../control-plane/installation-workflow.ts"; @@ -189,17 +190,17 @@ export default { ); const value = body.operation === "preflight" - ? await api.preflight(domain as any) + ? await Effect.runPromise(api.preflight(domain as any)) : body.operation === "remove" - ? await api.remove(domain as any) + ? await Effect.runPromise(api.remove(domain as any)) : body.operation === "find" - ? await api.find(domain as any) - : await api.attach(domain as any); + ? await Effect.runPromise(api.find(domain as any)) + : await Effect.runPromise(api.attach(domain as any)); return Response.json({ ok: true, value, trace }); } catch (error) { return Response.json({ ok: false, - error: error instanceof DomainError ? error.code : "unexpected", + error: isDomainFailure(error) ? error.code : "unexpected", trace, }); } diff --git a/tests/fixtures/domain-workflow-worker.ts b/tests/fixtures/domain-workflow-worker.ts index 0032831..6acff77 100644 --- a/tests/fixtures/domain-workflow-worker.ts +++ b/tests/fixtures/domain-workflow-worker.ts @@ -1,4 +1,4 @@ -// Native Workflow/DO test harness. Only Cloudflare's external responses are fake. +import * as Effect from "effect/Effect"; import { DurableObject } from "cloudflare:workers"; import { InstallationWorkflow as NativeWorkflow } from "../../control-plane/installation-workflow.ts"; import { InstallationRegistry } from "./domain-worker.ts"; @@ -184,18 +184,22 @@ export default { }); const session = random(), grantRef = random(); - await vault(env, "grant", grantRef).createGrant({ - subject: owner, - accessToken: "test-token", - scopes: ["openid", "account.read", "workers-platform.write"], - expiresAt: Date.now() + 3600_000, - }); - await vault(env, "session", session).createSession({ - subject: owner, - grantRef, - selectedAccountId: null, - expiresAt: Date.now() + 3600_000, - }); + await Effect.runPromise( + vault(env, "grant", grantRef).createGrant({ + subject: owner, + accessToken: "test-token", + scopes: ["openid", "account.read", "workers-platform.write"], + expiresAt: Date.now() + 3600_000, + }), + ); + await Effect.runPromise( + vault(env, "session", session).createSession({ + subject: owner, + grantRef, + selectedAccountId: null, + expiresAt: Date.now() + 3600_000, + }), + ); return Response.json({ cookie: `__Host-flarebot-control-session=${session}`, grantRef, @@ -203,12 +207,13 @@ export default { } if (url.pathname === "/fixture/expire") { const { grantRef } = (await request.json()) as { grantRef: string }; - await vault(env, "grant", grantRef).destroy(); + await Effect.runPromise(vault(env, "grant", grantRef).destroy()); return Response.json({ ok: true }); } return ( - (await handleInstallations(request, env, network(env))) ?? - new Response(null, { status: 404 }) + (await Effect.runPromise( + handleInstallations(request, env, network(env)), + )) ?? new Response(null, { status: 404 }) ); }, } satisfies ExportedHandler; diff --git a/tests/fixtures/golden-control-worker.ts b/tests/fixtures/golden-control-worker.ts index 5566ac3..b5ff963 100644 --- a/tests/fixtures/golden-control-worker.ts +++ b/tests/fixtures/golden-control-worker.ts @@ -1,5 +1,5 @@ -// A connected local provider boundary: accepted uploads launch real workerd. -// Inherit production health; only artifact and external networking vary. +import { ArtifactUnavailable } from "../../control-plane/deployment-errors.ts"; +import * as Effect from "effect/Effect"; import { Provider as NativeProvider } from "./orchestrator-worker"; import oauth, { network as oauthNetwork, AuthVault } from "./oauth-worker"; import { InstallationRegistry } from "../../control-plane/installation-registry"; @@ -19,26 +19,29 @@ const local = (env: TestEnv, path: string) => `http://127.0.0.1:${env.TEST_RUNNER_PORT}${path}`; const provider = (env: TestEnv) => env.PROVIDER.get(env.PROVIDER.idFromName("account")); -let artifact: ReturnType | undefined; +let artifact: + Promise>> | undefined; async function fixtureArtifact(env: TestEnv, pinned?: ReleaseIdentity) { artifact ??= fetch(local(env, "/artifact")).then(async (response) => { const entry = await response.json<{ identity: CatalogEntry["identity"]; files: Record; }>(); - return loadArtifact( - { - identity: entry.identity, - development: true, - files: Object.fromEntries( - Object.entries(entry.files).map(([path, bytes]) => [ - path, - Uint8Array.from(atob(bytes), (c) => c.charCodeAt(0)).buffer, - ]), - ), - }, - undefined, - true, + return Effect.runPromise( + loadArtifact( + { + identity: entry.identity, + development: true, + files: Object.fromEntries( + Object.entries(entry.files).map(([path, bytes]) => [ + path, + Uint8Array.from(atob(bytes), (c) => c.charCodeAt(0)).buffer, + ]), + ), + }, + undefined, + true, + ), ); }); const value = await artifact; @@ -94,7 +97,7 @@ const network = }; export class InstallationWorkflow extends NativeWorkflow { protected artifact(pinned: ReleaseIdentity) { - return fixtureArtifact(this.env as TestEnv, pinned); + return fixtureArtifactEffect(this.env as TestEnv, pinned); } protected network() { return network(this.env as TestEnv); @@ -108,9 +111,17 @@ export default { request, ); return ( - (await handleInstallations(request, env, network(env), (pinned) => - fixtureArtifact(env, pinned), + (await Effect.runPromise( + handleInstallations(request, env, network(env), (pinned) => + fixtureArtifactEffect(env, pinned), + ), )) ?? oauth.fetch(request, env, ctx) ); }, } satisfies ExportedHandler; + +const fixtureArtifactEffect = (env: TestEnv, pinned?: ReleaseIdentity) => + Effect.tryPromise({ + try: () => fixtureArtifact(env, pinned), + catch: () => new ArtifactUnavailable(), + }); diff --git a/tests/fixtures/oauth-worker.ts b/tests/fixtures/oauth-worker.ts index c3545ac..5f71fa2 100644 --- a/tests/fixtures/oauth-worker.ts +++ b/tests/fixtures/oauth-worker.ts @@ -1,3 +1,4 @@ +import * as Effect from "effect/Effect"; export { InstallationWorkflow } from "../../control-plane/installation-workflow.ts"; // EXPLICIT TEST-ONLY network/admin seam. Never exported by a production entry. import app from "../../control-plane/index.ts"; @@ -40,16 +41,20 @@ export class AuthVault extends ProductionVault { const secret = loadControlPlaneSecrets( this.env, ).credentialEncryptionKey.reveal(); - const value = await decrypt>( - secret, - record, - `${this.ctx.id}:${record.kind}:${record.expiresAt}`, + const value = await Effect.runPromise( + decrypt>( + secret, + record, + `${this.ctx.id}:${record.kind}:${record.expiresAt}`, + ), ); const expiresAt = Date.now() - 1; - const sealed = await encrypt( - secret, - { ...value, expiresAt }, - `${this.ctx.id}:${record.kind}:${expiresAt}`, + const sealed = await Effect.runPromise( + encrypt( + secret, + { ...value, expiresAt }, + `${this.ctx.id}:${record.kind}:${expiresAt}`, + ), ); await this.ctx.storage.put("record", { kind: record.kind, @@ -82,7 +87,8 @@ export const network: CloudflareFetch = async (input, init) => { codes.delete(form.get("code") ?? ""); if ( !registered || - registered.challenge !== (await hash(form.get("code_verifier") ?? "")) + registered.challenge !== + (await Effect.runPromise(hash(form.get("code_verifier") ?? ""))) ) return Response.json( { error: "invalid_grant", error_description: "private-provider-error" }, @@ -215,7 +221,9 @@ export default { return Response.json({ ok: true }); } if (path === "/__test__/inspect") { - const principal = await authenticatedPrincipal(request, env); + const principal = await Effect.runPromise( + authenticatedPrincipal(request, env), + ); const stub = env.AUTH_VAULT.get( env.AUTH_VAULT.idFromName(`grant:${principal.grantRef}`), ) as unknown as DurableObjectStub; @@ -226,10 +234,8 @@ export default { } if (path === "/__test__/deployment-seam") { try { - const { principal, grant } = await selectedDeploymentGrant( - request, - env, - network, + const { principal, grant } = await Effect.runPromise( + selectedDeploymentGrant(request, env, network), ); return Response.json({ subject: principal.subject, @@ -244,10 +250,8 @@ export default { } if (path === "/__test__/alarm") { await ( - vault( - env, - "transaction", - body.ref, + env.AUTH_VAULT.get( + env.AUTH_VAULT.idFromName(`transaction:${body.ref}`), ) as unknown as DurableObjectStub ).fixtureAlarm(); return Response.json({ ok: true }); @@ -255,8 +259,8 @@ export default { return new Response(null, { status: 404 }); } return ( - (await handleInstallations(request, env, network)) ?? - (await handleOAuth(request, env, network)) ?? + (await Effect.runPromise(handleInstallations(request, env, network))) ?? + (await Effect.runPromise(handleOAuth(request, env, network))) ?? app.fetch!(request, env, ctx) ); }, diff --git a/tests/fixtures/orchestrator-worker.ts b/tests/fixtures/orchestrator-worker.ts index 278dba7..500f8a8 100644 --- a/tests/fixtures/orchestrator-worker.ts +++ b/tests/fixtures/orchestrator-worker.ts @@ -1,3 +1,4 @@ +import * as Effect from "effect/Effect"; // Fixed stateful Cloudflare API fixture, native Workflow and native SQLite DOs. // Never included in either production entrypoint. import { @@ -9,12 +10,16 @@ import oauth from "./ownership-worker.ts"; import { network as oauthNetwork } from "./oauth-worker.ts"; import { InstallationWorkflow as ProductionWorkflow } from "../../control-plane/installation-workflow.ts"; import { customerArtifact } from "../../control-plane/catalog.ts"; -import { DeploymentError } from "../../control-plane/deployment-errors.ts"; +import { + ArtifactUnavailable, + HealthFailed, +} from "../../control-plane/deployment-errors.ts"; import { startInstallation } from "../../control-plane/start-installation.ts"; import { installationRegistry, - handleInstallations, -} from "../../control-plane/installations.ts"; + installationRegistryStub, +} from "../../control-plane/registry-client.ts"; +import { handleInstallations } from "../../control-plane/installations.ts"; import { authenticatedPrincipal, selectedDeploymentGrant, @@ -25,7 +30,7 @@ import type { Installation, ReleaseIdentity, } from "../../control-plane/installation-metadata.ts"; -import { unwrap } from "../../control-plane/installation-metadata.ts"; + import { digest, loadArtifact } from "../../control-plane/artifact.ts"; import { InstallationRegistry as BaseRegistry } from "./ownership-worker.ts"; import { AuthVault as BaseVault } from "./oauth-worker.ts"; @@ -53,10 +58,11 @@ interface TestEnv extends Env { PROVIDER: DurableObjectNamespace; } const remoteId = async (value: string) => - (await digest(new TextEncoder().encode(value).buffer as ArrayBuffer)).slice( - 0, - 32, - ); + ( + await Effect.runPromise( + digest(new TextEncoder().encode(value).buffer as ArrayBuffer), + ) + ).slice(0, 32); const safe = (result: unknown) => Response.json({ success: true, result }); const unavailable = () => Response.json( @@ -82,13 +88,13 @@ const network = }; // Two unmistakably local, checksummed release fixtures. No production flag. async function fixtureArtifact(env: Env, pinned?: ReleaseIdentity) { - const source = await customerArtifact(undefined, true); + const source = await Effect.runPromise(customerArtifact(undefined, true)); const opts: any = await provider(env).fixtureInspect(); if ( opts.options?.retireSource && pinned?.artifactDigest === source.identity.artifactDigest ) - throw new DeploymentError("artifact_unavailable"); + throw new ArtifactUnavailable(); if ( (!pinned && !opts.options?.upgradeLatest) || pinned?.artifactDigest === source.identity.artifactDigest @@ -125,7 +131,7 @@ async function fixtureArtifact(env: Env, pinned?: ReleaseIdentity) { ).buffer as ArrayBuffer; for (const file of manifest.files) { file.size = bytes[file.path].byteLength; - file.sha256 = await digest(bytes[file.path]); + file.sha256 = await Effect.runPromise(digest(bytes[file.path])); if ( file.assetHash && file.path === @@ -142,12 +148,10 @@ async function fixtureArtifact(env: Env, pinned?: ReleaseIdentity) { const identity = { version: manifest.release, sourceRevision: manifest.sourceRevision, - artifactDigest: await digest(bytes["manifest.json"]), + artifactDigest: await Effect.runPromise(digest(bytes["manifest.json"])), }; - return loadArtifact( - { identity, development: true, files: bytes }, - pinned, - true, + return Effect.runPromise( + loadArtifact({ identity, development: true, files: bytes }, pinned, true), ); } export class Provider extends DurableObject { @@ -445,7 +449,7 @@ export class Provider extends DurableObject { name: part, mime: f.type, size: f.size, - sha256: await digest(await f.arrayBuffer()), + sha256: await Effect.runPromise(digest(await f.arrayBuffer())), }); } const bindings = await Promise.all( @@ -568,7 +572,7 @@ export class Provider extends DurableObject { hash, mime: f.type, size: bytes.length, - sha256: await digest(bytes.buffer), + sha256: await Effect.runPromise(digest(bytes.buffer)), }); } await this.ctx.storage.put("uploadedAssets", files); @@ -658,14 +662,15 @@ export class Provider extends DurableObject { } export class InstallationWorkflow extends ProductionWorkflow { protected artifact(pinned: ReleaseIdentity) { - return fixtureArtifact(this.env, pinned); + return fixtureArtifactEffect(this.env, pinned); } protected network() { return network(this.env); } - protected async health(record: Installation) { - if (!(await provider(this.env).fixtureHealth(record))) - throw new DeploymentError("health_failed"); + protected health(record: Installation) { + return Effect.promise(() => provider(this.env).fixtureHealth(record)).pipe( + Effect.flatMap((healthy) => (healthy ? Effect.void : new HealthFailed())), + ); } } export default { @@ -703,12 +708,16 @@ export default { } } if (url.pathname === "/__test__/invalid-upgrade-intent") { - const principal = await authenticatedPrincipal(request, env); + const principal = await Effect.runPromise( + authenticatedPrincipal(request, env), + ); const { id } = (await request.json()) as any; const registry = installationRegistry(env, principal.subject); - const record = unwrap(await registry.get(principal.subject, id))!; + const record = (await Effect.runPromise( + registry.get(principal.subject, id), + ))!; return Response.json( - await registry.intent( + await installationRegistryStub(env, principal.subject).intent( principal.subject, id, record.operationId!, @@ -738,15 +747,15 @@ export default { id: string; requestId: string; }; - const { principal, grant } = await selectedDeploymentGrant( - request, - env, - oauthNetwork, + const { principal, grant } = await Effect.runPromise( + selectedDeploymentGrant(request, env, oauthNetwork), + ); + const artifact = await Effect.runPromise( + customerArtifact(undefined, true), ); - const artifact = await customerArtifact(undefined, true); return Response.json( - unwrap( - await installationRegistry(env, principal.subject).start( + await Effect.runPromise( + installationRegistry(env, principal.subject).start( principal.subject, id, principal.selectedAccountId!, @@ -766,14 +775,16 @@ export default { try { return Response.json( { - installation: await startInstallation( - request, - env, - id, - requestId, - network(env), - (pinned) => fixtureArtifact(env, pinned), - recover, + installation: await Effect.runPromise( + startInstallation( + request, + env, + id, + requestId, + network(env), + (pinned) => fixtureArtifactEffect(env, pinned), + { action: recover ? "recover" : "start" }, + ), ), }, { status: 202 }, @@ -786,9 +797,11 @@ export default { } } if (url.pathname === "/__test__/operation") { - const principal = await authenticatedPrincipal(request, env); - const record = unwrap( - await installationRegistry(env, principal.subject).get( + const principal = await Effect.runPromise( + authenticatedPrincipal(request, env), + ); + const record = await Effect.runPromise( + installationRegistry(env, principal.subject).get( principal.subject, url.searchParams.get("id")!, ), @@ -832,11 +845,10 @@ export default { }; } } - const installation = await handleInstallations( - request, - env, - network(env), - (pinned) => fixtureArtifact(env, pinned), + const installation = await Effect.runPromise( + handleInstallations(request, env, network(env), (pinned) => + fixtureArtifactEffect(env, pinned), + ), ); if (installation) return installation; return oauth.fetch( @@ -846,3 +858,9 @@ export default { ); }, } satisfies ExportedHandler; + +const fixtureArtifactEffect = (env: Env, pinned?: ReleaseIdentity) => + Effect.tryPromise({ + try: () => fixtureArtifact(env, pinned), + catch: () => new ArtifactUnavailable(), + }); diff --git a/tests/fixtures/ownership-worker.ts b/tests/fixtures/ownership-worker.ts index 5af4b37..edf55d4 100644 --- a/tests/fixtures/ownership-worker.ts +++ b/tests/fixtures/ownership-worker.ts @@ -1,12 +1,11 @@ +import * as Effect from "effect/Effect"; export { InstallationWorkflow } from "../../control-plane/installation-workflow.ts"; // Explicit test-only storage/RPC inspection. Never part of a production entry. import oauth, { network } from "./oauth-worker.ts"; export { AuthVault } from "./oauth-worker.ts"; import { InstallationRegistry as ProductionRegistry } from "../../control-plane/installation-registry.ts"; -import { - handleInstallations, - installationRegistry, -} from "../../control-plane/installations.ts"; +import { installationRegistryStub as installationRegistry } from "../../control-plane/registry-client.ts"; +import { handleInstallations } from "../../control-plane/installations.ts"; import { authenticatedPrincipal } from "../../control-plane/session.ts"; import type { Env } from "../../control-plane/config.ts"; import type { InstallationChanges } from "../../control-plane/installation-metadata.ts"; @@ -24,7 +23,9 @@ export default { async fetch(request, env, ctx) { const path = new URL(request.url).pathname; if (path.startsWith("/__test__/metadata/")) { - const principal = await authenticatedPrincipal(request, env); + const principal = await Effect.runPromise( + authenticatedPrincipal(request, env), + ); const body = request.method === "POST" ? ((await request.json()) as Record) @@ -67,17 +68,19 @@ export default { return new Response(null, { status: 404 }); } return ( - (await handleInstallations(request, env, async (input, init) => { - const url = new URL( - typeof input === "string" - ? input - : input instanceof URL - ? input.href - : input.url, - ); - calls.push(url.origin + url.pathname); - return network(input, init); - })) ?? oauth.fetch(request, env, ctx) + (await Effect.runPromise( + handleInstallations(request, env, async (input, init) => { + const url = new URL( + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url, + ); + calls.push(url.origin + url.pathname); + return network(input, init); + }), + )) ?? oauth.fetch(request, env, ctx) ); }, } satisfies ExportedHandler; diff --git a/tests/gateway-provisioning.test.mjs b/tests/gateway-provisioning.test.mjs index 6760907..c7598ae 100644 --- a/tests/gateway-provisioning.test.mjs +++ b/tests/gateway-provisioning.test.mjs @@ -1,42 +1,50 @@ +import * as Effect from "effect/Effect"; import assert from "node:assert/strict"; import { test } from "node:test"; import { build } from "esbuild"; const bundle = await build({ - entryPoints: ["control-plane/deployment-api.ts"], + stdin: { + contents: `export { CloudflareAccount } from './control-plane/cloudflare-account.ts'; export { ensureModelGateway } from './control-plane/model-gateway.ts';`, + resolveDir: process.cwd(), + }, bundle: true, format: "esm", platform: "node", write: false, }); -const { DeploymentAPI } = await import( +const { CloudflareAccount, ensureModelGateway } = await import( `data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].text).toString("base64")}` ); function fixture(options = {}) { const state = { gateway: null, writes: [], requests: [], ...options }; const ok = (result) => Response.json({ success: true, result }); - const api = new DeploymentAPI( + const api = new CloudflareAccount( "fixture-token", - { accountId: "b".repeat(32), resources: {} }, + "b".repeat(32), async (input, init) => { - const url = new URL(input); + const request = new Request(input, init); + const url = new URL(request.url); + const body = request.method === "GET" ? null : await request.json(); assert.equal(url.origin, "https://api.cloudflare.com"); - assert.equal(init.headers.Authorization, "Bearer fixture-token"); + assert.equal( + new Headers(init.headers).get("Authorization"), + "Bearer fixture-token", + ); assert.equal(init.redirect, "manual"); const path = url.pathname.split("/ai-gateway/")[1]; state.requests.push([init.method, path]); assert.ok(!path?.startsWith("custom-providers")); if (state.denied) return new Response("private-provider-error", { status: state.denied }); - if (init.method !== "GET") - state.writes.push([init.method, path, JSON.parse(init.body)]); + if (init.method !== "GET") state.writes.push([init.method, path, body]); if (path === "gateways/default") return state.gateway ? ok(state.gateway) : new Response(null, { status: 404 }); if (path === "gateways" && init.method === "POST") { - state.gateway = JSON.parse(init.body); + state.gateway = body; if (state.loseGateway) { state.loseGateway = false; throw new Error("lost reply"); @@ -51,7 +59,7 @@ function fixture(options = {}) { test("OpenRouter enablement creates only the default gateway", async () => { const { api, state } = fixture(); - await api.enableOpenRouter(); + await Effect.runPromise(ensureModelGateway(api)); assert.equal(state.gateway.id, "default"); assert.equal(state.gateway.collect_logs, false); assert.equal(state.gateway.authentication, true); @@ -60,7 +68,7 @@ test("OpenRouter enablement creates only the default gateway", async () => { state.writes.map(([, path]) => path), ["gateways"], ); - await api.enableOpenRouter(); + await Effect.runPromise(ensureModelGateway(api)); assert.equal(state.writes.length, 1); assert.ok( state.requests.every(([, path]) => !path.includes("custom-providers")), @@ -75,15 +83,18 @@ test("preserves existing shared gateway settings without writes", async () => { workers_ai_billing_mode: "postpaid", }; const { api, state } = fixture({ gateway }); - await api.enableOpenRouter(); + await Effect.runPromise(ensureModelGateway(api)); assert.deepEqual(state.gateway, gateway); assert.deepEqual(state.writes, []); }); test("reconciles a lost gateway creation reply without custom-provider requests", async () => { const { api, state } = fixture({ loseGateway: true }); - await assert.rejects(api.enableOpenRouter(), /temporarily_unavailable/); - await api.enableOpenRouter(); + await assert.rejects( + Effect.runPromise(ensureModelGateway(api)), + /temporarily_unavailable/, + ); + await Effect.runPromise(ensureModelGateway(api)); assert.equal(state.gateway.id, "default"); assert.equal(state.writes.length, 1); assert.ok( @@ -97,7 +108,10 @@ for (const [denied, code] of [ ]) { test(`sanitizes permission failure ${denied} without writes`, async () => { const { api, state } = fixture({ denied }); - await assert.rejects(api.enableOpenRouter(), new RegExp(code)); + await assert.rejects( + Effect.runPromise(ensureModelGateway(api)), + new RegExp(code), + ); assert.deepEqual(state.writes, []); }); } diff --git a/tests/installation-metadata.test.mjs b/tests/installation-metadata.test.mjs new file mode 100644 index 0000000..3c9807d --- /dev/null +++ b/tests/installation-metadata.test.mjs @@ -0,0 +1,440 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Cause from "effect/Cause"; +import { parseInstallation } from "../control-plane/installation-metadata.ts"; +import { + InvalidMetadata, + InstallationNotFound, + InstallationConflict, +} from "../control-plane/installation-errors.ts"; +import { registryResult } from "../control-plane/registry-result.ts"; +import { registryClient } from "../control-plane/registry-client.ts"; +import { runDeploymentEffect } from "../control-plane/workflow-boundary.ts"; +import { + beginInstallation, + changeInstallation, +} from "../control-plane/installation-lifecycle.ts"; +import { + beginDomain, + changeDomain, +} from "../control-plane/domain-lifecycle.ts"; +import { operationSchema } from "../control-plane/operation.ts"; + +const id = "a".repeat(32); +const release = { + version: "1.0.0", + sourceRevision: "b".repeat(40), + artifactDigest: "c".repeat(64), +}; +const reserved = { + schemaVersion: 1, + installationId: id, + ownerSubject: "owner", + accountId: "d".repeat(32), + createdAt: 100, + updatedAt: 200, + revision: 1, + resources: { + workerName: `flarebot-${id}`, + sandboxApplicationName: `flarebot-shell-${id}`, + personalAgentNamespaceId: null, + sandboxNamespaceId: null, + sandboxApplicationId: null, + runtimeOrigin: null, + }, + desiredRelease: null, + installedRelease: null, + status: "reserved", + progress: null, + errorCode: null, + operationId: null, +}; +const installing = { + ...reserved, + desiredRelease: release, + operationId: "e".repeat(32), + status: "installing", + progress: "preparing", +}; +const ready = { + ...installing, + resources: { + ...reserved.resources, + personalAgentNamespaceId: "1".repeat(32), + sandboxNamespaceId: "2".repeat(32), + sandboxApplicationId: "3".repeat(32), + runtimeOrigin: `https://flarebot-${id}.customer.workers.dev`, + }, + installedRelease: { ...release, installedAt: 150 }, + status: "ready", + progress: "complete", +}; + +test("recovery carries ambiguous write intents into the new operation until explicit, valid clearance", () => { + const failed = parseInstallation({ + ...installing, + status: "failed", + errorCode: "recovery_required", + }); + const previous = operationSchema.parse({ + operationId: installing.operationId, + deadline: 1000, + workerIntent: { + operationId: installing.operationId, + release, + configDigest: "f".repeat(64), + }, + containerIntent: true, + containerUpdate: true, + rolloutIntent: installing.operationId, + rolloutId: "6".repeat(32), + }); + const command = { + desired: release, + deadline: 2000, + operationId: "7".repeat(32), + now: 300, + upgrade: null, + recovery: null, + }; + const resumed = beginInstallation(failed, previous, command); + assert.equal(resumed.installation.operationId, command.operationId); + assert.deepEqual(resumed.operation, { + ...previous, + operationId: command.operationId, + deadline: 2000, + }); + const recovery = { + expectedRevision: failed.revision, + clearWorker: true, + clearContainer: false, + clearRollout: true, + }; + const cleared = beginInstallation(failed, previous, { ...command, recovery }); + assert.equal(cleared.operation.workerIntent, null); + assert.equal(cleared.operation.rolloutIntent, null); + assert.equal(cleared.operation.rolloutId, null); + assert.equal(cleared.operation.containerIntent, true); + assert.equal(cleared.operation.containerUpdate, true); + assert.ok(previous.workerIntent); + assert.throws( + () => + beginInstallation(failed, previous, { + ...command, + recovery: { ...recovery, expectedRevision: 99 }, + }), + InstallationConflict, + ); + assert.throws( + () => + beginInstallation( + { + ...failed, + resources: { + ...failed.resources, + sandboxNamespaceId: "8".repeat(32), + }, + }, + previous, + { ...command, recovery }, + ), + InstallationConflict, + ); +}); + +test("only verified completion promotes a desired release; failed or stale updates preserve installed identity", () => { + const target = { + ...release, + version: "2.0.0", + artifactDigest: "f".repeat(64), + }; + const updating = parseInstallation({ + ...ready, + desiredRelease: target, + status: "updating", + progress: "verifying", + }); + const failed = changeInstallation( + updating, + updating.revision, + { status: "failed", errorCode: "health_failed" }, + 300, + ); + assert.deepEqual(failed.installedRelease, ready.installedRelease); + assert.deepEqual(failed.desiredRelease, target); + const completed = changeInstallation( + updating, + updating.revision, + { status: "ready" }, + 300, + ); + assert.deepEqual(completed.installedRelease, { ...target, installedAt: 300 }); + assert.equal(completed.progress, "complete"); + assert.equal(completed.revision, updating.revision + 1); + assert.throws( + () => changeInstallation(updating, 99, { status: "ready" }, 300), + InstallationConflict, + ); + assert.throws( + () => + changeInstallation( + updating, + updating.revision, + { resources: { sandboxNamespaceId: "9".repeat(32) } }, + 300, + ), + InstallationConflict, + ); +}); + +test("domain retry preserves uncertain attachment evidence and removal waits for a resolved domain ID", () => { + const pending = beginDomain( + null, + { action: "attach", origin: "https://bot.example.com", zoneId: id }, + "1".repeat(32), + 1000, + ); + const failed = changeDomain(pending, pending.operationId, pending.revision, { + status: "failed", + errorCode: "temporarily_unavailable", + writeIntent: true, + domainId: null, + }); + const retried = beginDomain( + failed, + { action: "retry" }, + "2".repeat(32), + 2000, + ); + assert.deepEqual(retried, { + ...failed, + operationId: "2".repeat(32), + deadline: 2000, + revision: failed.revision + 1, + status: "connecting", + errorCode: null, + }); + assert.throws( + () => beginDomain(failed, { action: "remove" }, "3".repeat(32), 3000), + InstallationConflict, + ); + for (const errorCode of [ + "temporarily_unavailable", + "https_pending", + "attachment_outcome_unknown", + ]) + assert.throws( + () => + changeDomain(retried, retried.operationId, retried.revision, { + status: "failed", + errorCode, + writeIntent: false, + domainId: null, + }), + InstallationConflict, + ); + for (const errorCode of [ + "resource_conflict", + "account_denied", + "reauthorization_required", + ]) + assert.equal( + changeDomain(retried, retried.operationId, retried.revision, { + status: "failed", + errorCode, + writeIntent: false, + domainId: null, + }).writeIntent, + false, + ); + const active = changeDomain(retried, retried.operationId, retried.revision, { + status: "active", + errorCode: null, + writeIntent: true, + domainId: "4".repeat(40), + }); + const removing = beginDomain( + active, + { action: "remove" }, + "5".repeat(32), + 3000, + ); + assert.equal(removing.origin, null); + assert.equal(removing.domainId, active.domainId); + assert.equal(removing.hostname, active.hostname); +}); + +test("metadata preserves lifecycle states and legacy progress defaults", () => { + for (const record of [ + reserved, + installing, + ready, + { ...ready, status: "updating", progress: "preparing" }, + { ...installing, status: "failed", errorCode: "health_failed" }, + { + ...ready, + status: "failed", + progress: "verifying", + errorCode: "health_failed", + }, + ]) + assert.deepEqual(parseInstallation(record), record); + for (const record of [reserved, installing, ready]) { + const { progress, ...legacy } = record; + assert.deepEqual(parseInstallation(legacy), { ...record, progress: null }); + } +}); + +test("metadata rejects inconsistent ownership, lifecycle, timestamps and private fields", () => { + for (const record of [ + { ...reserved, resources: { ...reserved.resources, workerName: "other" } }, + { + ...reserved, + resources: { ...reserved.resources, sandboxApplicationName: "other" }, + }, + { + ...ready, + resources: { + ...ready.resources, + runtimeOrigin: "https://unowned.customer.workers.dev", + }, + }, + { ...reserved, updatedAt: 99 }, + { + ...ready, + installedRelease: { ...ready.installedRelease, installedAt: 99 }, + }, + { + ...ready, + installedRelease: { ...ready.installedRelease, installedAt: 201 }, + }, + { ...reserved, progress: "preparing" }, + { ...installing, progress: "complete" }, + { ...ready, progress: "verifying" }, + { ...reserved, desiredRelease: release }, + { ...reserved, operationId: id }, + { + ...reserved, + resources: { ...reserved.resources, sandboxNamespaceId: id }, + }, + { ...installing, desiredRelease: null }, + { ...installing, operationId: null }, + { ...ready, status: "installing", progress: "preparing" }, + { ...installing, status: "updating" }, + { ...installing, status: "ready", progress: "complete" }, + { ...installing, status: "failed" }, + { ...installing, errorCode: "health_failed" }, + { ...ready, resources: { ...ready.resources, sandboxNamespaceId: null } }, + { ...ready, desiredRelease: { ...release, version: "2.0.0" } }, + { ...ready, privateContent: "private-sentinel" }, + { ...ready, resources: { ...ready.resources, token: "private-sentinel" } }, + ]) { + assert.throws( + () => parseInstallation(record), + (error) => { + assert.ok(error instanceof InvalidMetadata); + assert.equal(error.message, "invalid_metadata"); + assert.ok(!JSON.stringify(error).includes("private-sentinel")); + return true; + }, + ); + } +}); + +test("registry RPC preserves plain results and reconstructs distinct typed failures lazily", async () => { + for (const ErrorType of [ + InvalidMetadata, + InstallationNotFound, + InstallationConflict, + ]) { + const error = new ErrorType(); + error.privateContent = "private-sentinel"; + const wire = await registryResult(Effect.fail(error)); + assert.deepEqual(wire, { ok: false, error: error.code }); + let calls = 0; + const client = registryClient({ + async get(subject, installationId) { + assert.equal(this.marker, "native-receiver"); + assert.deepEqual([subject, installationId], ["owner", id]); + calls++; + return wire; + }, + marker: "native-receiver", + }); + const program = client.get("owner", id); + assert.equal(calls, 0); + const exit = await Effect.runPromiseExit(program); + assert.equal(calls, 1); + assert.ok(Exit.isFailure(exit)); + assert.ok(!Cause.hasDies(exit.cause)); + const failure = Exit.findErrorOption(exit).value; + assert.ok(failure instanceof ErrorType); + assert.notEqual(failure, error); + assert.equal(failure.privateContent, undefined); + await assert.rejects(runDeploymentEffect(program), { + code: "temporarily_unavailable", + }); + } +}); + +test("registry failures stop composed writes and can be handled selectively", async () => { + let writes = 0; + const client = registryClient({ + get: async () => ({ ok: false, error: "installation_not_found" }), + update: async () => { + writes++; + return { ok: true, value: ready }; + }, + }); + const program = Effect.gen(function* () { + yield* client.get("owner", id); + return yield* client.update("owner", id, 1, {}); + }); + const result = await Effect.runPromise( + program.pipe( + Effect.catchTag("InstallationNotFound", () => Effect.succeed(null)), + ), + ); + assert.equal(result, null); + assert.equal(writes, 0); + const unhandled = await Effect.runPromiseExit( + program.pipe( + Effect.catchTag("InstallationConflict", () => Effect.succeed(null)), + ), + ); + assert.ok(Exit.isFailure(unhandled)); + assert.ok( + Exit.findErrorOption(unhandled).value instanceof InstallationNotFound, + ); +}); + +test("registry successes compose and transport defects are not swallowed or retried", async () => { + const successful = registryClient({ + get: () => registryResult(Effect.succeed(ready)), + }); + assert.deepEqual(await Effect.runPromise(successful.get("owner", id)), ready); + const defect = new Error("private-transport-sentinel"); + let calls = 0; + const failed = registryClient({ + get: () => + registryResult( + Effect.sync(() => { + calls++; + throw defect; + }), + ), + }); + const exit = await Effect.runPromiseExit( + failed.get("owner", id).pipe(Effect.catch(() => Effect.succeed(null))), + ); + assert.equal(calls, 1); + assert.ok(Exit.isFailure(exit)); + assert.ok(Cause.hasDies(exit.cause)); + assert.ok(!Cause.hasFails(exit.cause)); + await assert.rejects( + registryResult(Effect.die(defect)), + (error) => error === defect, + ); +}); diff --git a/tests/oauth.test.mjs b/tests/oauth.test.mjs index 92f74ef..85a5ae0 100644 --- a/tests/oauth.test.mjs +++ b/tests/oauth.test.mjs @@ -124,6 +124,11 @@ test( redirect: "manual", ...options, headers: { Origin: origin, ...options.headers }, + }).catch((error) => { + throw new Error( + `${options.method ?? "GET"} ${path.split("?")[0]} failed`, + { cause: error }, + ); }); const admin = async (path, body) => ( @@ -751,7 +756,8 @@ test( assert.equal(minimalConsent.status, 303); assert.deepEqual( new URL(minimalConsent.headers.get("Location")).searchParams - .get("scope").split(" "), + .get("scope") + .split(" "), broaderClient.oauthScopes, ); for (const mutate of [ diff --git a/tests/orchestrator.test.mjs b/tests/orchestrator.test.mjs index 227a3e2..b3eeb0c 100644 --- a/tests/orchestrator.test.mjs +++ b/tests/orchestrator.test.mjs @@ -1,3 +1,4 @@ +import * as Effect from "effect/Effect"; import { upgradeAssetHash } from "./fixtures/upgrade-artifact.mjs"; import assert from "node:assert/strict"; import { randomBytes, generateKeyPairSync } from "node:crypto"; @@ -63,29 +64,33 @@ async function artifactEntry() { } test("immutable catalog refuses dirty production, wrong pin and mutated bytes before effects", async () => { const entry = await artifactEntry(); - const loaded = await loadArtifact(entry, undefined, true); + const loaded = await Effect.runPromise(loadArtifact(entry, undefined, true)); assert.equal(loaded.identity.artifactDigest, entry.identity.artifactDigest); - await assert.rejects(() => loadArtifact(entry), { + await assert.rejects(() => Effect.runPromise(loadArtifact(entry)), { message: "artifact_unavailable", }); await assert.rejects( () => - loadArtifact( - entry, - { ...entry.identity, artifactDigest: "0".repeat(64) }, - true, + Effect.runPromise( + loadArtifact( + entry, + { ...entry.identity, artifactDigest: "0".repeat(64) }, + true, + ), ), { message: "artifact_unavailable" }, ); await assert.rejects( () => - loadArtifact( - { - ...entry, - files: { ...entry.files, "worker/index.js": new ArrayBuffer(0) }, - }, - undefined, - true, + Effect.runPromise( + loadArtifact( + { + ...entry, + files: { ...entry.files, "worker/index.js": new ArrayBuffer(0) }, + }, + undefined, + true, + ), ), { message: "artifact_unavailable" }, ); diff --git a/tests/registry-client.types.ts b/tests/registry-client.types.ts new file mode 100644 index 0000000..6ce297a --- /dev/null +++ b/tests/registry-client.types.ts @@ -0,0 +1,98 @@ +import type * as Effect from "effect/Effect"; +import type { DomainRecord } from "../control-plane/domain-metadata.ts"; +import type { RegistryFailure } from "../control-plane/installation-errors.ts"; +import type { Installation } from "../control-plane/installation-metadata.ts"; +import type { + InstallationRegistry, + InstallationPage, + InstallationState, +} from "../control-plane/installation-registry.ts"; +import type { InstallationOperation } from "../control-plane/operation.ts"; +import type { + installationRegistry, + InstallationRegistryClient, +} from "../control-plane/registry-client.ts"; + +type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 + ? true + : false; +type Assert = T; + +interface Successes { + reserve: Installation; + get: Installation | null; + getDomain: DomainRecord | null; + domainReplay: string | null; + startDomain: DomainRecord; + updateDomain: DomainRecord; + list: InstallationPage; + start: InstallationState; + replay: Installation | null; + operation: InstallationOperation | null; + active: InstallationState; + intent: InstallationOperation; + update: Installation; +} + +// Assert against application models, independently of the client's mapped type. +type Client = ReturnType; +type ClientContract = Assert>; +type MethodNames = Assert>; +type Results = Assert< + Equal< + { + [K in keyof Successes]: Effect.Success>; + }, + Successes + > +>; +type Failures = Assert< + Equal< + { + [K in keyof Successes]: Effect.Error>; + }, + { [K in keyof Successes]: RegistryFailure } + > +>; +type Requirements = Assert< + Equal< + { + [K in keyof Successes]: Effect.Services>; + }, + { [K in keyof Successes]: never } + > +>; +type Arguments = Assert< + Equal< + { + [K in keyof Successes]: Parameters; + }, + { [K in keyof Successes]: Parameters } + > +>; + +// Exercise real Wrangler/Workers RPC types, not a Promise-only mock. +type NativeGet = ReturnType["get"]>; +type NativeEnvelopeIsDisposable = Assert< + Awaited extends Disposable ? true : false +>; +type NativeEnvelopeIsPipelined = Assert< + NativeGet["ok"] extends PromiseLike ? true : false +>; + +declare const client: Client; +declare const installation: Effect.Success>; +if (installation) { + const owner: string = installation.ownerSubject; + const resources: Installation["resources"] = installation.resources; + // @ts-expect-error Application values do not expose RPC pipelining. + installation.ownerSubject.then; + // @ts-expect-error RPC disposal belongs to the transport envelope. + installation[Symbol.dispose]; + void owner; + void resources; +} +// @ts-expect-error Updates require a revision; optional arguments stay optional elsewhere. +client.update("owner", "id", {}); +client.list("owner"); diff --git a/tsconfig.worker.json b/tsconfig.worker.json index ed11fe6..6ced62f 100644 --- a/tsconfig.worker.json +++ b/tsconfig.worker.json @@ -15,6 +15,8 @@ "tests/fixtures/schedule-worker.ts", "tests/fixtures/chat-worker.ts", "control-plane/*.ts", + "tests/registry-client.types.ts", + "tests/control-plane-effect.types.ts", "tests/fixtures/oauth-worker.ts", "tests/fixtures/ownership-worker.ts", "tests/fixtures/orchestrator-worker.ts", -- 2.51.2