From 59f6d2977721d2fc908f09f0c44f2371b11ac6d1 Mon Sep 17 00:00:00 2001 From: Peter Date: Thu, 24 Sep 2026 22:45:26 -1000 Subject: [PATCH 1/2] fix(cloudflare/hyperdrive): only contribute the dev origin to local hosts `ConnectBinding` (and the async-Worker `env` path in `WorkerAsyncBindings`) always attached the `hyperdrives` dev-origin record to the host's bind data. That record is an `Output.map` that throws for an Access-protected origin with no `dev` override, and Apply evaluates every binding before reconciling the host, so `alchemy deploy` failed with "...not supported in development mode..." even though the live Worker provider never reads `hyperdrives`. The record is now contributed only when the host runs locally (`host.Mode ?? defaultProviderMode` is "local"), the same resolution the planner uses and the same gate `bindWorkerAsyncBindings` already applies to Access enrollment and `maybeQueueShim` to the queue shim. Fixes #1836 Co-Authored-By: Claude Opus 5.5 --- .../Cloudflare/Hyperdrive/ConnectBinding.ts | 20 ++- .../Cloudflare/Workers/WorkerAsyncBindings.ts | 4 +- .../Hyperdrive/ConnectBinding.test.ts | 162 ++++++++++++++++++ 3 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 packages/alchemy/test/Cloudflare/Hyperdrive/ConnectBinding.test.ts diff --git a/packages/alchemy/src/Cloudflare/Hyperdrive/ConnectBinding.ts b/packages/alchemy/src/Cloudflare/Hyperdrive/ConnectBinding.ts index 36fe9f9477..4899f3d56a 100644 --- a/packages/alchemy/src/Cloudflare/Hyperdrive/ConnectBinding.ts +++ b/packages/alchemy/src/Cloudflare/Hyperdrive/ConnectBinding.ts @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; import * as Output from "../../Output.ts"; +import { defaultProviderMode, type ProviderMode } from "../../ProviderMode.ts"; import { Worker, WorkerEnvironment } from "../Workers/Worker.ts"; import { Connect, type ConnectClient } from "./Connect.ts"; import type { Connection } from "./Connection.ts"; @@ -24,7 +25,7 @@ export const ConnectBinding = Layer.effect( id: connection.hyperdriveId as unknown as string, }, ], - hyperdrives: getHyperdriveDevOrigin(connection), + hyperdrives: yield* getHyperdriveDevOriginForHost(connection, host), }); } @@ -48,6 +49,23 @@ export const ConnectBinding = Layer.effect( }), ); +/** + * The `hyperdrives` dev channel for `connection` bound to `host`. Only the + * local worker provider reads it, so it is contributed only when the host + * runs locally — its registration-captured `Mode` (`Alchemy.remote()` → + * `"live"`) or the run default (`alchemy dev` → `"local"`), the same + * resolution the planner applies to the host. A live host (`alchemy deploy`, + * or a `remote()` worker in dev) never evaluates the dev origin, so an + * Access-protected origin without a `dev` override deploys. + */ +export const getHyperdriveDevOriginForHost = Effect.fn(function* ( + connection: Connection, + host: { readonly Mode?: ProviderMode | undefined }, +) { + const mode = host.Mode ?? (yield* defaultProviderMode); + return mode === "local" ? getHyperdriveDevOrigin(connection) : undefined; +}); + export const getHyperdriveDevOrigin = (connection: Connection) => { const origin = Output.map( Output.all(connection.dev, connection.origin, connection.mtls), diff --git a/packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts b/packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts index 782902d5ec..9dd18d19af 100644 --- a/packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts +++ b/packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts @@ -22,7 +22,7 @@ import type { ContainerApplication } from "../Containers/ContainerApplication.ts import { isDatabase } from "../D1/Database.ts"; import { isSendEmail } from "../Email/SendEmail.ts"; import { isApp } from "../Flagship/App.ts"; -import { getHyperdriveDevOrigin } from "../Hyperdrive/ConnectBinding.ts"; +import { getHyperdriveDevOriginForHost } from "../Hyperdrive/ConnectBinding.ts"; import { isHyperdriveConnection } from "../Hyperdrive/Connection.ts"; import { isImages } from "../Images/Images.ts"; import { isNamespace as isKVNamespace } from "../KV/Namespace.ts"; @@ -285,7 +285,7 @@ export const bindWorkerAsyncBindings = Effect.fn(function* ( yield* resource.bind`${bindingName}`({ bindings: [resolvedBindingMeta], hyperdrives: isHyperdriveConnection(binding) - ? getHyperdriveDevOrigin(binding) + ? yield* getHyperdriveDevOriginForHost(binding, resource) : undefined, // Dev-only local-emulation opt-out channel (like `hyperdrives`): // worker-only bindings and `SendEmail` descriptors piped through diff --git a/packages/alchemy/test/Cloudflare/Hyperdrive/ConnectBinding.test.ts b/packages/alchemy/test/Cloudflare/Hyperdrive/ConnectBinding.test.ts new file mode 100644 index 0000000000..96a1bd330c --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Hyperdrive/ConnectBinding.test.ts @@ -0,0 +1,162 @@ +import * as Cloudflare from "@/Cloudflare/index.ts"; +import type { Connection } from "@/Cloudflare/Hyperdrive/Connection.ts"; +import { Worker, WorkerEnvironment } from "@/Cloudflare/Workers/Worker.ts"; +import * as Output from "@/Output"; +import { remote, type ProviderMode } from "@/ProviderMode.ts"; +import * as Stack from "@/Stack"; +import { Stage } from "@/Stage"; +import { InMemoryService, State } from "@/State"; +import * as Test from "@/Test/Alchemy"; +import { expect } from "alchemy-test"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import { inDev } from "../../test.resources.ts"; + +const { test } = Test.make({ providers: Cloudflare.providers() }); + +const tags = ["unit", "provider:cloudflare:hyperdrive"]; + +/** The slice of the host Worker that `ConnectBinding` touches at bind time. */ +interface RecordingHost { + readonly Mode: ProviderMode | undefined; + readonly bind: ( + template: TemplateStringsArray, + ...args: unknown[] + ) => (data: unknown) => Effect.Effect; +} + +// Worker resolves its host through the resource's Self key. +const WorkerHost = Context.Service(Worker.Self.key); + +const accessOrigin = { + scheme: "postgres" as const, + host: "db.internal.example.com", + database: "app", + user: "app", + password: Redacted.make("password"), + accessClientId: Redacted.make("client-id"), + accessClientSecret: Redacted.make("client-secret"), +}; + +const devOriginError = + /Hyperdrive instance Db has an origin that requires Cloudflare Access\. This is not supported in development mode/; + +/** + * Register an Access-protected Hyperdrive (no `dev` origin), let `bind` + * attach it to a host, then resolve the bind data the way Apply does right + * before the host reconciles (`Output.evaluate(node.bindings, outputs)`). + */ +const resolveBindData = ( + bind: (connection: Connection) => Effect.Effect, +) => + Effect.gen(function* () { + const connection = yield* Cloudflare.Hyperdrive.Connection("Db", { + origin: accessOrigin, + }); + const data = yield* bind(connection); + return yield* Output.evaluate(data, { + [connection.FQN]: { + hyperdriveId: "hyperdrive-id", + name: "db", + accountId: "account-id", + origin: accessOrigin, + mtls: {}, + dev: undefined, + }, + }); + }).pipe( + Stack.make({ + name: "HyperdriveConnectBinding", + providers: Cloudflare.providers(), + state: Layer.effect( + State, + Effect.sync(() => InMemoryService({})), + ), + }), + Effect.map((stack) => stack.output), + Effect.provideService(Stage, "test"), + Effect.scoped, + ); + +/** Effect-native Worker path: `Cloudflare.Hyperdrive.Connect(connection)`. */ +const viaConnect = (hostMode: ProviderMode | undefined) => + resolveBindData((connection) => + Effect.gen(function* () { + const recorded: unknown[] = []; + const host: RecordingHost = { + Mode: hostMode, + bind: () => (data) => Effect.sync(() => void recorded.push(data)), + }; + const connect = yield* Cloudflare.Hyperdrive.Connect.pipe( + Effect.provide(Cloudflare.Hyperdrive.ConnectBinding), + Effect.provideService(WorkerHost, host), + Effect.provideService(WorkerEnvironment, {}), + ); + yield* connect(connection); + expect(recorded).toHaveLength(1); + return recorded[0]; + }), + ); + +/** Async Worker path: `env: { DB: connection }`. */ +const viaAsyncEnv = (hostMode: ProviderMode | undefined) => + resolveBindData((connection) => + Effect.gen(function* () { + const worker = yield* Cloudflare.Worker("Api", { + script: `export default { fetch: () => new Response("ok") };`, + env: { DB: connection }, + }).pipe(remote(hostMode === "live")); + const stack = yield* Stack.Stack; + const row = (stack.bindings[worker.FQN] ?? []).find( + (row) => row.sid === "DB", + ); + expect(row).toBeDefined(); + return row?.data; + }), + ); + +const paths = { Connect: viaConnect, "async env": viaAsyncEnv }; + +for (const [path, bindVia] of Object.entries(paths)) { + test( + `${path}: deploy binds an Access-protected origin without \`dev\``, + Effect.gen(function* () { + const data = yield* bindVia(undefined); + expect(data).toMatchObject({ + bindings: [{ type: "hyperdrive", id: "hyperdrive-id" }], + }); + // The dev origin channel is read only by the local worker provider. + expect(data).not.toHaveProperty("hyperdrives.hyperdrive-id"); + }), + { tags }, + ); + + test( + `${path}: a remote() worker in dev binds an Access-protected origin without \`dev\``, + inDev( + Effect.gen(function* () { + const data = yield* bindVia("live"); + expect(data).not.toHaveProperty("hyperdrives.hyperdrive-id"); + }), + ), + { tags }, + ); + + test( + `${path}: a local worker in dev still rejects an Access-protected origin without \`dev\``, + inDev( + Effect.gen(function* () { + const exit = yield* bindVia(undefined).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(Cause.squash(exit.cause))).toMatch(devOriginError); + } + }), + ), + { tags }, + ); +} From c5294bdcd7bdcb715c9ca69ba95fdafbc6c560eb Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Tue, 6 Oct 2026 02:37:24 -0700 Subject: [PATCH 2/2] test(cloudflare/hyperdrive): deploy and query an Access-protected origin end to end Postgres in Docker behind a Cloudflare Tunnel (cloudflared run locally), guarded by a self-hosted Access app admitting one service token. Both binding flavors bind the Connection without a dev override and query through it. Replaces the in-memory ConnectBinding.test.ts. --- .../Hyperdrive/ConnectBinding.test.ts | 162 -------------- .../Cloudflare/Hyperdrive/Hyperdrive.test.ts | 202 ++++++++++++++++++ .../fixtures/access-async-worker.ts | 21 ++ .../fixtures/access-effect-worker.ts | 40 ++++ .../Hyperdrive/fixtures/access-origin.ts | 68 ++++++ 5 files changed, 331 insertions(+), 162 deletions(-) delete mode 100644 packages/alchemy/test/Cloudflare/Hyperdrive/ConnectBinding.test.ts create mode 100644 packages/alchemy/test/Cloudflare/Hyperdrive/fixtures/access-async-worker.ts create mode 100644 packages/alchemy/test/Cloudflare/Hyperdrive/fixtures/access-effect-worker.ts create mode 100644 packages/alchemy/test/Cloudflare/Hyperdrive/fixtures/access-origin.ts diff --git a/packages/alchemy/test/Cloudflare/Hyperdrive/ConnectBinding.test.ts b/packages/alchemy/test/Cloudflare/Hyperdrive/ConnectBinding.test.ts deleted file mode 100644 index 96a1bd330c..0000000000 --- a/packages/alchemy/test/Cloudflare/Hyperdrive/ConnectBinding.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import * as Cloudflare from "@/Cloudflare/index.ts"; -import type { Connection } from "@/Cloudflare/Hyperdrive/Connection.ts"; -import { Worker, WorkerEnvironment } from "@/Cloudflare/Workers/Worker.ts"; -import * as Output from "@/Output"; -import { remote, type ProviderMode } from "@/ProviderMode.ts"; -import * as Stack from "@/Stack"; -import { Stage } from "@/Stage"; -import { InMemoryService, State } from "@/State"; -import * as Test from "@/Test/Alchemy"; -import { expect } from "alchemy-test"; -import * as Cause from "effect/Cause"; -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Layer from "effect/Layer"; -import * as Redacted from "effect/Redacted"; -import { inDev } from "../../test.resources.ts"; - -const { test } = Test.make({ providers: Cloudflare.providers() }); - -const tags = ["unit", "provider:cloudflare:hyperdrive"]; - -/** The slice of the host Worker that `ConnectBinding` touches at bind time. */ -interface RecordingHost { - readonly Mode: ProviderMode | undefined; - readonly bind: ( - template: TemplateStringsArray, - ...args: unknown[] - ) => (data: unknown) => Effect.Effect; -} - -// Worker resolves its host through the resource's Self key. -const WorkerHost = Context.Service(Worker.Self.key); - -const accessOrigin = { - scheme: "postgres" as const, - host: "db.internal.example.com", - database: "app", - user: "app", - password: Redacted.make("password"), - accessClientId: Redacted.make("client-id"), - accessClientSecret: Redacted.make("client-secret"), -}; - -const devOriginError = - /Hyperdrive instance Db has an origin that requires Cloudflare Access\. This is not supported in development mode/; - -/** - * Register an Access-protected Hyperdrive (no `dev` origin), let `bind` - * attach it to a host, then resolve the bind data the way Apply does right - * before the host reconciles (`Output.evaluate(node.bindings, outputs)`). - */ -const resolveBindData = ( - bind: (connection: Connection) => Effect.Effect, -) => - Effect.gen(function* () { - const connection = yield* Cloudflare.Hyperdrive.Connection("Db", { - origin: accessOrigin, - }); - const data = yield* bind(connection); - return yield* Output.evaluate(data, { - [connection.FQN]: { - hyperdriveId: "hyperdrive-id", - name: "db", - accountId: "account-id", - origin: accessOrigin, - mtls: {}, - dev: undefined, - }, - }); - }).pipe( - Stack.make({ - name: "HyperdriveConnectBinding", - providers: Cloudflare.providers(), - state: Layer.effect( - State, - Effect.sync(() => InMemoryService({})), - ), - }), - Effect.map((stack) => stack.output), - Effect.provideService(Stage, "test"), - Effect.scoped, - ); - -/** Effect-native Worker path: `Cloudflare.Hyperdrive.Connect(connection)`. */ -const viaConnect = (hostMode: ProviderMode | undefined) => - resolveBindData((connection) => - Effect.gen(function* () { - const recorded: unknown[] = []; - const host: RecordingHost = { - Mode: hostMode, - bind: () => (data) => Effect.sync(() => void recorded.push(data)), - }; - const connect = yield* Cloudflare.Hyperdrive.Connect.pipe( - Effect.provide(Cloudflare.Hyperdrive.ConnectBinding), - Effect.provideService(WorkerHost, host), - Effect.provideService(WorkerEnvironment, {}), - ); - yield* connect(connection); - expect(recorded).toHaveLength(1); - return recorded[0]; - }), - ); - -/** Async Worker path: `env: { DB: connection }`. */ -const viaAsyncEnv = (hostMode: ProviderMode | undefined) => - resolveBindData((connection) => - Effect.gen(function* () { - const worker = yield* Cloudflare.Worker("Api", { - script: `export default { fetch: () => new Response("ok") };`, - env: { DB: connection }, - }).pipe(remote(hostMode === "live")); - const stack = yield* Stack.Stack; - const row = (stack.bindings[worker.FQN] ?? []).find( - (row) => row.sid === "DB", - ); - expect(row).toBeDefined(); - return row?.data; - }), - ); - -const paths = { Connect: viaConnect, "async env": viaAsyncEnv }; - -for (const [path, bindVia] of Object.entries(paths)) { - test( - `${path}: deploy binds an Access-protected origin without \`dev\``, - Effect.gen(function* () { - const data = yield* bindVia(undefined); - expect(data).toMatchObject({ - bindings: [{ type: "hyperdrive", id: "hyperdrive-id" }], - }); - // The dev origin channel is read only by the local worker provider. - expect(data).not.toHaveProperty("hyperdrives.hyperdrive-id"); - }), - { tags }, - ); - - test( - `${path}: a remote() worker in dev binds an Access-protected origin without \`dev\``, - inDev( - Effect.gen(function* () { - const data = yield* bindVia("live"); - expect(data).not.toHaveProperty("hyperdrives.hyperdrive-id"); - }), - ), - { tags }, - ); - - test( - `${path}: a local worker in dev still rejects an Access-protected origin without \`dev\``, - inDev( - Effect.gen(function* () { - const exit = yield* bindVia(undefined).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(String(Cause.squash(exit.cause))).toMatch(devOriginError); - } - }), - ), - { tags }, - ); -} diff --git a/packages/alchemy/test/Cloudflare/Hyperdrive/Hyperdrive.test.ts b/packages/alchemy/test/Cloudflare/Hyperdrive/Hyperdrive.test.ts index a85e0577ba..955b7109fc 100644 --- a/packages/alchemy/test/Cloudflare/Hyperdrive/Hyperdrive.test.ts +++ b/packages/alchemy/test/Cloudflare/Hyperdrive/Hyperdrive.test.ts @@ -1,15 +1,30 @@ import * as hyperdrive from "@distilled.cloud/cloudflare/hyperdrive"; +import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust"; import { assert, expect } from "alchemy-test"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; +import * as HttpClient from "effect/http/HttpClient"; import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; import { MinimumLogLevel } from "effect/References"; import * as Schedule from "effect/Schedule"; +import * as pathe from "pathe"; import * as Cloudflare from "@/Cloudflare"; import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment"; +import { findZoneByName } from "@/Cloudflare/Zone/lookup"; import * as Neon from "@/Neon"; import * as Provider from "@/Provider"; import * as Test from "@/Test/Alchemy"; +import { waitForWorkerToBeDeleted } from "../Utils/Worker.ts"; +import HyperdriveAccessEffectWorker from "./fixtures/access-effect-worker.ts"; +import { + ACCESS_ORIGIN_HOST, + ACCESS_ORIGIN_PASSWORD, + ACCESS_ORIGIN_PORT, + ACCESS_ORIGIN_ZONE, + AccessOriginConnection, + AccessOriginRoute, +} from "./fixtures/access-origin.ts"; const { test } = Test.make({ providers: Layer.merge(Cloudflare.providers(), Neon.providers()) }); @@ -137,6 +152,193 @@ test.provider( }, ); +// ── Access-protected origin (#1836) ──────────────────────────────────────── +// A real origin behind Cloudflare Access: Postgres in Docker, published +// through a Cloudflare Tunnel whose connector (`cloudflared`) runs here, +// guarded by a self-hosted Access app that admits one service token. Both +// binding flavors bind the Connection with no `dev` override and must +// deploy and query through it. + +const cloudflaredBin = Bun.which("cloudflared"); +const dockerBin = Bun.which("docker"); +const ACCESS_ORIGIN_CONTAINER = "alchemy-test-hyperdrive-access-origin"; + +const run = (cmd: string[]) => + Effect.sync(() => Bun.spawnSync(cmd, { stdout: "pipe", stderr: "pipe" })); + +/** Postgres with TLS (the image's snakeoil cert) on {@link ACCESS_ORIGIN_PORT}. */ +const accessOriginPostgres = Effect.acquireRelease( + Effect.gen(function* () { + yield* run([dockerBin!, "rm", "-f", ACCESS_ORIGIN_CONTAINER]); + const started = yield* run([ + dockerBin!, + "run", + "-d", + "--rm", + "--name", + ACCESS_ORIGIN_CONTAINER, + "-e", + `POSTGRES_PASSWORD=${ACCESS_ORIGIN_PASSWORD}`, + "-p", + `127.0.0.1:${ACCESS_ORIGIN_PORT}:5432`, + "postgres:17", + "-c", + "ssl=on", + "-c", + "ssl_cert_file=/etc/ssl/certs/ssl-cert-snakeoil.pem", + "-c", + "ssl_key_file=/etc/ssl/private/ssl-cert-snakeoil.key", + ]); + if (started.exitCode !== 0) { + return yield* Effect.die(new Error(`docker run failed: ${started.stderr.toString()}`)); + } + yield* run([ + dockerBin!, + "exec", + ACCESS_ORIGIN_CONTAINER, + "pg_isready", + "-h", + "127.0.0.1", + "-U", + "postgres", + ]).pipe( + Effect.repeat({ + schedule: Schedule.spaced("1 second"), + until: (result) => result.exitCode === 0, + times: 30, + }), + ); + }), + () => run([dockerBin!, "rm", "-f", ACCESS_ORIGIN_CONTAINER]), +); + +class TunnelNotHealthy extends Data.TaggedError("TunnelNotHealthy")<{ status: string }> {} +class AccessQueryFailed extends Data.TaggedError("AccessQueryFailed")<{ + status: number; + body: string; +}> {} + +/** Run the tunnel's connector here until the scope closes, then wait for it to register. */ +const accessOriginConnector = (accountId: string, tunnelId: string, token: string) => + Effect.gen(function* () { + yield* Effect.acquireRelease( + Effect.sync(() => + Bun.spawn([cloudflaredBin!, "tunnel", "run", "--token", token], { + stdout: "ignore", + stderr: "ignore", + }), + ), + (proc) => + Effect.promise(async () => { + proc.kill(); + await proc.exited; + }), + ); + yield* zeroTrust.getTunnelCloudflared({ accountId, tunnelId }).pipe( + Effect.flatMap((tunnel) => + tunnel.status === "healthy" + ? Effect.void + : Effect.fail(new TunnelNotHealthy({ status: String(tunnel.status) })), + ), + Effect.retry({ + while: (e) => e._tag === "TunnelNotHealthy", + schedule: Schedule.spaced("2 seconds"), + times: 30, + }), + ); + }); + +const queryThroughAccess = (url: string) => + HttpClient.get(url).pipe( + Effect.flatMap((res) => + res.text.pipe( + Effect.flatMap((body) => + res.status === 200 + ? Effect.succeed(JSON.parse(body) as { via: string }) + : Effect.fail(new AccessQueryFailed({ status: res.status, body })), + ), + ), + ), + Effect.retry({ schedule: Schedule.spaced("3 seconds"), times: 20 }), + ); + +test.provider.skipIf(!cloudflaredBin || !dockerBin)( + "deploys and queries an Access-protected origin without a dev override", + (stack) => + Effect.gen(function* () { + const { accountId } = yield* yield* CloudflareEnvironment; + const zone = yield* findZoneByName({ accountId, name: ACCESS_ORIGIN_ZONE }); + if (!zone) return yield* Effect.die(new Error(`zone ${ACCESS_ORIGIN_ZONE} not found`)); + + yield* stack.destroy(); + + const deployed = yield* Effect.scoped( + Effect.gen(function* () { + yield* accessOriginPostgres; + + // The tunnel must have a live connector before Hyperdrive is + // created: Cloudflare connects to the origin when it creates it. + const route = yield* stack.deploy(AccessOriginRoute(zone.id)); + yield* accessOriginConnector( + accountId, + route.tunnel.tunnelId, + Redacted.value(route.tunnel.token), + ); + + const deployed = yield* stack + .deploy( + Effect.gen(function* () { + yield* AccessOriginRoute(zone.id); + const connection = yield* AccessOriginConnection; + const effectWorker = yield* HyperdriveAccessEffectWorker; + const asyncWorker = yield* Cloudflare.Worker("HyperdriveAccessAsyncWorker", { + main: pathe.resolve(import.meta.dirname, "fixtures/access-async-worker.ts"), + env: { HD: AccessOriginConnection }, + }); + return { connection, effectWorker, asyncWorker }; + }), + ) + .pipe( + // Hyperdrive resolves the origin host when it creates the config, + // and a just-created CNAME can take a little while to answer. + Effect.retry({ schedule: Schedule.spaced("10 seconds"), times: 12 }), + ); + + const actual = yield* hyperdrive.getConfig({ + accountId, + hyperdriveId: deployed.connection.hyperdriveId, + }); + assert("accessClientId" in actual.origin, "origin must be Access-protected"); + expect(actual.origin.host).toEqual(ACCESS_ORIGIN_HOST); + + // Both binding flavors reach Postgres through Access + the tunnel. + expect(yield* queryThroughAccess(deployed.effectWorker.url!)).toEqual({ + via: "through-access", + }); + expect(yield* queryThroughAccess(deployed.asyncWorker.url!)).toEqual({ + via: "through-access", + }); + return deployed; + }), + ); + + yield* stack.destroy(); + yield* waitForConfigToBeDeleted(deployed.connection.hyperdriveId, accountId); + yield* waitForWorkerToBeDeleted(deployed.effectWorker.workerName, accountId); + yield* waitForWorkerToBeDeleted(deployed.asyncWorker.workerName, accountId); + }).pipe(logLevel), + { + tags: [ + "provider:cloudflare", + "provider:cloudflare:hyperdrive", + "provider:cloudflare:tunnel", + "provider:cloudflare:access", + "live", + ], + timeout: 300_000, + }, +); + const waitForConfigToBeDeleted = Effect.fn(function* (hyperdriveId: string, accountId: string) { yield* hyperdrive.getConfig({ accountId, hyperdriveId }).pipe( Effect.flatMap(() => Effect.fail(new ConfigStillExists())), diff --git a/packages/alchemy/test/Cloudflare/Hyperdrive/fixtures/access-async-worker.ts b/packages/alchemy/test/Cloudflare/Hyperdrive/fixtures/access-async-worker.ts new file mode 100644 index 0000000000..6c1d585962 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Hyperdrive/fixtures/access-async-worker.ts @@ -0,0 +1,21 @@ +import type { Hyperdrive } from "@cloudflare/workers-types"; +import { Client } from "pg"; + +/** + * Async Worker that binds the Access-protected Hyperdrive via + * `env: { HD: connection }` and runs a query through it. + */ +export default { + async fetch(_request: Request, env: { HD: Hyperdrive }): Promise { + const client = new Client({ connectionString: env.HD.connectionString }); + try { + await client.connect(); + const result = await client.query("select 'through-access' as via"); + return Response.json(result.rows[0]); + } catch (error) { + return new Response(String(error), { status: 500 }); + } finally { + await client.end().catch(() => {}); + } + }, +}; diff --git a/packages/alchemy/test/Cloudflare/Hyperdrive/fixtures/access-effect-worker.ts b/packages/alchemy/test/Cloudflare/Hyperdrive/fixtures/access-effect-worker.ts new file mode 100644 index 0000000000..7b77e60e25 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Hyperdrive/fixtures/access-effect-worker.ts @@ -0,0 +1,40 @@ +import * as Effect from "effect/Effect"; +import * as HttpServerResponse from "effect/http/HttpServerResponse"; +import * as Redacted from "effect/Redacted"; +import { Client } from "pg"; +import * as Cloudflare from "@/Cloudflare/index.ts"; +import { AccessOriginConnection } from "./access-origin.ts"; + +/** + * Effect Worker that binds the Access-protected Hyperdrive through + * `Cloudflare.Hyperdrive.Connect` and runs a query through it, so a 200 + * proves the whole path: Hyperdrive → Access → Tunnel → Postgres. + */ +export default class HyperdriveAccessEffectWorker extends Cloudflare.Worker()( + "HyperdriveAccessEffectWorker", + { main: import.meta.url }, + Effect.gen(function* () { + const connection = yield* AccessOriginConnection; + const hd = yield* Cloudflare.Hyperdrive.Connect(connection); + return { + fetch: Effect.gen(function* () { + const connectionString = Redacted.value(yield* hd.connectionString); + return yield* Effect.tryPromise(async () => { + const client = new Client({ connectionString }); + await client.connect(); + try { + const result = await client.query("select 'through-access' as via"); + return result.rows[0] as { via: string }; + } finally { + await client.end(); + } + }).pipe( + Effect.flatMap((row) => HttpServerResponse.json(row)), + Effect.catch((error) => + Effect.succeed(HttpServerResponse.text(String(error), { status: 500 })), + ), + ); + }), + }; + }).pipe(Effect.provide(Cloudflare.Hyperdrive.ConnectBinding)), +) {} diff --git a/packages/alchemy/test/Cloudflare/Hyperdrive/fixtures/access-origin.ts b/packages/alchemy/test/Cloudflare/Hyperdrive/fixtures/access-origin.ts new file mode 100644 index 0000000000..b30ae8a968 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Hyperdrive/fixtures/access-origin.ts @@ -0,0 +1,68 @@ +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import * as Cloudflare from "@/Cloudflare/index.ts"; +import * as Output from "@/Output"; + +/** + * A Hyperdrive origin behind Cloudflare Access: a local Postgres reached + * through a Cloudflare Tunnel, published on {@link ACCESS_ORIGIN_HOST} and + * guarded by a self-hosted Access application that admits one service token. + * `cloudflared` runs on the test machine with the tunnel's token (see the + * Access test in `Hyperdrive.test.ts`). + */ +export const ACCESS_ORIGIN_ZONE = "alchemy-test-2.us"; +export const ACCESS_ORIGIN_HOST = `hyperdrive-access-${ + process.env.PULL_REQUEST ?? process.env.USER ?? "local" +}.${ACCESS_ORIGIN_ZONE}`; +export const ACCESS_ORIGIN_PORT = 15432; +export const ACCESS_ORIGIN_PASSWORD = "alchemy-access-origin"; + +/** The service token Access admits; shared by logical id with the route. */ +export const AccessOriginToken = Cloudflare.Access.ServiceToken("HyperdriveAccessToken", {}); + +/** + * Tunnel, proxied CNAME and Access application publishing the local + * Postgres on {@link ACCESS_ORIGIN_HOST}. + */ +export const AccessOriginRoute = (zoneId: string) => + Effect.gen(function* () { + const tunnel = yield* Cloudflare.Tunnel.Tunnel("HyperdriveAccessTunnel", { + ingress: [ + { hostname: ACCESS_ORIGIN_HOST, service: `tcp://localhost:${ACCESS_ORIGIN_PORT}` }, + { service: "http_status:404" }, + ], + }); + yield* Cloudflare.DNS.Record("HyperdriveAccessCname", { + zoneId, + name: ACCESS_ORIGIN_HOST, + type: "CNAME", + content: tunnel.tunnelId.pipe(Output.map((id) => `${id}.cfargotunnel.com`)), + proxied: true, + }); + const token = yield* AccessOriginToken; + yield* Cloudflare.Access.Application("HyperdriveAccessApp", { + type: "self_hosted", + domain: ACCESS_ORIGIN_HOST, + policies: [{ decision: "non_identity", include: [{ serviceToken: token.serviceTokenId }] }], + }); + return { tunnel, token }; + }); + +/** + * The Hyperdrive Connection for the Access-protected origin. It has no `dev` + * override — the shape that used to fail `alchemy deploy` (#1836). + */ +export const AccessOriginConnection = Effect.gen(function* () { + const token = yield* AccessOriginToken; + return yield* Cloudflare.Hyperdrive.Connection("HyperdriveAccessConnection", { + origin: { + scheme: "postgres", + host: ACCESS_ORIGIN_HOST, + database: "postgres", + user: "postgres", + password: Redacted.make(ACCESS_ORIGIN_PASSWORD), + accessClientId: token.clientId.pipe(Output.map(Redacted.make)), + accessClientSecret: token.clientSecret.pipe(Output.map((secret) => secret!)), + }, + }); +});