From 2652a3230d054216a5daaa992e70ad993b880625 Mon Sep 17 00:00:00 2001 From: "Michael (Pear)" Date: Wed, 17 Jun 2026 07:14:35 -0700 Subject: [PATCH] example(cloudflare-worker-auth): use the real @alchemy.run/pr-package library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the self-contained worker with `PrPackage.handler()` and source the bearer token from `PrPackage.AuthTokenValue`, so the example exercises the actual library code path the Redacted bearer-token fix lives in. The integ test drives the real bearer-protected routes (PUT /projects/:pkg/packages, DELETE .../tags/:tag): valid token -> 200, invalid / missing / "" -> 401. Bumps the distilled submodule pin to include the Cloudflare error-class tree-shaking fix (distilled#352) — without it the Worker bundle keeps a dangling `import "@distilled.cloud/cloudflare/flagship"` and Cloudflare rejects the upload with ScriptModuleNotFound. --- bun.lock | 41 +-- distilled | 2 +- .../cloudflare-worker-auth/alchemy.run.ts | 4 +- examples/cloudflare-worker-auth/package.json | 1 + examples/cloudflare-worker-auth/src/Api.ts | 52 +--- .../cloudflare-worker-auth/src/AuthToken.ts | 25 -- .../cloudflare-worker-auth/test/integ.test.ts | 251 +++++++++++++++--- examples/cloudflare-worker-auth/tsconfig.json | 3 + 8 files changed, 257 insertions(+), 122 deletions(-) delete mode 100644 examples/cloudflare-worker-auth/src/AuthToken.ts diff --git a/bun.lock b/bun.lock index 008535d89..9e7bab02e 100644 --- a/bun.lock +++ b/bun.lock @@ -32,7 +32,7 @@ }, "distilled/packages/aws": { "name": "@distilled.cloud/aws", - "version": "0.25.2", + "version": "0.25.1", "dependencies": { "@aws-crypto/crc32": "catalog:", "@aws-crypto/util": "catalog:", @@ -63,7 +63,7 @@ }, "distilled/packages/axiom": { "name": "@distilled.cloud/axiom", - "version": "0.25.2", + "version": "0.25.1", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -79,7 +79,7 @@ }, "distilled/packages/azure": { "name": "@distilled.cloud/azure", - "version": "0.25.2", + "version": "0.25.1", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -95,7 +95,7 @@ }, "distilled/packages/cloudflare": { "name": "@distilled.cloud/cloudflare", - "version": "0.25.2", + "version": "0.25.1", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -114,7 +114,7 @@ }, "distilled/packages/coinbase": { "name": "@distilled.cloud/coinbase", - "version": "0.25.2", + "version": "0.25.1", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -131,7 +131,7 @@ }, "distilled/packages/core": { "name": "@distilled.cloud/core", - "version": "0.25.2", + "version": "0.25.1", "devDependencies": { "@types/bun": "catalog:", "@types/node": "catalog:", @@ -144,7 +144,7 @@ }, "distilled/packages/expo-eas": { "name": "@distilled.cloud/expo-eas", - "version": "0.25.2", + "version": "0.25.1", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -160,7 +160,7 @@ }, "distilled/packages/fly-io": { "name": "@distilled.cloud/fly-io", - "version": "0.25.2", + "version": "0.25.1", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -176,7 +176,7 @@ }, "distilled/packages/gcp": { "name": "@distilled.cloud/gcp", - "version": "0.25.2", + "version": "0.25.1", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -192,7 +192,7 @@ }, "distilled/packages/kubernetes": { "name": "@distilled.cloud/kubernetes", - "version": "0.25.2", + "version": "0.25.1", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -208,7 +208,7 @@ }, "distilled/packages/mongodb-atlas": { "name": "@distilled.cloud/mongodb-atlas", - "version": "0.25.2", + "version": "0.25.1", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -224,7 +224,7 @@ }, "distilled/packages/neon": { "name": "@distilled.cloud/neon", - "version": "0.25.2", + "version": "0.25.1", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -240,7 +240,7 @@ }, "distilled/packages/planetscale": { "name": "@distilled.cloud/planetscale", - "version": "0.25.2", + "version": "0.25.1", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -256,7 +256,7 @@ }, "distilled/packages/posthog": { "name": "@distilled.cloud/posthog", - "version": "0.25.2", + "version": "0.25.1", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -274,7 +274,7 @@ }, "distilled/packages/prisma-postgres": { "name": "@distilled.cloud/prisma-postgres", - "version": "0.25.2", + "version": "0.25.1", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -290,7 +290,7 @@ }, "distilled/packages/stripe": { "name": "@distilled.cloud/stripe", - "version": "0.25.2", + "version": "0.25.1", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -306,7 +306,7 @@ }, "distilled/packages/supabase": { "name": "@distilled.cloud/supabase", - "version": "0.25.2", + "version": "0.25.1", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -322,7 +322,7 @@ }, "distilled/packages/turso": { "name": "@distilled.cloud/turso", - "version": "0.25.2", + "version": "0.25.1", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -338,7 +338,7 @@ }, "distilled/packages/typesense": { "name": "@distilled.cloud/typesense", - "version": "0.25.2", + "version": "0.25.1", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -355,7 +355,7 @@ }, "distilled/packages/workos": { "name": "@distilled.cloud/workos", - "version": "0.25.2", + "version": "0.25.1", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -704,6 +704,7 @@ "name": "cloudflare-worker-auth", "version": "0.0.0", "dependencies": { + "@alchemy.run/pr-package": "workspace:*", "@cloudflare/workers-types": "catalog:", "@effect/platform-bun": "catalog:", "@effect/platform-node": "catalog:", diff --git a/distilled b/distilled index 3737b723f..61a84be97 160000 --- a/distilled +++ b/distilled @@ -1 +1 @@ -Subproject commit 3737b723f77674fbf26764207025d31dabdea19f +Subproject commit 61a84be97a04a3248d4b807181a06e3f5b78783c diff --git a/examples/cloudflare-worker-auth/alchemy.run.ts b/examples/cloudflare-worker-auth/alchemy.run.ts index fb7964319..dfdc6f946 100644 --- a/examples/cloudflare-worker-auth/alchemy.run.ts +++ b/examples/cloudflare-worker-auth/alchemy.run.ts @@ -1,3 +1,4 @@ +import * as PrPackage from "@alchemy.run/pr-package"; import * as Alchemy from "alchemy"; import * as Cloudflare from "alchemy/Cloudflare"; import * as Output from "alchemy/Output"; @@ -5,7 +6,6 @@ import * as Effect from "effect/Effect"; import * as Redacted from "effect/Redacted"; import Api from "./src/Api.ts"; -import { AuthTokenValue } from "./src/AuthToken.ts"; export default Alchemy.Stack( "CloudflareWorkerAuthExample", @@ -14,7 +14,7 @@ export default Alchemy.Stack( state: Cloudflare.state(), }, Effect.gen(function* () { - const authToken = yield* AuthTokenValue; + const authToken = yield* PrPackage.AuthTokenValue; const api = yield* Api; return { diff --git a/examples/cloudflare-worker-auth/package.json b/examples/cloudflare-worker-auth/package.json index 5fa88b739..76a559473 100644 --- a/examples/cloudflare-worker-auth/package.json +++ b/examples/cloudflare-worker-auth/package.json @@ -18,6 +18,7 @@ "test": "bun test" }, "dependencies": { + "@alchemy.run/pr-package": "workspace:*", "@cloudflare/workers-types": "catalog:", "@effect/platform-bun": "catalog:", "@effect/platform-node": "catalog:", diff --git a/examples/cloudflare-worker-auth/src/Api.ts b/examples/cloudflare-worker-auth/src/Api.ts index 8127530ca..ac194ba37 100644 --- a/examples/cloudflare-worker-auth/src/Api.ts +++ b/examples/cloudflare-worker-auth/src/Api.ts @@ -1,54 +1,20 @@ +import * as PrPackage from "@alchemy.run/pr-package"; import * as Cloudflare from "alchemy/Cloudflare"; -import * as Effect from "effect/Effect"; -import * as Redacted from "effect/Redacted"; -import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; -import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; -import { AuthToken } from "./AuthToken.ts"; /** - * A Worker with a single bearer-token protected route, mirroring the auth - * check in `@alchemy.run/pr-package`. + * The real `@alchemy.run/pr-package` Worker. `PrPackage.handler` wires up the + * R2 bucket, KV tag index, Secrets Store bearer token, and the PackageStore + * Durable Object internally — this file is the Worker entry, so it must set + * `main: import.meta.filename`. * - * `Cloudflare.Secret.bind(...)` resolves to `Redacted`. The comparison - * MUST unwrap it with `Redacted.value(expected)` — coercing a `Redacted` to a - * string yields the literal `""`, so `Bearer ${expected}` would - * compare against `"Bearer "` and silently accept the wrong token. - * See https://github.com/alchemy-run/alchemy-effect/pull/598. + * The handler's bearer-token check is the worker side of the bug fixed in + * https://github.com/alchemy-run/alchemy-effect/pull/598. */ export default class Api extends Cloudflare.Worker()( "Api", { main: import.meta.filename, + url: true, }, - Effect.gen(function* () { - const authToken = yield* Cloudflare.Secret.bind(AuthToken); - - return { - fetch: Effect.gen(function* () { - const request = yield* HttpServerRequest; - - if (request.url.startsWith("/protected")) { - const authHeader = request.headers.authorization; - const expected = yield* authToken; - if ( - !authHeader || - authHeader !== `Bearer ${Redacted.value(expected)}` - ) { - return HttpServerResponse.text("unauthorized", { status: 401 }); - } - return HttpServerResponse.text("ok"); - } - - return HttpServerResponse.text("public"); - }).pipe( - Effect.catchTag("SecretError", (err) => - Effect.succeed( - HttpServerResponse.text(`failed to read secret: ${err.message}`, { - status: 500, - }), - ), - ), - ), - }; - }).pipe(Effect.provide(Cloudflare.SecretBindingLive)), + PrPackage.handler(), ) {} diff --git a/examples/cloudflare-worker-auth/src/AuthToken.ts b/examples/cloudflare-worker-auth/src/AuthToken.ts deleted file mode 100644 index 71270d9f3..000000000 --- a/examples/cloudflare-worker-auth/src/AuthToken.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Random } from "alchemy"; -import * as Cloudflare from "alchemy/Cloudflare"; -import * as Effect from "effect/Effect"; - -/** Secrets Store backing the bearer token. */ -export const Store = Cloudflare.SecretsStore("AuthSecrets"); - -/** - * Random-generated bearer token. `Random` persists its value in state, so the - * token is stable across deploys (only regenerated on replacement). - * - * `yield* AuthTokenValue` resolves to `{ text: Output> }` — - * yield it in the stack to read `.text` for the `authToken` output. - */ -export const AuthTokenValue = Random("AuthTokenValue"); - -/** Cloudflare Secret bound to the Worker. Internal — yielded by `Api`. */ -export const AuthToken = Effect.gen(function* () { - const store = yield* Store; - const value = yield* AuthTokenValue; - return yield* Cloudflare.Secret("AuthToken", { - store, - value: value.text, - }); -}); diff --git a/examples/cloudflare-worker-auth/test/integ.test.ts b/examples/cloudflare-worker-auth/test/integ.test.ts index 5dd093aee..7865e2a6f 100644 --- a/examples/cloudflare-worker-auth/test/integ.test.ts +++ b/examples/cloudflare-worker-auth/test/integ.test.ts @@ -3,6 +3,7 @@ import * as Test from "alchemy/Test/Bun"; import { expect } from "bun:test"; import * as Effect from "effect/Effect"; import * as Schedule from "effect/Schedule"; +import * as HttpBody from "effect/unstable/http/HttpBody"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import Stack from "../alchemy.run.ts"; @@ -19,17 +20,89 @@ afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack), { timeout: 180_000, }); +type Client = HttpClient.HttpClient; + +// A fresh workers.dev URL can return connection errors or Cloudflare edge 52x +// cold-start responses for a few seconds. `client.execute` only fails the +// Effect on connection errors, so repeat a write-free public request (a GET on +// a non-existent tag -> 404) until the Worker code is actually executing. +const warmUp = (client: Client, url: string) => + client.get(`${url}/projects/_warmup/tags/_none`).pipe( + Effect.retry({ schedule: Schedule.exponential("500 millis"), times: 10 }), + Effect.repeat({ + schedule: Schedule.spaced("1 second"), + until: (res) => res.status === 404, + times: 20, + }), + ); + +const upload = ( + client: Client, + url: string, + token: string, + project: string, + tags: string[], + content: string, +) => + client.execute( + HttpClientRequest.put(`${url}/projects/${project}/packages`).pipe( + HttpClientRequest.bearerToken(token), + HttpClientRequest.setHeader("X-Tags", JSON.stringify(tags)), + HttpClientRequest.setBody(HttpBody.text(content)), + ), + ); + +const getPackage = ( + client: Client, + url: string, + project: string, + resourceId: string, +) => client.get(`${url}/projects/${project}/packages/${resourceId}`); + +const getTag = (client: Client, url: string, project: string, tag: string) => + client.get(`${url}/projects/${project}/tags/${tag}`); + +const deleteTag = ( + client: Client, + url: string, + token: string, + project: string, + tag: string, +) => + client.execute( + HttpClientRequest.make("DELETE")( + `${url}/projects/${project}/tags/${tag}`, + ).pipe(HttpClientRequest.bearerToken(token)), + ); + +// Tag lookups go through Workers KV, which is eventually consistent, and the +// edge can return a transient 5xx. Poll the request until it reaches the +// expected status (or give up after ~30s) so assertions test the converged +// state rather than racing the write. +const pollUntilStatus = ( + request: Effect.Effect, + status: number, +): Effect.Effect => + request.pipe( + Effect.repeat({ + schedule: Schedule.spaced("1 second"), + until: (res) => res.status === status, + times: 30, + }), + ); + /** * Regression guard for https://github.com/alchemy-run/alchemy-effect/pull/598 * - * The bearer token is symmetric: + * Exercises the real `@alchemy.run/pr-package` Worker. The bearer token is + * symmetric: * * - Stack output: `authToken.text` is an `Output>`. It must - * be unwrapped via `Output.map(Redacted.value)` before being returned — + * be unwrapped via `Output.map(Redacted.value)` before being returned - * otherwise it JSON-serializes to the literal string "". * - Worker check: `Cloudflare.Secret.bind(...)` resolves to `Redacted`. - * The comparison must unwrap with `Redacted.value(expected)` — otherwise it - * compares against "Bearer ". + * The handler must unwrap with `Redacted.value(expected)` - otherwise it + * compares the request against "Bearer ". * * Two bugs that cancelled: a publisher reading the broken `` output * would send `Bearer `, which the broken worker check also produced, @@ -55,47 +128,163 @@ test( const { url, authToken } = yield* stack; const client = yield* HttpClient.HttpClient; - const protectedUrl = `${url}/protected`; - const get = (req: HttpClientRequest.HttpClientRequest) => - client.execute(req); + // `PUT /projects/:pkg/packages` is bearer-protected by the pr-package + // handler (auth runs before any body/header validation). + const packagesUrl = `${url}/projects/test-pkg/packages`; + const put = (token?: string) => { + let req = HttpClientRequest.put(packagesUrl).pipe( + HttpClientRequest.setHeader("X-Tags", JSON.stringify(["v1"])), + HttpClientRequest.setBody(HttpBody.text("hello-package-body")), + ); + if (token !== undefined) { + req = req.pipe(HttpClientRequest.bearerToken(token)); + } + return client.execute(req); + }; - // Warm up through edge propagation — fresh workers.dev URLs take a few - // seconds to start serving. The public route needs no auth. - yield* client.get(url).pipe( - Effect.retry({ - schedule: Schedule.exponential("500 millis"), - times: 10, + // Warm up through edge propagation - a fresh workers.dev URL can return + // connection errors or Cloudflare edge 52x cold-start responses for a few + // seconds. `client.execute` only fails the Effect on connection errors, so + // repeat until the Worker itself answers with the auth rejection (401). + // A bad-token PUT is rejected before any binding work, so repeating it is + // side-effect free. + yield* put("warmup").pipe( + Effect.retry({ schedule: Schedule.exponential("500 millis"), times: 10 }), + Effect.repeat({ + schedule: Schedule.spaced("1 second"), + until: (res) => res.status === 401, + times: 20, }), ); - // Valid token -> 200 - const ok = yield* get( - HttpClientRequest.get(protectedUrl).pipe( - HttpClientRequest.bearerToken(authToken), - ), - ); + // Valid token -> 200 with a resourceId. The first real write can hit a + // cold R2/DO binding and 500, so poll until it converges. + const ok = yield* pollUntilStatus(put(authToken), 200); expect(ok.status).toBe(200); + const body = (yield* ok.json) as { resourceId: string }; + expect(body.resourceId).toBeString(); - // Invalid token -> 401 - const bad = yield* get( - HttpClientRequest.get(protectedUrl).pipe( - HttpClientRequest.bearerToken("not-the-token"), - ), - ); + // Invalid token -> 401. + const bad = yield* put("not-the-token"); expect(bad.status).toBe(401); - // No Authorization header -> 401 - const none = yield* get(HttpClientRequest.get(protectedUrl)); + // No Authorization header -> 401. + const none = yield* put(undefined); expect(none.status).toBe(401); // The literal "" (what both old bugs produced) -> 401. // Pre-fix this matched and returned 200. - const redacted = yield* get( - HttpClientRequest.get(protectedUrl).pipe( - HttpClientRequest.bearerToken(""), + const redacted = yield* put(""); + expect(redacted.status).toBe(401); + + // Clean up the uploaded object so afterAll's destroy() can delete the R2 + // bucket - deleting the only tag orphans and removes the stored package. + const del = yield* client.execute( + HttpClientRequest.make("DELETE")(`${url}/projects/test-pkg/tags/v1`).pipe( + HttpClientRequest.bearerToken(authToken), ), ); - expect(redacted.status).toBe(401); + expect(del.status).toBe(200); + }), + { timeout: 120_000 }, +); + +test( + "uploads a bundle and serves the exact bytes back for download", + Effect.gen(function* () { + const { url, authToken } = yield* stack; + const client = yield* HttpClient.HttpClient; + yield* warmUp(client, url); + + const project = "download-test"; + const content = "fake-tarball-contents- -12345"; + + // Upload a bundle under a single tag (retry through cold-binding 5xx). + const put = yield* pollUntilStatus( + upload(client, url, authToken, project, ["1.0.0"], content), + 200, + ); + expect(put.status).toBe(200); + const { resourceId } = (yield* put.json) as { resourceId: string }; + expect(resourceId).toBeString(); + + // Download directly by resourceId - the stored bytes round-trip exactly. + const dl = yield* pollUntilStatus( + getPackage(client, url, project, resourceId), + 200, + ); + expect(dl.status).toBe(200); + expect(yield* dl.text).toBe(content); + + // Download via the tag: the route 302-redirects to the package and the + // client follows it, so the tag serves the same bytes. KV may lag the + // upload, so poll until the tag resolves. + const viaTag = yield* pollUntilStatus( + getTag(client, url, project, "1.0.0"), + 200, + ); + expect(yield* viaTag.text).toBe(content); + + // Cleanup: dropping the only tag removes the bundle (see GC test below). + const del = yield* deleteTag(client, url, authToken, project, "1.0.0"); + expect(del.status).toBe(200); + const gone = yield* pollUntilStatus( + getPackage(client, url, project, resourceId), + 404, + ); + expect(gone.status).toBe(404); + }), + { timeout: 120_000 }, +); + +test( + "a bundle is garbage-collected once its last tag pointer is removed", + Effect.gen(function* () { + const { url, authToken } = yield* stack; + const client = yield* HttpClient.HttpClient; + yield* warmUp(client, url); + + const project = "gc-test"; + const content = "gc-bundle-body"; + + // One bundle, two tags pointing at it (retry through cold-binding 5xx). + const put = yield* pollUntilStatus( + upload(client, url, authToken, project, ["1.0.0", "latest"], content), + 200, + ); + expect(put.status).toBe(200); + const { resourceId } = (yield* put.json) as { resourceId: string }; + + // The bundle is downloadable (R2 is read-after-write consistent). + const present = yield* pollUntilStatus( + getPackage(client, url, project, resourceId), + 200, + ); + expect(present.status).toBe(200); + + // Remove one of the two tags - the removed tag stops resolving, but the + // bundle survives because "latest" still points at it. + expect( + (yield* deleteTag(client, url, authToken, project, "1.0.0")).status, + ).toBe(200); + const removedTag = yield* pollUntilStatus( + getTag(client, url, project, "1.0.0"), + 404, + ); + expect(removedTag.status).toBe(404); + expect((yield* getPackage(client, url, project, resourceId)).status).toBe( + 200, + ); + + // Remove the last tag - the bundle is garbage-collected. + expect( + (yield* deleteTag(client, url, authToken, project, "latest")).status, + ).toBe(200); + const collected = yield* pollUntilStatus( + getPackage(client, url, project, resourceId), + 404, + ); + expect(collected.status).toBe(404); }), { timeout: 120_000 }, ); diff --git a/examples/cloudflare-worker-auth/tsconfig.json b/examples/cloudflare-worker-auth/tsconfig.json index 0cf8c32af..39d709b15 100644 --- a/examples/cloudflare-worker-auth/tsconfig.json +++ b/examples/cloudflare-worker-auth/tsconfig.json @@ -11,6 +11,9 @@ "references": [ { "path": "../../packages/alchemy/tsconfig.json" + }, + { + "path": "../../packages/pr-package/tsconfig.json" } ] }