diff --git a/Makefile b/Makefile index 979ce55c7..600c8d039 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ mindmap go-build go-test go-lint go-fmt go-vet go-tidy \ lint-md-links script-test test \ e2e-test behaviour-test lint-eval-cases functional-tests \ - wasm-build mint-cf-worker-test + wasm-build wasm-stage mint-cf-worker-test # Let Go automatically download the toolchain version required by go.mod. # This ensures local builds use the right version without manual intervention. @@ -33,7 +33,8 @@ help: @echo " lint-eval-cases - Lint eval case definitions (annotations.yaml completeness)" @echo " functional-tests - Run functional agent tests (requires EVAL_ORG, FULLSEND_DIR, GH_TOKEN, GCP creds)" @echo " wasm-build - Build mintcore WASM binary and report gzip size vs Workers limits" - @echo " mint-cf-worker-test - Run CF mint Worker bridge smoke tests (stub until workersrc lands)" + @echo " wasm-stage - Build and stage WASM + wasm_exec.js into CF Worker source tree" + @echo " mint-cf-worker-test - Build WASM, install deps, and run CF Worker bridge smoke tests" # Install all development tools needed for linting, formatting, and pre-commit hooks. # Prerequisites: uv (https://docs.astral.sh/uv/) and go (https://go.dev/) @@ -141,12 +142,29 @@ wasm-build: fi @echo "==> WASM build OK" -# Stub CI contract for CF mint Worker bridge smoke tests (#5481). -# Populated by #5427 / follow-up with wasm-stage + workersrc npm test. +# Stage WASM binary and wasm_exec.js into the CF Worker source tree. +# Run after wasm-build to prepare for `wrangler dev` or `wrangler deploy`. +WORKERSRC_DIR := internal/dispatch/cf/workersrc +wasm-stage: wasm-build + @echo "==> Staging WASM artifacts into $(WORKERSRC_DIR)..." + cp cmd/mint-wasm/mint.wasm $(WORKERSRC_DIR)/mintcore.wasm + cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" $(WORKERSRC_DIR)/wasm_exec.js + @echo "==> Staged: $(WORKERSRC_DIR)/mintcore.wasm, $(WORKERSRC_DIR)/wasm_exec.js" + +# Run CF Worker bridge smoke tests. +# Works on a clean checkout: stages WASM, installs npm deps, runs vitest. +# Uses `npm install` (not `npm ci`) because the workersrc lockfile is not +# committed — the dep tree is small enough that install-time resolution is +# acceptable. Switch to `npm ci` if a lockfile is added later. # Do not rename: .github/workflows/mint-cf-worker-test.yml calls this target. -mint-cf-worker-test: - @echo "==> mint-cf-worker-test: stub (no CF Worker bridge tests yet)" - @echo " Replace this stub when internal/dispatch/cf/workersrc lands (#5427)." +mint-cf-worker-test: wasm-stage + @echo "==> Installing CF Worker npm dependencies..." + cd $(WORKERSRC_DIR) && npm install --no-audit --no-fund + @echo "==> Type-checking CF Worker source (production + test files)..." + cd $(WORKERSRC_DIR) && npm run typecheck && npm run typecheck:tests + @echo "==> Running CF Worker bridge smoke tests..." + cd $(WORKERSRC_DIR) && npm test + @echo "==> Worker smoke tests passed" lint-md-links: lychee --offline --no-progress --include-fragments --exclude-path node_modules --exclude-path experiments '**/*.md' diff --git a/docs/contributing/go-code.md b/docs/contributing/go-code.md index 3bc8baf49..d369667d6 100644 --- a/docs/contributing/go-code.md +++ b/docs/contributing/go-code.md @@ -8,6 +8,8 @@ When changing `internal/mint/main.go`, always copy it to `internal/dispatch/gcf/ **Standalone mint:** `cmd/mint/` is a standalone HTTP server variant of the token mint that serves the same purpose as the GCF mint (`internal/mint/`) but runs without GCP infrastructure. Both use the shared `internal/mintcore/` library for token minting logic; they differ only in deployment model (filesystem PEM vs Secret Manager, JWKS vs STS verification). It supports custom role permissions via `CUSTOM_ROLE_PERMISSIONS` and a fallback proxy to an upstream mint. It has its own `go.mod` and tests run from `cmd/mint/`. +**CF Worker adapter:** `internal/dispatch/cf/workersrc/` is a thin TypeScript Cloudflare Worker adapter that consumes mintcore via WASM (`cmd/mint-wasm`). The adapter handles I/O only (Worker secrets, host fetch, Fetch Request/Response mapping); all mint logic stays in Go. The Go WASM bridge registers `mintcoreInitMint` and `mintcoreHandleFetch` on `globalThis` via `syscall/js`; changes to these entry points in `cmd/mint-wasm` or to the contracts they consume in `internal/mintcore/` require updating `workersrc/src/index.ts` to match. + **Mint client:** `internal/mintclient/` is the Go client for calling the mint service at runtime. It exchanges a GitHub Actions OIDC JWT for a role-scoped installation token. Unlike `internal/mint/` and `internal/mintcore/`, it has no embedded copies or sync requirements. The `internal/mintcore/` module is shared between the mint and devmint. Its files are also embedded for Cloud Function deployment at `internal/dispatch/gcf/mintsrc/mintcore/*.embed`. When changing any file in `internal/mintcore/`, sync it to the corresponding `.embed` file under `mintsrc/mintcore/`. Note: the mint's `go.mod.embed` uses `replace mintcore => ./mintcore` (not `../mintcore`), because `provisioner.go` rewrites the replace directive at bundle time to match the deployed directory layout. diff --git a/docs/guides/dev/cli-internals.md b/docs/guides/dev/cli-internals.md index c08d6fcb9..541c0f157 100644 --- a/docs/guides/dev/cli-internals.md +++ b/docs/guides/dev/cli-internals.md @@ -649,6 +649,7 @@ var executableFiles = map[string]struct{}{ | `cmd/mint/` | ~285 | Standalone mint server (no GCP dependency) | | `internal/mintcore/` | ~1425 | Shared mint library (handler, OIDC verifiers, GitHub API) | | `internal/dispatch/gcf/provisioner.go` | ~1959 | GCP infrastructure provisioner | +| `internal/dispatch/cf/workersrc/` | ~800 | CF Worker adapter for mint (WASM bridge, I/O only) | | `internal/sandbox/sandbox.go` | ~459 | OpenShell sandbox operations | | `internal/harness/harness.go` | ~486 | Harness YAML parsing | | `internal/layers/layers.go` | ~159 | Layer interface and stack | diff --git a/internal/dispatch/cf/workersrc/.gitignore b/internal/dispatch/cf/workersrc/.gitignore new file mode 100644 index 000000000..1a10e0ab6 --- /dev/null +++ b/internal/dispatch/cf/workersrc/.gitignore @@ -0,0 +1,6 @@ +# Build artifacts staged by `make wasm-stage` (not committed). +mintcore.wasm +wasm_exec.js + +# npm install output. +node_modules/ diff --git a/internal/dispatch/cf/workersrc/package.json b/internal/dispatch/cf/workersrc/package.json new file mode 100644 index 000000000..849292631 --- /dev/null +++ b/internal/dispatch/cf/workersrc/package.json @@ -0,0 +1,25 @@ +{ + "name": "fullsend-mint-worker", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Thin Cloudflare Worker adapter for the fullsend token mint (WASM)", + "scripts": { + "prestage": "cd ../../../.. && make wasm-stage", + "stage": "echo 'Staged mintcore.wasm and wasm_exec.js via make wasm-stage'", + "predev": "npm run stage", + "dev": "wrangler dev", + "predeploy": "npm run stage", + "deploy": "wrangler deploy", + "typecheck": "tsc --noEmit", + "typecheck:tests": "tsc --noEmit --project tsconfig.test.json", + "test": "vitest run" + }, + "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.8.0", + "@cloudflare/workers-types": "^5.20260708.1", + "typescript": "^5.8.0", + "vitest": "^3.2.0", + "wrangler": "^4.0.0" + } +} diff --git a/internal/dispatch/cf/workersrc/src/index.test.ts b/internal/dispatch/cf/workersrc/src/index.test.ts new file mode 100644 index 000000000..686881bb7 --- /dev/null +++ b/internal/dispatch/cf/workersrc/src/index.test.ts @@ -0,0 +1,32 @@ +// Smoke tests for the CF Worker mint bridge. +// +// These tests run inside @cloudflare/vitest-pool-workers (Miniflare) +// with the real Go WASM binary. They verify that the bridge boots — +// not full mint OIDC coverage. Run after `make wasm-stage` so that +// mintcore.wasm and wasm_exec.js are present. +import { SELF } from "cloudflare:test"; +import { describe, expect, it } from "vitest"; + +describe("mint worker bridge smoke", () => { + it("boots and serves /health", async () => { + const resp = await SELF.fetch("https://worker.test/health"); + expect(resp.status).toBe(200); + + const body = await resp.text(); + expect(body).toContain("ok"); + }); + + it("returns 404 for unknown paths", async () => { + const resp = await SELF.fetch("https://worker.test/nonexistent"); + // Go's ServeHTTP routes this; unmatched paths return 404. + expect(resp.status).toBe(404); + }); + + it("returns 405 for non-POST on /v1/token", async () => { + const resp = await SELF.fetch("https://worker.test/v1/token", { + method: "GET", + }); + // The mint handler rejects non-POST on the token endpoint. + expect(resp.status).toBe(405); + }); +}); diff --git a/internal/dispatch/cf/workersrc/src/index.ts b/internal/dispatch/cf/workersrc/src/index.ts new file mode 100644 index 000000000..d5dd2e563 --- /dev/null +++ b/internal/dispatch/cf/workersrc/src/index.ts @@ -0,0 +1,632 @@ +// Thin Cloudflare Worker adapter for the fullsend token mint. +// +// All mint logic (OIDC verification, claims validation, JWT signing, +// token minting, path routing) stays in Go via the mintcore WASM module. +// This adapter handles I/O only: +// - Worker secrets -> PEM key access (via pemCallback) +// - Worker env vars -> mint configuration (via configJSON) +// - Host fetch -> outbound HTTP (via fetchCallback) +// - Fetch Request/Response mapping for mintcoreHandleFetch +// +// The WASM module is compiled from cmd/mint-wasm with +// GOOS=js GOARCH=wasm and registers two global functions via syscall/js: +// +// - mintcoreInitMint(configJSON, fetchCallback, pemCallback): string +// Initializes the mint handler with explicit config and host callbacks. +// Returns "" on success or an error message on failure. +// +// - mintcoreHandleFetch(method, url, headersJSON, body): Promise<{status, headers, body}> +// Routes a Fetch request through Go's http.Handler (ServeHTTP). +// Authorization is passed inside headersJSON, not as a separate argument. +// Returns a Promise resolving to {status: number, headers: string, body: string} +// where headers is a JSON-encoded map. +// +// wasm_exec.js is the Go WASM support file from the Go toolchain +// ($(go env GOROOT)/lib/wasm/wasm_exec.js for Go ≥1.24). It must be +// copied into this directory at build time. The Go class it exports +// bootstraps the Go runtime and provides the import object required +// by the WASM binary. +import "../wasm_exec.js"; + +// ES module import of the compiled WASM binary. Wrangler handles this +// via the [[rules]] CompiledWasm glob — no [wasm_modules] binding needed. +// The binary is built from cmd/mint-wasm and staged into this directory +// by `make wasm-stage`. +import mintcoreWasm from "../mintcore.wasm"; + +/** + * Worker environment bindings. + * + * PEM secrets follow the naming convention _APP_PEM + * (e.g. CODER_APP_PEM, TRIAGE_APP_PEM). The Go WASM bridge handles + * role-to-secret-name mapping (PemSecretRole); the JS callback just + * looks up the secret name it receives from Go. + */ +export interface Env { + /** JSON map of role -> GitHub App ID. */ + ROLE_APP_IDS: string; + /** Comma-separated list of allowed GitHub orgs. */ + ALLOWED_ORGS: string; + /** Expected OIDC audience claim value. */ + OIDC_AUDIENCE: string; + /** Comma-separated list of allowed roles (derived from ROLE_APP_IDS if unset). */ + ALLOWED_ROLES?: string; + /** Comma-separated workflow file patterns (empty = reject all; "*" = any). */ + ALLOWED_WORKFLOW_FILES?: string; + /** Comma-separated repos using per-repo WIF providers. */ + PER_REPO_WIF_REPOS?: string; + /** JSON-encoded map of custom role permissions. */ + CUSTOM_ROLE_PERMISSIONS?: string; + + /** + * Dynamic secret access: Worker secrets are accessed by name. + * PEM keys are stored as secrets named _APP_PEM. + * TypeScript index signature covers these dynamic keys. + */ + [key: string]: unknown; +} + +/** + * Deterministic configuration error — thrown when required Worker env + * fields are missing or empty. Unlike transient WASM errors, a config + * error will not resolve on retry (the env doesn't change between + * requests), so GoWasm.init() caches the rejection to avoid + * re-running WebAssembly.instantiate + go.run on every request. + */ +class ConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "ConfigError"; + } +} + +/** + * Validate that all required Worker environment bindings are present + * and non-empty. Call this before any WASM work so that persistent + * misconfig fails fast with a clear message instead of burning + * cycles on WebAssembly.instantiate + go.run. + * + * Throws ConfigError listing every missing field (not just the first). + */ +function validateEnv(env: Env): void { + const required: Array<{ key: keyof Env; label: string }> = [ + { key: "ROLE_APP_IDS", label: "ROLE_APP_IDS" }, + { key: "ALLOWED_ORGS", label: "ALLOWED_ORGS" }, + { key: "OIDC_AUDIENCE", label: "OIDC_AUDIENCE" }, + ]; + const missing = required.filter((f) => { + const v = env[f.key]; + return typeof v !== "string" || v === ""; + }); + if (missing.length > 0) { + const names = missing.map((f) => f.label).join(", "); + throw new ConfigError( + `missing required Worker env: ${names}`, + ); + } +} + +/** + * Detect role names that collide after hyphen→underscore normalization. + * + * Cloudflare Worker secret names must be valid JS identifiers (no + * hyphens), so createPemCallback maps hyphens to underscores when + * constructing the secret key (e.g. "my-role" → MY_ROLE_APP_PEM). + * If two distinct role names normalize to the same secret key + * (e.g. "my-role" and "my_role" both → MY_ROLE_APP_PEM), the PEM + * lookup becomes ambiguous. Fail fast with a clear ConfigError so + * operators fix wrangler secret naming before requests start failing. + * + * Throws ConfigError listing every colliding pair. + */ +function detectRoleSecretCollisions(roleAppIDs: Record): void { + const normalized = new Map(); // normalized → original + const collisions: string[] = []; + for (const role of Object.keys(roleAppIDs)) { + const key = role.replace(/-/g, "_").toUpperCase(); + const existing = normalized.get(key); + if (existing !== undefined && existing !== role) { + collisions.push(`"${existing}" and "${role}" both map to secret ${key}_APP_PEM`); + } else { + normalized.set(key, role); + } + } + if (collisions.length > 0) { + throw new ConfigError( + `role name secret collision after hyphen→underscore normalization: ${collisions.join("; ")}`, + ); + } +} + +/** + * Build the WASM configuration from Worker environment bindings. + * Returns a JSON string matching the mintcore.WorkerConfig struct. + * Field names use PascalCase to match Go's default JSON encoding + * (the struct has no json tags). + * + * AllowedWorkflowFiles defaults to "" (empty) when the env var is + * absent or blank. Go's SplitCSV("") produces an empty allowlist, + * which is fail-closed — matching the standalone mint (cmd/mint). + * Operators must set the env var explicitly to allow workflow files. + */ +function buildWasmConfig(env: Env): string { + return JSON.stringify({ + RoleAppIDs: env.ROLE_APP_IDS, + AllowedOrgs: env.ALLOWED_ORGS, + OIDCAudience: env.OIDC_AUDIENCE, + AllowedRoles: env.ALLOWED_ROLES ?? "", + AllowedWorkflowFiles: env.ALLOWED_WORKFLOW_FILES ?? "", + PerRepoWIFRepos: env.PER_REPO_WIF_REPOS ?? "", + CustomRolePermissions: env.CUSTOM_ROLE_PERMISSIONS ?? "", + }); +} + +/** + * Create a PEM accessor callback for the WASM module. + * + * The Go side (HostPEMAccessor.AccessPEM) calls PemSecretRole(role) + * to map role names (e.g. "fix" -> "coder") and then invokes this + * callback with the mapped secret role name. This callback converts + * that to the Worker secret key format (_APP_PEM) and looks it + * up in the env bindings. + * + * Must return a Promise because Go calls awaitPromise on the + * result. + */ +function createPemCallback( + env: Env, +): (secretRole: string) => Promise { + // Note: secretRole is pre-validated by the Go side (ValidateRoleName in + // pem_js.go) before the JS callback is invoked. Only lowercase + // alphanumeric names with hyphens/underscores (no double-hyphens) + // reach this callback, so toUpperCase() is safe for secret key construction. + // + // Cloudflare Worker secret/binding names must be valid JS identifiers + // (no hyphens). Go's RolePattern allows hyphens in role names, so we + // map them to underscores when constructing the secret key. Operators + // must name their CF secrets with underscores (e.g. role "my-role" + // → secret MY_ROLE_APP_PEM). + return (secretRole: string): Promise => { + const secretName = `${secretRole.replace(/-/g, "_").toUpperCase()}_APP_PEM`; + const pem = env[secretName]; + if (typeof pem !== "string" || pem === "") { + // Reject with a plain string — not new Error(...) — so that Go's + // awaitPromise + Value.String() sees the message directly instead + // of the opaque "[object Error]". + return Promise.reject(`PEM secret ${secretName} not found or empty`); + } + return Promise.resolve(pem); + }; +} + +/** + * Create a fetch callback for the WASM module. + * + * The Go side (HostFetchDoer.Do) calls this with + * (method, url, headersJSON, body) and expects a Promise resolving + * to {status: number, headers: string, body: string} where headers + * is a JSON-encoded map of response headers. + */ +function createFetchCallback(): ( + method: string, + url: string, + headersJSON: string, + body: string, +) => Promise<{ status: number; headers: string; body: string }> { + return async ( + method: string, + url: string, + headersJSON: string, + body: string, + ): Promise<{ status: number; headers: string; body: string }> => { + // Wrap the entire callback body so that any thrown Error (from + // JSON.parse, fetch, or resp.text) is converted to a plain-string + // rejection. Go's awaitPromise + Value.String() on an Error object + // yields "[object Error]"; a plain string is observable directly. + try { + let headers: Record; + try { + headers = JSON.parse(headersJSON); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return Promise.reject(`failed to parse fetch headers JSON: ${msg}`); + } + const resp = await fetch(url, { + method, + headers, + body: method !== "GET" && method !== "HEAD" ? body : undefined, + }); + const respBody = await resp.text(); + const respHeaders = JSON.stringify( + Object.fromEntries(resp.headers.entries()), + ); + return { + status: resp.status, + headers: respHeaders, + body: respBody, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return Promise.reject(`fetch callback failed: ${msg}`); + } + }; +} + +/** + * Self-imposed wall-clock latency budget (ms) for mintcoreHandleFetch. + * + * This guards against hung I/O (e.g. a stalled outbound fetch) or a + * wedged Go cooperative scheduler — scenarios where the handler never + * resolves. 25 s is wall-clock time, not CPU time (Workers CPU limits + * exclude time spent in `await`), so it is not derived from the + * platform CPU cap. + * + * On timeout the GoWasm singleton is marked poisoned (see fetch + * handler). All subsequent requests receive 503 until the Workers + * runtime recycles the isolate and boots a fresh instance. We do + * NOT recreate the GoWasm wrapper because (a) the old Go runtime + * cannot be terminated and would leak, and (b) `mintcoreInitMint` + * / `mintcoreHandleFetch` are registered on isolate-wide + * `globalThis`, so a late finish from the timed-out instance could + * overwrite the new exports and corrupt state. + */ +const HANDLE_FETCH_TIMEOUT_MS = 25_000; + +/** + * GoWasm manages the lifecycle of the Go WASM runtime. + * + * Architecture note — single shared instance per warm isolate: + * Cloudflare Workers reuse a single V8 isolate across sequential + * requests for the same Worker. Because Go's WASM target + * (GOOS=js GOARCH=wasm) starts a single cooperative runtime via + * `go.run()`, there is exactly one Go WASM instance per warm + * isolate. Concurrent requests (possible during `await` points) + * share the cooperative `GOOS=js` scheduler within that instance. + * Idle isolates may be evicted or have their timers throttled by + * the Workers runtime. + * + * Recovery strategy — poison-on-timeout: + * If the Go scheduler stalls or a request times out + * (HANDLE_FETCH_TIMEOUT_MS), the GoWasm instance is marked + * poisoned. All subsequent requests receive 503 until the + * Workers runtime recycles the isolate and boots a fresh + * instance. Recreating the GoWasm wrapper is unsafe because: + * (a) The old Go runtime cannot be terminated — it would + * leak a blocked goroutine and its memory. + * (b) `mintcoreInitMint` / `mintcoreHandleFetch` are + * registered on isolate-wide `globalThis` via + * `syscall/js`. A late finish from the timed-out Go + * instance could overwrite the new instance's exports. + * The module-scope `goWasm` is declared `const` to enforce + * this — no code path may replace the singleton. + * + * The standard Go WASM target (GOOS=js GOARCH=wasm) requires the + * wasm_exec.js support code to bootstrap the Go runtime. The Go class + * from wasm_exec.js provides the import object that satisfies the + * WASM binary's host imports (gojs.*, syscall/js bridges). + * + * The WASM bridge (cmd/mint-wasm) registers two functions on globalThis: + * + * - mintcoreInitMint(configJSON, fetchCallback, pemCallback): string + * - mintcoreHandleFetch(method, url, headersJSON, body): Promise<{status, headers, body}> + */ +class GoWasm { + private initPromise: Promise | null = null; + private _poisoned = false; + + /** + * Whether this instance has been poisoned after a timeout. Once + * poisoned, all calls to init() and handleFetch() throw immediately. + * The Workers runtime must recycle the isolate to recover. + */ + get poisoned(): boolean { + return this._poisoned; + } + + /** + * Mark this instance as poisoned. Called when handleFetch times out + * to prevent reuse of a potentially corrupted Go runtime. Clears the + * cached initPromise so we don't hold references to the old WASM + * instance's closure chain. + */ + markPoisoned(): void { + this._poisoned = true; + this.initPromise = null; + } + + /** + * Initialize the Go WASM runtime with the given module and env. + * Idempotent and concurrency-safe — concurrent callers share the + * same initialization Promise. + * + * Config errors (missing required env) are deterministic: the env + * won't change between requests, so the rejection is cached to + * prevent re-running expensive WASM instantiation on every request. + * + * Transient errors (WASM load failures, runtime panics) clear the + * cached promise so a subsequent request can retry. + */ + async init(wasmModule: WebAssembly.Module, env: Env): Promise { + if (this._poisoned) { + throw new Error( + "GoWasm instance poisoned after timeout — isolate must be recycled", + ); + } + if (!this.initPromise) { + this.initPromise = this.doInit(wasmModule, env).catch((err) => { + // Only allow retry for non-config errors. Config errors are + // deterministic — retrying won't help until the env changes. + if (!(err instanceof ConfigError)) { + this.initPromise = null; + } + throw err; + }); + } + return this.initPromise; + } + + /** + * Internal init implementation. Called exactly once via the + * Promise guard in init(). + */ + private async doInit( + wasmModule: WebAssembly.Module, + env: Env, + ): Promise { + // Validate required env fields before any WASM work. Missing + // required config is deterministic — fail fast with a clear + // message instead of burning cycles on instantiate + go.run. + validateEnv(env); + + // Validate ROLE_APP_IDS is a non-null JSON object before passing + // to Go. Detect role names that would collide after + // hyphen→underscore normalization (e.g. "my-role" and "my_role" + // → same CF secret). Must happen before WASM init so operators + // get a clear error. + try { + const parsed: unknown = JSON.parse(env.ROLE_APP_IDS); + // JSON.parse("null") returns null, JSON.parse("[]") returns + // an array — both pass typeof === "object" but are not valid + // role-app-ID maps. Reject anything that isn't a plain object. + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new ConfigError( + `ROLE_APP_IDS must be a JSON object (got ${parsed === null ? "null" : Array.isArray(parsed) ? "array" : typeof parsed})`, + ); + } + const roleAppIDs = parsed as Record; + detectRoleSecretCollisions(roleAppIDs); + } catch (err) { + if (err instanceof ConfigError) { + throw err; + } + // JSON parse failure will be caught by Go's mintcoreInitMint; + // don't mask it with a less-specific error here. + } + + // The Go class from wasm_exec.js bootstraps the Go runtime and + // provides the import object required by the WASM binary. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const go = new (globalThis as any).Go(); + + // Instantiate with the Go-provided import object so that host + // imports (gojs.*, syscall/js bridges) are satisfied. + const instance = await WebAssembly.instantiate( + wasmModule, + go.importObject, + ); + + // Run the Go main function. The Go WASM bridge registers its + // handler functions on globalThis and blocks on a channel, keeping + // the instance alive. We do not await go.run() — it resolves only + // when the Go program exits (which it shouldn't for a server). + // Attach a .catch() so that Go runtime panics surface as logged + // errors instead of silent unhandled promise rejections. + go.run(instance).catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + console.error("Go WASM runtime error:", msg); + }); + + // Initialize the mint handler with config and I/O callbacks. + // Signature: mintcoreInitMint(configJSON, fetchCallback, pemCallback) + const configJSON = buildWasmConfig(env); + const fetchCallback = createFetchCallback(); + const pemCallback = createPemCallback(env); + + const mintcoreInitMint = (globalThis as Record)[ + "mintcoreInitMint" + ] as + | (( + config: string, + fetch: unknown, + pem: unknown, + ) => string) + | undefined; + + if (typeof mintcoreInitMint !== "function") { + throw new Error( + "mintcoreInitMint not registered — WASM bridge may not be loaded", + ); + } + + const initErr = mintcoreInitMint(configJSON, fetchCallback, pemCallback); + if (initErr) { + // Go-returned init errors are deterministic for the same env + // (bad config, invalid role-app-ID JSON, etc.). Classify as + // ConfigError so GoWasm.init() caches the rejection and does + // not re-run WebAssembly.instantiate + go.run on every request, + // which would leak a blocked Go main goroutine each time. + throw new ConfigError(`mintcore init failed: ${initErr}`); + } + } + + /** + * Forward a Fetch request to the WASM mint handler via + * mintcoreHandleFetch(method, url, headersJSON, body). + * + * Go's ServeHTTP handles all path routing, authentication, and + * response generation. The JS side only maps between Fetch + * Request/Response and the four HandleFetch arguments. + * + * The call is wrapped in a timeout (HANDLE_FETCH_TIMEOUT_MS) so + * that a stalled Go cooperative scheduler surfaces as a clean + * error instead of hanging the request indefinitely. + * + * Returns {status: number, headers: string (JSON), body: string}. + */ + async handleFetch( + method: string, + url: string, + headersJSON: string, + body: string, + ): Promise<{ status: number; headers: string; body: string }> { + if (this._poisoned) { + throw new Error( + "GoWasm instance poisoned after timeout — isolate must be recycled", + ); + } + + const mintcoreHandleFetch = (globalThis as Record)[ + "mintcoreHandleFetch" + ] as + | (( + method: string, + url: string, + headersJSON: string, + body: string, + ) => Promise<{ status: number; headers: string; body: string }>) + | undefined; + + if (typeof mintcoreHandleFetch !== "function") { + throw new Error( + "mintcoreHandleFetch not registered — WASM bridge may not be loaded", + ); + } + + const result = mintcoreHandleFetch(method, url, headersJSON, body); + // clearTimeout on settle prevents a leaked timer from firing a + // spurious unhandled rejection after the request completes. + let timeoutId!: ReturnType; + const timeout = new Promise((_resolve, reject) => { + timeoutId = setTimeout(() => { + reject( + new Error( + `mintcoreHandleFetch timed out after ${HANDLE_FETCH_TIMEOUT_MS}ms`, + ), + ); + }, HANDLE_FETCH_TIMEOUT_MS); + }); + return Promise.race([result, timeout]).finally(() => + clearTimeout(timeoutId), + ); + } +} + +// Module-scoped singleton: one Go WASM instance per warm Worker isolate. +// See GoWasm class comment for the architectural rationale. +// Declared `const`: the singleton is never replaced. On timeout, the +// instance is poisoned in place — see markPoisoned() and the recovery +// strategy comment on the GoWasm class. +const goWasm = new GoWasm(); + +/** + * Return a JSON error response. + */ +function errorResponse(status: number, message: string): Response { + return new Response(JSON.stringify({ error: message }), { + status, + headers: { + "content-type": "application/json", + "cache-control": "no-store", + }, + }); +} + +export default { + async fetch( + request: Request, + env: Env, + _ctx: ExecutionContext, + ): Promise { + // Poisoned after a prior timeout — the Go runtime may be wedged and + // cannot be terminated. Refuse all requests until the Workers + // runtime recycles the isolate and boots a fresh instance. + if (goWasm.poisoned) { + return errorResponse( + 503, + "mint instance poisoned after timeout — awaiting isolate recycle", + ); + } + + try { + await goWasm.init(mintcoreWasm, env); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error("WASM init failed:", msg); + return errorResponse(500, "mint initialization failed"); + } + + // Extract Fetch Request into the four mintcoreHandleFetch arguments. + // Go's ServeHTTP handles all path routing — the JS side passes + // every request through, including unknown paths (Go returns 404). + const headersObj: Record = {}; + request.headers.forEach((value, key) => { + headersObj[key] = value; + }); + const headersJSON = JSON.stringify(headersObj); + + let body = ""; + if (request.method !== "GET" && request.method !== "HEAD") { + body = await request.text(); + } + + try { + const result = await goWasm.handleFetch( + request.method, + request.url, + headersJSON, + body, + ); + + // Parse response headers from JSON string. + const respHeaders = new Headers(); + if (result.headers && result.headers !== "{}") { + try { + const parsed: Record = JSON.parse(result.headers); + for (const [key, value] of Object.entries(parsed)) { + respHeaders.set(key, value); + } + } catch (err) { + // Log and continue without response headers. + const msg = err instanceof Error ? err.message : String(err); + console.error("failed to parse response headers JSON:", msg); + } + } + + return new Response(result.body, { + status: result.status, + headers: respHeaders, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error("Request handling failed:", msg); + + // If the handler timed out, the Go WASM runtime may be wedged + // (stalled scheduler, hung I/O). Poison the singleton so all + // subsequent requests receive 503 until the Workers runtime + // recycles the isolate. We do NOT recreate GoWasm because: + // (a) The old Go runtime cannot be terminated — it leaks. + // (b) globalThis-registered exports (mintcoreInitMint, + // mintcoreHandleFetch) could be overwritten by a late + // finish from the timed-out instance. + if (msg.includes("timed out")) { + console.error( + "Poisoning GoWasm instance after timeout — " + + "isolate must be recycled to recover", + ); + goWasm.markPoisoned(); + } + + return errorResponse(500, "internal error"); + } + }, +}; diff --git a/internal/dispatch/cf/workersrc/tsconfig.json b/internal/dispatch/cf/workersrc/tsconfig.json new file mode 100644 index 000000000..1b6e565a9 --- /dev/null +++ b/internal/dispatch/cf/workersrc/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true + }, + "include": ["src/**/*.ts", "*.d.ts"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/internal/dispatch/cf/workersrc/tsconfig.test.json b/internal/dispatch/cf/workersrc/tsconfig.test.json new file mode 100644 index 000000000..cc17ed772 --- /dev/null +++ b/internal/dispatch/cf/workersrc/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["@cloudflare/workers-types", "@cloudflare/vitest-pool-workers"] + }, + "include": ["src/**/*.ts", "*.d.ts"], + "exclude": [] +} diff --git a/internal/dispatch/cf/workersrc/vitest.config.ts b/internal/dispatch/cf/workersrc/vitest.config.ts new file mode 100644 index 000000000..4dc16e9c1 --- /dev/null +++ b/internal/dispatch/cf/workersrc/vitest.config.ts @@ -0,0 +1,30 @@ +import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"; + +export default defineWorkersConfig({ + test: { + poolOptions: { + workers: { + wrangler: { configPath: "./wrangler.toml" }, + miniflare: { + // Minimal env bindings for smoke testing. These satisfy + // validateEnv() and mintcoreInitMint() so the WASM bridge + // can boot. "coder" is a canonical mintcore role — using a + // non-canonical name (e.g. "test") causes mintcoreInitMint + // to fail because HasRole() rejects unknown roles, and the + // ConfigError is cached permanently. + // PEM secrets are not needed for the /health and routing tests. + // + // ALLOWED_WORKFLOW_FILES is set explicitly here (not via a + // production default). Production code defaults to "" (fail- + // closed) when the env var is absent — matching cmd/mint. + bindings: { + ROLE_APP_IDS: '{"coder":"12345"}', + ALLOWED_ORGS: "test-org", + OIDC_AUDIENCE: "test-aud", + ALLOWED_WORKFLOW_FILES: "*", + }, + }, + }, + }, + }, +}); diff --git a/internal/dispatch/cf/workersrc/wasm.d.ts b/internal/dispatch/cf/workersrc/wasm.d.ts new file mode 100644 index 000000000..b3ec22055 --- /dev/null +++ b/internal/dispatch/cf/workersrc/wasm.d.ts @@ -0,0 +1,6 @@ +// Type declarations for WASM module imports (ES module format). +// Wrangler treats .wasm imports as CompiledWasm modules via [[rules]]. +declare module "*.wasm" { + const module: WebAssembly.Module; + export default module; +} diff --git a/internal/dispatch/cf/workersrc/wasm_exec.d.ts b/internal/dispatch/cf/workersrc/wasm_exec.d.ts new file mode 100644 index 000000000..bb9f7311f --- /dev/null +++ b/internal/dispatch/cf/workersrc/wasm_exec.d.ts @@ -0,0 +1,14 @@ +// Type declarations for the Go WASM support file (wasm_exec.js). +// +// wasm_exec.js is copied from the Go toolchain at build time: +// cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" . +// (Go ≥1.24 moved wasm_exec.js from misc/wasm/ to lib/wasm/.) +// +// It registers a Go class on globalThis that bootstraps the Go +// runtime and provides the import object required by WASM binaries +// compiled with GOOS=js GOARCH=wasm. + +declare class Go { + importObject: WebAssembly.Imports; + run(instance: WebAssembly.Instance): Promise; +} diff --git a/internal/dispatch/cf/workersrc/wrangler.toml b/internal/dispatch/cf/workersrc/wrangler.toml new file mode 100644 index 000000000..c5b89cbe1 --- /dev/null +++ b/internal/dispatch/cf/workersrc/wrangler.toml @@ -0,0 +1,49 @@ +# Wrangler configuration for the fullsend mint Cloudflare Worker. +# +# This config targets the mint-test Worker deployment. Production +# deploys will use the CLI embed path once fullsend mint deploy +# supports --mode cf (#5345). +# +# The Worker name follows the convention: +# -mint-test.fullsend-ai.workers.dev +# Set the name at deploy time via --name or WRANGLER_NAME env var. + +name = "fullsend-mint-test" +main = "src/index.ts" +compatibility_date = "2026-04-09" + +# Workers Free plan: 3 MB compressed limit. +# Workers Paid plan: 10 MB compressed limit. +# The mintcore WASM binary must fit within these limits. + +[observability] +enabled = true + +# Environment variables set at deploy time by the CLI provisioner. +# Listed here for documentation; actual values come from Worker secrets +# and environment variable bindings configured during deployment. +# +# [vars] +# ROLE_APP_IDS = "" # JSON map: role → GitHub App ID +# ALLOWED_ORGS = "" # Comma-separated org names +# OIDC_AUDIENCE = "" # Expected OIDC audience claim +# ALLOWED_ROLES = "" # Comma-separated role names +# ALLOWED_WORKFLOW_FILES = "" # Comma-separated workflow file patterns (unset = fail-closed) + +# Role PEM keys are stored as Worker secrets (encrypted at rest). +# Secret names follow the convention: _APP_PEM +# e.g. CODER_APP_PEM, TRIAGE_APP_PEM, REVIEW_APP_PEM +# +# CF secret names must be valid JS identifiers (no hyphens). Roles +# with hyphens in Go (e.g. "my-role") map to underscore-based +# secret names (MY_ROLE_APP_PEM). See createPemCallback in index.ts. +# +# Set via: wrangler secret put _APP_PEM + +# The mintcore WASM binary is imported directly as an ES module in +# src/index.ts (not via [wasm_modules], which is incompatible with ES +# module format). The binary is compiled from cmd/mint-wasm with +# GOOS=js GOARCH=wasm and staged into this directory by `make wasm-stage`. +[[rules]] +type = "CompiledWasm" +globs = ["**/*.wasm"]