diff --git a/.gitignore b/.gitignore index 61390c7f22..4db9d09d86 100644 --- a/.gitignore +++ b/.gitignore @@ -77,4 +77,4 @@ pnpm-lock.yaml .ai-workspace/ /processes/ -.probe +.probe* diff --git a/bun.lock b/bun.lock index 0395909e73..8ed8002c50 100644 --- a/bun.lock +++ b/bun.lock @@ -1563,6 +1563,7 @@ "@distilled.cloud/core": "workspace:*", "@distilled.cloud/neon": "workspace:*", "@distilled.cloud/planetscale": "workspace:*", + "@distilled.cloud/vercel": "workspace:*", "@effect/sql-d1": "catalog:", "@effect/sql-sqlite-do": "catalog:", "@libsql/client": "catalog:", @@ -3031,23 +3032,23 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], - "@opentui/core": ["@opentui/core@0.5.2", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.2", "@opentui/core-darwin-x64": "0.5.2", "@opentui/core-linux-arm64": "0.5.2", "@opentui/core-linux-arm64-musl": "0.5.2", "@opentui/core-linux-x64": "0.5.2", "@opentui/core-linux-x64-musl": "0.5.2", "@opentui/core-win32-arm64": "0.5.2", "@opentui/core-win32-x64": "0.5.2" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-GOuD0Mq1ylybq9eEX6L0TdYlfJ2zJVLGecuh6yEM8RpT9oMtR0f+gEWqGJsBN/ay+Y5TOCH9b3vePEcwOW01fg=="], + "@opentui/core": ["@opentui/core@0.5.3", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.3", "@opentui/core-darwin-x64": "0.5.3", "@opentui/core-linux-arm64": "0.5.3", "@opentui/core-linux-arm64-musl": "0.5.3", "@opentui/core-linux-x64": "0.5.3", "@opentui/core-linux-x64-musl": "0.5.3", "@opentui/core-win32-arm64": "0.5.3", "@opentui/core-win32-x64": "0.5.3" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-K8EQu44cx0rhnn3v3baCQW18Bpci3GltZayOwVpGGsbiAGL1WUYqwQjuaWsmS0c4dCa9rQ5xCEoHB1C4936nDg=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fGi/RubZIhxcU8+M3GXNaBmEHqf127ZOnKmUTSp4ty2QdQ0kT4RTd+t91a/oyxrLZjjCmry0jXyquT0CZR1Byw=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-R39YeUqaMb/rH1h6G4MkB4MLVKIrRaUaXLfVqorZM4xgU5BxnfPetRk1vWR9vuLCvDwskg+kQ589kULw0o6AWA=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ezFpB+5mQTd9BS4VvHOYEgQK6YxiYwVUmFb2sfTbJBnTqbOFP3Z/cdE5qNUHoeLQ01/nOs2dmUPcGyWxtMZIzw=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-1pmUas/chTVFGeiN19kaOx+5Xbte/DLhcgKyACwWO0M3+xE3z1v/6QGSyX6CoP5HBpmDroiX+JHv1ic/JlGd/g=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-9S53zkIkbwRb0iIv2thibK+IofpTCEz/c48rJNaBx3qDylaDcTA11k5oqt1gkFcOLernlqIcow8aMCNDnRYMAA=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nMo9Q9VIaQVdw2SNKlwIEWMmf3z+cI4jRdCkh36e2RU1FO7LrIBAEmV1ZuRp1CIFVGPkqCXIizCeckZHTr4yQ=="], - "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-s2DB6he7jgbBqWOHBAIV/p4X12mj+Hlk1pAEcwK36j2wg+L4nVVdWecUJm1xeJn6LJSRcaJYE29sA45w5b7B2g=="], + "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-QOYAxbbWrhYo27Cd6m0ATzpEx9YCAKAq82LgfUn0xu+VKXLNu+Q3hMNSVbG0SepUQQZil5rz228R9o9Cs8995w=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.2", "", { "os": "linux", "cpu": "x64" }, "sha512-HO0TWovqxo95hxFoTBx47MTP6pzzTW/QMoOIiD8jw0hkrn1DF8rSVPva0vLQMRer/drO9RpjyroFRNra7710og=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-hdAYLriLpTj3lvpMyL25GPBzvM2w/n2KCSbIwTmgS2F/dPZYCKJxHEETj2lCvtStSp7KuY8tkg3Xl5RAq1v7gA=="], - "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.2", "", { "os": "linux", "cpu": "x64" }, "sha512-zyuJaRGBRbUOABgf3nJ2Jk/NmD+gS2dQpJzC2ikr2OMqclPVk7gFbPTHW2s/A3SJH7D8d0PJ6wL2wzXWMUdrWQ=="], + "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-BkVIiPQ1TOf5/FfmIpf7DQU5rT/FO6ASW5R/o/wonI5Pdul7XiDCu86gzGyk1x5k9Sbh6GLeq1fe8/tPmI7IaA=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-J1QxYJqxyAO49RyocqZSoGWs5uszlYot8sInLasi6nUbjCFg4h7Kvh+64+gxE2i4LASeyvMRpAIYwblI6eMbtg=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-AjObTyZPU0xsK3Yk8GmhkboK6OcMoHBbydqYAybeHD4+v6axScSuZ3OEI9J05JJ9T7H2nZNky75tsdnjsvZJmg=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.2", "", { "os": "win32", "cpu": "x64" }, "sha512-9/F1Q19GdtkZ1jypa0dRP6v9SJHHc91XHs4ZSroamDeqSDyTaGu1bbWnKiC503gpNpQMY0mjOVg4g+WVJLdDig=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.3", "", { "os": "win32", "cpu": "x64" }, "sha512-e3nRlF2nSkLKCUPBF32OL9EDgtQDIh2pBo7tjhumpTyJ3qoNOa3us7DsM290Vw4xnakM6jpk9r9NzRf72CuMVg=="], "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], diff --git a/distilled b/distilled index 01db915adf..34010969cb 160000 --- a/distilled +++ b/distilled @@ -1 +1 @@ -Subproject commit 01db915adfe9f16f5fa54353b5b767896d285a80 +Subproject commit 34010969cbfe71c7ee08aa6a24b267b95d40e7be diff --git a/packages/alchemy-test/src/FileLog.ts b/packages/alchemy-test/src/FileLog.ts index f172e0426e..052bb56f80 100644 --- a/packages/alchemy-test/src/FileLog.ts +++ b/packages/alchemy-test/src/FileLog.ts @@ -32,6 +32,10 @@ export const formatEvent = (event: TestEvent): string | undefined => { switch (event._tag) { case "RunStart": return `running ${event.tests.length} tests from ${event.files} files (${new Date().toISOString()})\n\n`; + case "TestRetry": { + const title = `${event.test.file} > ${event.test.titlePath.join(" > ")}`; + return `RETRY ${title} — attempt ${event.attempt} failed:\n${event.error}\n\n`; + } case "TestEnd": { const title = `${event.test.file} > ${event.test.titlePath.join(" > ")}`; const retries = @@ -79,6 +83,13 @@ export const formatEvent = (event: TestEvent): string | undefined => { export interface FileLog { readonly append: (event: TestEvent) => Effect.Effect; + /** + * Append a raw pre-formatted chunk. Used for records that are not test + * events — e.g. the RUN INTERRUPTED trailer written when the process is + * killed externally (SIGINT/SIGTERM) mid-run. Best-effort: write failures + * are ignored. + */ + readonly appendRaw: (text: string) => Effect.Effect; /** * Enqueue one live file-hook log line (prefixed with the file it belongs * to). File-level hooks (beforeAll deploys / afterAll destroys) can run @@ -145,6 +156,8 @@ export const makeFileLog = Effect.fn(function* (logFile: string) { .writeFileString(logFile, chunk, { flag: "a" }) .pipe(Effect.ignore); }; + const appendRaw: FileLog["appendRaw"] = (text) => + fs.writeFileString(logFile, text, { flag: "a" }).pipe(Effect.ignore); // Hook lines flow through an unbounded queue to a single writer fiber so // the capture site (a synchronous array-push interception) never performs // I/O and never blocks: `offerUnsafe` on an unbounded queue is a plain @@ -176,5 +189,5 @@ export const makeFileLog = Effect.fn(function* (logFile: string) { yield* Queue.end(hookLines); yield* Fiber.await(writer); }); - return { append, appendHookLine, close } satisfies FileLog; + return { append, appendRaw, appendHookLine, close } satisfies FileLog; }); diff --git a/packages/alchemy-test/src/PlainReporter.ts b/packages/alchemy-test/src/PlainReporter.ts index 45ffcec3a7..2e8a3359b6 100644 --- a/packages/alchemy-test/src/PlainReporter.ts +++ b/packages/alchemy-test/src/PlainReporter.ts @@ -249,6 +249,16 @@ const onEvent = ( state.running.delete(`${event.file} :: ${event.hook}`); state.lastEnd = Date.now(); }); + case "TestRetry": { + // The failed attempt's error prints NOW — the retry may run for + // minutes, and if the process is killed during it this line is the + // only console record of what went wrong. + const title = `${dim(event.test.file)} ${dim(">")} ${event.test.titlePath.join(` ${dim(">")} `)}`; + const firstLine = event.error.split("\n", 1)[0] ?? event.error; + return write( + `${yellow("↻")} ${title} ${yellow(`attempt ${event.attempt} failed — retrying`)}\n${indent(dim(firstLine))}`, + ); + } case "TestEnd": { state.running.delete(event.test.id); state.lastEnd = Date.now(); diff --git a/packages/alchemy-test/src/Reporter.ts b/packages/alchemy-test/src/Reporter.ts index 0bcc61731b..53c910668d 100644 --- a/packages/alchemy-test/src/Reporter.ts +++ b/packages/alchemy-test/src/Reporter.ts @@ -95,6 +95,22 @@ export type TestEvent = /** LIVE reference to the test's captured-output buffer (see FileStart). */ readonly logs?: ReadonlyArray; } + | { + /** + * An attempt failed and the test is about to be re-run. Emitted BEFORE + * the retry starts so the failed attempt's error reaches the console + * and the run log immediately — without this, a test that spends + * several timeouts' worth of wall clock across retries leaves no trace + * on disk until its final TestEnd, and an externally-killed run + * (SIGTERM/SIGINT) loses the failure entirely. + */ + readonly _tag: "TestRetry"; + readonly test: TestMeta; + /** 1-based number of the attempt that just failed. */ + readonly attempt: number; + /** Pretty-printed failure of that attempt. */ + readonly error: string; + } | { readonly _tag: "TestEnd"; readonly test: TestMeta; diff --git a/packages/alchemy-test/src/Runner.ts b/packages/alchemy-test/src/Runner.ts index 7c8a58e3cf..fe2e005a7e 100644 --- a/packages/alchemy-test/src/Runner.ts +++ b/packages/alchemy-test/src/Runner.ts @@ -24,6 +24,7 @@ import { inspect } from "node:util"; import { pathToFileURL } from "node:url"; import { makeFileLog } from "./FileLog.ts"; +import { writeDirect } from "./StrayOutput.ts"; import type { FileSuite, Hook, LogEntry, Suite, TestCase } from "./Model.ts"; import { containsOnly, forEachTest, titlePath } from "./Model.ts"; import * as Registry from "./Registry.ts"; @@ -381,11 +382,40 @@ interface TestAttempt { readonly afterEach: Exit.Exit; } +/** + * Marker failure for a body that hit its per-test timeout. Distinguished + * from ordinary failures because a timed-out attempt is NEVER retried — + * see {@link attemptNeedsRetry}. + */ +class TestTimeoutError extends Error { + override name = "TestTimeoutError"; +} + +/** Did the attempt's body fail by hitting its per-test timeout? */ +const failedByTimeout = (exit: Exit.Exit): boolean => + Exit.isSuccess(exit) && + exit.value.body !== undefined && + Exit.isFailure(exit.value.body) && + exit.value.body.cause.reasons.some( + (reason) => + reason._tag === "Fail" && reason.error instanceof TestTimeoutError, + ); + const attemptNeedsRetry = ( exit: Exit.Exit, expectsFailure: boolean | undefined, ): boolean => { if (Exit.isFailure(exit)) return !wasInterrupted(exit); + // A per-test timeout is never retried. The attempt already consumed the + // test's ENTIRE time budget, and its body fiber may have been abandoned + // mid-teardown (still running detached, still holding locks/state) — a + // re-run races the abandoned attempt AND multiplies the wall-clock cost + // by (1 + retries). On real cloud suites that pushed a single wedged + // 120s-timeout test past external wall clocks (`timeout 240`, CI limits, + // Ctrl+C), which killed the whole runner with a SIGTERM/SIGINT-style + // exit 130 and a truncated run log before the failure was ever reported. + // Hitting the timeout IS the result; report it immediately. + if (failedByTimeout(exit)) return false; if ( Exit.isFailure(exit.value.beforeEach) || Exit.isFailure(exit.value.afterEach) @@ -399,6 +429,17 @@ const attemptNeedsRetry = ( ); }; +/** Compact description of why an attempt failed (for TestRetry events). */ +const attemptFailure = (exit: Exit.Exit): string => { + if (Exit.isFailure(exit)) return prettyCause(exit.cause); + const hook = hookError(exit.value); + if (hook !== undefined) return hook; + const body = exit.value.body; + return body !== undefined && Exit.isFailure(body) + ? prettyCause(body.cause) + : "unknown failure"; +}; + const hookError = (attempt: TestAttempt): string | undefined => { const errors: Array = []; if (Exit.isFailure(attempt.beforeEach)) { @@ -451,7 +492,7 @@ const runBodyWithTimeout = Effect.fn(function* ( Effect.timeoutOption(Duration.millis(INTERRUPT_GRACE_MS)), ); return Exit.fail( - new Error( + new TestTimeoutError( Option.isNone(settled) ? `test timed out after ${timeoutMs}ms (teardown did not settle within ${INTERRUPT_GRACE_MS}ms and was abandoned)` : `test timed out after ${timeoutMs}ms`, @@ -542,6 +583,15 @@ const runTest = Effect.fn(function* (test: TestCase, ctx: ExecContext) { retries < (test.retry ?? ctx.options.retry) ) { retries++; + // Announce the failed attempt BEFORE re-running: the retry may take + // minutes (or the run may be killed during it) — the attempt's error + // must already be on the console and in the run log by then. + yield* ctx.emit({ + _tag: "TestRetry", + test: meta, + attempt: retries, + error: attemptFailure(exit), + }); // Clear IN PLACE — TestStart handed this array's reference out. logs.length = 0; exit = yield* runAttempt(); @@ -727,6 +777,49 @@ export const run = Effect.fn(function* (options: RunOptions) { const emit = (event: TestEvent): Effect.Effect => reporter.emit(event).pipe(Effect.andThen(fileLog.append(event))); + // Hoisted run state, shared with the interruption trailer below: results + // reported so far, currently-executing test fibers, and the announced + // test total. + const allResults: Array<{ meta: TestMeta; result: TestResult }> = []; + const running = new Map>(); + const totals = { tests: 0 }; + + // When the process is killed externally (Ctrl+C, a `timeout N` wrapper, + // a CI wall clock — all of which the platform runMain converts into an + // interruption of this fiber and an exit-130), the run would otherwise + // die silently: no failure report, and a run log that simply stops after + // `running N tests...`. This finalizer runs on the CLI scope's close with + // the run's exit; on interruption it drains the live hook-line queue and + // appends an attributed trailer so the log always records what was in + // flight when the run was killed. + yield* Effect.addFinalizer((exit) => + Effect.gen(function* () { + if (!wasInterrupted(exit)) return; + const passed = allResults.filter( + (r) => r.result.status === "pass", + ).length; + const failed = allResults.filter( + (r) => r.result.status === "fail", + ).length; + const inFlight = [...running.keys()]; + const lines = [ + `RUN INTERRUPTED (killed by signal — Ctrl+C, timeout wrapper, or CI limit) after ${((Date.now() - startedAt) / 1000).toFixed(1)}s`, + `${allResults.length}/${totals.tests} tests reported (${passed} passed, ${failed} failed)`, + ...(inFlight.length === 0 + ? [] + : ["still running when killed:", ...inFlight.map((id) => ` ${id}`)]), + ]; + // Drain queued hook lines first so the trailer is the log's last word. + yield* fileLog.close; + yield* fileLog.appendRaw(`\n${lines.join("\n")}\n`); + yield* Effect.sync(() => { + writeDirect( + `\nrun interrupted — partial results in ${options.logFile}\n`, + ); + }); + }), + ); + const absoluteFiles = yield* discover(options).pipe(Effect.orDie); const relative = absoluteFiles.map((f) => path.relative(options.root, f)); yield* emit({ _tag: "CollectStart", files: relative }); @@ -779,6 +872,7 @@ export const run = Effect.fn(function* (options: RunOptions) { }; walk(c.suite); } + totals.tests = allMetas.length; yield* emit({ _tag: "RunStart", files: collected.length, @@ -786,10 +880,8 @@ export const run = Effect.fn(function* (options: RunOptions) { }); // Phase 2 — run files concurrently. - const allResults: Array<{ meta: TestMeta; result: TestResult }> = []; const fileFailures: Array<{ file: string; error: string }> = []; const lock = yield* Semaphore.make(EXCLUSIVE_PERMITS); - const running = new Map>(); const completed = new Set(); const testIndex = new Map(); diff --git a/packages/alchemy-test/test/Runner.test.ts b/packages/alchemy-test/test/Runner.test.ts index fce50d90d0..1e88608fd5 100644 --- a/packages/alchemy-test/test/Runner.test.ts +++ b/packages/alchemy-test/test/Runner.test.ts @@ -86,6 +86,184 @@ it("streams file-hook output to the run log while the hook is still running", as } }); +it("reports a timed-out test after a single attempt — no retries, no runner death", async () => { + // Regression: a test that hit its per-test timeout used to be re-run by + // the default retry (x2). Each retry burned the FULL timeout again (plus + // the 10s interrupt grace when teardown was wedged), tripling the + // wall-clock cost of a wedged cloud test — which pushed real suites past + // external wall clocks (`timeout 240`, CI limits, Ctrl+C). The external + // kill surfaced as an exit-130 "runner crash" with a truncated run log + // and no failure report. A timeout consumed the whole time budget and may + // have left its body fiber abandoned mid-teardown; it must be reported + // immediately, exactly once. + const root = await mkdtemp(resolve(tmpdir(), "alchemy-test-timeout-")); + try { + await writeFile( + resolve(root, "timeout.test.ts"), + ` + import { it } from ${JSON.stringify(apiUrl)}; + it( + "sleeps past its timeout", + () => new Promise((r) => setTimeout(r, 60_000)), + { timeout: 300 }, + ); + `, + ); + + const started = Date.now(); + // NOTE: no --retry flag — the DEFAULT retry (2) must not re-run timeouts. + const child = Bun.spawn( + [process.execPath, cli, root, "--concurrency", "1"], + { + cwd: root, + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, NO_COLOR: "1" }, + }, + ); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + const output = `${stdout}\n${stderr}`; + const elapsed = Date.now() - started; + + // A clean per-test failure — not a dead runner. + expect(exitCode).toBe(1); + expect(output).toContain("timed out after 300ms"); + expect(output).toContain("Tests: 1 failed"); + // Exactly one attempt: no retry marker anywhere. + expect(output).not.toContain("retried"); + expect(output).not.toContain("attempt 1 failed"); + // One 300ms attempt, not three — generous bound that still catches the + // (1 + retries) wall-clock multiplication if it regresses. + expect(elapsed).toBeLessThan(15_000); + + // The failure made it into the run log (it used to be lost when the + // multiplied retries outlived the external wall clock). + const log = await readRunLog(root); + expect(log).toContain("timed out after 300ms"); + expect(log).toContain("Tests: 1 failed"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +it("still retries ordinary failures and streams each failed attempt", async () => { + const root = await mkdtemp(resolve(tmpdir(), "alchemy-test-retry-")); + try { + await writeFile( + resolve(root, "flaky.test.ts"), + ` + import { it } from ${JSON.stringify(apiUrl)}; + let attempts = 0; + it("always fails", () => { + attempts++; + throw new Error("ordinary-failure attempt " + attempts); + }); + `, + ); + + const child = Bun.spawn( + [process.execPath, cli, root, "--concurrency", "1"], + { + cwd: root, + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, NO_COLOR: "1" }, + }, + ); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + const output = `${stdout}\n${stderr}`; + + expect(exitCode).toBe(1); + // Default retry (2) still applies to non-timeout failures... + expect(output).toContain("[retried x2]"); + expect(output).toContain("ordinary-failure attempt 3"); + // ...and each failed attempt is announced BEFORE its retry runs, so a + // killed run still has the earlier attempts' errors on record. + expect(output).toContain("attempt 1 failed"); + expect(output).toContain("attempt 2 failed"); + const log = await readRunLog(root); + expect(log).toContain("attempt 1 failed"); + expect(log).toContain("ordinary-failure attempt 1"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +it("an externally-killed run leaves an attributed trailer in the log", async () => { + // Regression: SIGTERM/SIGINT (Ctrl+C, `timeout N`, CI kill) interrupts the + // main fiber and exits 130 — the run log used to just STOP after + // `running N tests...`, with no record of what was in flight. + const root = await mkdtemp(resolve(tmpdir(), "alchemy-test-killed-")); + try { + await writeFile( + resolve(root, "slow.test.ts"), + ` + import { it } from ${JSON.stringify(apiUrl)}; + it( + "very slow test", + () => new Promise((r) => setTimeout(r, 60_000)), + { timeout: 120_000 }, + ); + `, + ); + + const child = Bun.spawn( + [process.execPath, cli, root, "--concurrency", "1"], + { + cwd: root, + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, NO_COLOR: "1" }, + }, + ); + try { + // Wait until the run is actually executing (RunStart reached the log), + // then kill it the way a wall clock would. + const deadline = Date.now() + 15_000; + let started = false; + while (Date.now() < deadline && !started) { + started = (await readRunLog(root)).includes("running 1 tests"); + if (!started) await new Promise((r) => setTimeout(r, 200)); + } + expect(started).toBe(true); + + child.kill("SIGTERM"); + const exitCode = await child.exited; + // Signal semantics preserved: interruption still exits 130 — + expect(exitCode).toBe(130); + // — but the log now records the kill and what was running. + const log = await readRunLog(root); + expect(log).toContain("RUN INTERRUPTED"); + expect(log).toContain("very slow test"); + } finally { + child.kill(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +/** Concatenated contents of every per-run log under the given run root. */ +const readRunLog = async (root: string): Promise => { + const { readdir, readFile } = await import("node:fs/promises"); + const logDir = resolve(root, ".alchemy", "log", "test"); + const entries = await readdir(logDir).catch(() => [] as string[]); + const contents = await Promise.all( + entries.map((entry) => + readFile(resolve(logDir, entry), "utf8").catch(() => ""), + ), + ); + return contents.join("\n"); +}; + it("fails the process for every hook kind and preserves hook output", async () => { const root = await mkdtemp(resolve(tmpdir(), "alchemy-test-hooks-")); try { diff --git a/packages/alchemy/package.json b/packages/alchemy/package.json index a32eebd74f..fc9a48c91c 100644 --- a/packages/alchemy/package.json +++ b/packages/alchemy/package.json @@ -216,6 +216,18 @@ "worker": "./src/Neon/*/index.ts", "import": "./lib/Neon/*/index.js" }, + "./Vercel": { + "types": "./lib/Vercel/index.d.ts", + "bun": "./src/Vercel/index.ts", + "worker": "./src/Vercel/index.ts", + "import": "./lib/Vercel/index.js" + }, + "./Vercel/*": { + "types": "./lib/Vercel/*/index.d.ts", + "bun": "./src/Vercel/*/index.ts", + "worker": "./src/Vercel/*/index.ts", + "import": "./lib/Vercel/*/index.js" + }, "./Prisma": { "types": "./lib/Prisma/index.d.ts", "bun": "./src/Prisma/index.ts", @@ -375,6 +387,7 @@ "@distilled.cloud/core": "workspace:*", "@distilled.cloud/neon": "workspace:*", "@distilled.cloud/planetscale": "workspace:*", + "@distilled.cloud/vercel": "workspace:*", "@effect/sql-d1": "catalog:", "@effect/sql-sqlite-do": "catalog:", "@libsql/client": "catalog:", diff --git a/packages/alchemy/src/Cli/commands/_shared.ts b/packages/alchemy/src/Cli/commands/_shared.ts index ad49857a4b..a3abd2020a 100644 --- a/packages/alchemy/src/Cli/commands/_shared.ts +++ b/packages/alchemy/src/Cli/commands/_shared.ts @@ -30,6 +30,7 @@ import { GitHubAuth } from "../../GitHub/AuthProvider.ts"; import { NeonAuth } from "../../Neon/AuthProvider.ts"; import { PlanetscaleAuth } from "../../Planetscale/AuthProvider.ts"; import { PrismaAuth } from "../../Prisma/AuthProvider.ts"; +import { VercelAuth } from "../../Vercel/AuthProvider.ts"; import * as Stack from "../../Stack.ts"; import { Stage } from "../../Stage.ts"; import { recordCli } from "../../Telemetry/Metrics.ts"; @@ -462,6 +463,7 @@ export const builtinAuth = Layer.mergeAll( NeonAuth, PlanetscaleAuth, PrismaAuth, + VercelAuth, ); /** diff --git a/packages/alchemy/src/Cli/commands/vercel.ts b/packages/alchemy/src/Cli/commands/vercel.ts new file mode 100644 index 0000000000..2348b52069 --- /dev/null +++ b/packages/alchemy/src/Cli/commands/vercel.ts @@ -0,0 +1,135 @@ +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Option from "effect/Option"; +import { Command, Flag } from "effect/unstable/cli"; + +import { AuthProviders } from "../../Auth/AuthProvider.ts"; +import { withProfileOverride } from "../../Auth/Profile.ts"; +import { VercelAuth } from "../../Vercel/AuthProvider.ts"; +import * as VercelCredentials from "../../Vercel/Credentials.ts"; +import { + bootstrap as bootstrapVercel, + teardownStateStore, +} from "../../Vercel/StateStore/State.ts"; +import * as VercelEnvironment from "../../Vercel/VercelEnvironment.ts"; +import { loadConfigProvider } from "../../Util/ConfigProvider.ts"; +import { fileLogger } from "../../Util/FileLogger.ts"; + +import { envFile, instrumentCommand, profile } from "./_shared.ts"; + +/** + * Build the Vercel auth + environment layer stack used by every + * `alchemy vercel ...` subcommand. Mirrors the wiring inside + * `Vercel.state(...)` so the command can talk to the user's team + * out-of-band. + */ +const vercelLayers = (envFileOpt: Option.Option, profileName: string) => + Effect.gen(function* () { + const authProviders: AuthProviders["Service"] = {}; + const authRegistry = Layer.succeed(AuthProviders, authProviders); + const authLayer = Layer.provideMerge(VercelAuth, authRegistry); + const vercel = Layer.provideMerge( + Layer.mergeAll( + VercelCredentials.fromAuthProvider(), + VercelEnvironment.fromProfile(), + ), + authLayer, + ); + + const logger = Logger.layer([fileLogger("vercel.txt")], { + mergeWithExisting: true, + }); + + return Layer.mergeAll( + vercel, + ConfigProvider.layer( + withProfileOverride(yield* loadConfigProvider(envFileOpt), profileName), + ), + logger, + ); + }); + +const vercelForce = Flag.boolean("force").pipe( + Flag.withDescription( + "Force a full redeploy even if the state-store project already exists. " + + "Without this flag, an existing store is adopted and only its credentials are refreshed.", + ), + Flag.withDefault(false), +); + +const vercelProjectName = Flag.string("project-name").pipe( + Flag.withDescription( + "Override the default state-store project name (advanced; also set " + + "ALCHEMY_VERCEL_STATE_PROJECT so the deployed function agrees).", + ), + Flag.optional, + Flag.map(Option.getOrUndefined), +); + +const bootstrapCommand = Command.make( + "bootstrap", + { + envFile, + profile, + force: vercelForce, + projectName: vercelProjectName, + }, + instrumentCommand( + "vercel.bootstrap", + (a: { + profile: string; + force: boolean; + projectName: string | undefined; + }) => ({ + "alchemy.profile": a.profile, + "alchemy.force": a.force, + "alchemy.project_name": a.projectName ?? "", + }), + )( + Effect.fn(function* ({ envFile, profile, force, projectName }) { + const services = yield* vercelLayers(envFile, profile); + yield* bootstrapVercel({ + projectName, + force, + profile, + }).pipe(Effect.provide(services)); + }), + ), +).pipe( + Command.withDescription( + "Deploy (or upgrade) the Vercel state store used by Vercel.state()", + ), +); + +const teardownCommand = Command.make( + "teardown", + { + envFile, + profile, + projectName: vercelProjectName, + }, + instrumentCommand( + "vercel.teardown", + (a: { profile: string; projectName: string | undefined }) => ({ + "alchemy.profile": a.profile, + "alchemy.project_name": a.projectName ?? "", + }), + )( + Effect.fn(function* ({ envFile, profile, projectName }) { + const services = yield* vercelLayers(envFile, profile); + yield* teardownStateStore({ + projectName, + profile, + }).pipe(Effect.provide(services)); + }), + ), +).pipe( + Command.unlisted, + Command.withDescription("Tear down the Vercel state store"), +); + +export const vercelCommand = Command.make("vercel", {}).pipe( + Command.withSubcommands([bootstrapCommand, teardownCommand]), +); diff --git a/packages/alchemy/src/Cli/main.ts b/packages/alchemy/src/Cli/main.ts index 363a60240b..c8994879ed 100644 --- a/packages/alchemy/src/Cli/main.ts +++ b/packages/alchemy/src/Cli/main.ts @@ -29,6 +29,7 @@ import { profileCommand } from "./commands/profile.ts"; import { stateCommand } from "./commands/state.ts"; import { syncCommand } from "./commands/sync.ts"; import { tailCommand } from "./commands/tail.ts"; +import { vercelCommand } from "./commands/vercel.ts"; import { selectCli } from "./selectCli.ts"; const root = Command.make("alchemy", {}).pipe( @@ -46,6 +47,7 @@ const root = Command.make("alchemy", {}).pipe( stateCommand, syncCommand, unsafeCommand, + vercelCommand, ]), ); diff --git a/packages/alchemy/src/Cloudflare/Workers/HttpServer.ts b/packages/alchemy/src/Cloudflare/Workers/HttpServer.ts index c5da66b355..0f835e507c 100644 --- a/packages/alchemy/src/Cloudflare/Workers/HttpServer.ts +++ b/packages/alchemy/src/Cloudflare/Workers/HttpServer.ts @@ -3,6 +3,7 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Scope from "effect/Scope"; import * as EffectHttp from "effect/unstable/http/HttpEffect"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; @@ -27,6 +28,10 @@ export const makeRequestEffect = ( ) => { const safeHandler = Http.safeHttpEffect(handler); return Effect.gen(function* () { + // The bridge-provided per-event scope (WorkerBridge.processEvent / + // DurableObjectBridge.#execute) — handed to toHandledWebResponse so the + // handler's finalizers settle post-response with it via ctx.waitUntil. + const requestScope = yield* Effect.scope; const request = HttpServerRequest.fromWeb( webRequest as any as globalThis.Request, ).modify({ @@ -42,7 +47,7 @@ export const makeRequestEffect = ( }), }); - return yield* toHandledWebResponse(safeHandler).pipe( + return yield* toHandledWebResponse(safeHandler, requestScope).pipe( Effect.provide([ Layer.succeed(HttpServerRequest.HttpServerRequest, request), Layer.succeed(Request, webRequest as any), @@ -53,6 +58,7 @@ export const makeRequestEffect = ( const toHandledWebResponse = ( handler: Effect.Effect, + requestScope: Scope.Scope, ) => Effect.gen(function* () { // `toHandled` exposes the final response through this callback, not its @@ -61,12 +67,37 @@ const toHandledWebResponse = ( const webResponse = yield* Deferred.make(); yield* EffectHttp.toHandled(handler, (request, response) => - Deferred.succeed( - webResponse, - // Conversion to web response with options matches `EffectHttp.toWebHandler`'s callback. - HttpServerResponse.toWeb(EffectHttp.scopeTransferToStream(response), { - withoutBody: request.method === "HEAD", - context, + Effect.flatMap(Effect.scope, (handlerScope) => + Effect.gen(function* () { + // `toHandled` runs the handler under its OWN internal scope + // (shadowing the bridge's per-event scope) and closes it INLINE + // right after this callback — a handler's `Effect.addFinalizer` + // would delay the response by the finalizer's full duration. + // Eject it and settle it with the bridge's request scope instead, + // which the bridge closes post-response via `ctx.waitUntil` — + // honoring the documented contract that request finalizers run + // after the response. Streaming bodies keep effect's native + // transfer: `scopeTransferToStream` ejects the scope itself and + // closes it when the body stream ends. (Mirrors the Vercel + // FunctionBridge's toHandledWebResponse.) + if (response.body._tag !== "Stream") { + EffectHttp.scopeDisableClose(handlerScope); + yield* Scope.addFinalizerExit(requestScope, (exit) => + Scope.close(handlerScope, exit), + ); + } + yield* Deferred.succeed( + webResponse, + // Conversion to web response with options matches + // `EffectHttp.toWebHandler`'s callback. + HttpServerResponse.toWeb( + EffectHttp.scopeTransferToStream(response), + { + withoutBody: request.method === "HEAD", + context, + }, + ), + ); }), ), ); diff --git a/packages/alchemy/src/Plan.ts b/packages/alchemy/src/Plan.ts index 5410fe6920..8e8f7dd742 100644 --- a/packages/alchemy/src/Plan.ts +++ b/packages/alchemy/src/Plan.ts @@ -27,6 +27,7 @@ import { isResolved, type NoopDiff, type ReplaceDiff, + stripUnresolved, type UpdateDiff, } from "./Diff.ts"; import { parseFqn } from "./FQN.ts"; @@ -1165,13 +1166,24 @@ export const make = ( // update keeps the deploy idempotent: if cloud state already // matches news, the provider's update is a no-op write. // - // Skip the adoption probe entirely when `news` still contains - // unresolved upstream Outputs (e.g. a `streamArn` referencing - // a stream being created in the same plan). Calling `read` with - // an unresolved value would surface as `ParseError` from the - // SDK protocol layer. Resources whose props depend on - // not-yet-created upstreams cannot themselves be pre-existing - // — there's nothing to adopt. + // `news` may still contain unresolved upstream Outputs (e.g. an + // env value referencing a sibling created in the same plan). + // That does NOT mean the resource cannot pre-exist: physical + // identity usually comes from a deterministic name (or a + // literal name prop), so a same-name resource can already live + // in the cloud while the row's non-identity inputs are still + // unresolved. Skipping the probe for such rows let `reconcile` + // silently converge onto an existing unowned resource — + // adoption without consent. Instead, probe with the unresolved + // leaves stripped (`stripUnresolved`) — the same sanitized + // `olds` shape `read` already tolerates from interrupted-apply + // state — and treat THAT probe as best-effort: a read that + // chokes on a stripped hole (its identity genuinely lived in + // the unresolved value, e.g. a `streamArn` of a stream being + // created in the same plan) degrades to "not pre-existing", + // the pre-probe behavior, instead of failing the plan (same + // rationale as the #995 recovery-read degrade below). A fully + // resolved probe keeps propagating errors as before. // A resource declared at a former FQN whose row just migrated // away is genuinely NEW by declaration — skip the probe. Its // predecessor's physical resource still carries tags branded @@ -1180,22 +1192,31 @@ export const make = ( // and silently adopt the very resource that was renamed away. const reusesMigratedFqn = migratedRowFqns.has(fqn); let forceUpdateAfterAdoption = false; - if ( - oldState === undefined && - provider.read && - isResolved(news) && - !reusesMigratedFqn - ) { + if (oldState === undefined && provider.read && !reusesMigratedFqn) { + const newsResolved = isResolved(news); + const probeOlds = newsResolved ? news : stripUnresolved(news); const adoptInstanceId = yield* generateInstanceId(); - const readResult = yield* provider + const probe = provider .read({ id, fqn, instanceId: adoptInstanceId, - olds: news, + olds: probeOlds, output: undefined, }) .pipe(providePlanScope(fqn, adoptInstanceId)); + const readResult = yield* newsResolved + ? probe + : probe.pipe( + Effect.catchCause((cause) => + Effect.logDebug( + `Adoption probe for '${fqn}' failed with unresolved ` + + "inputs stripped; treating the resource as not " + + "pre-existing.", + cause, + ).pipe(Effect.as(undefined)), + ), + ); if (readResult !== undefined) { const isUnowned = Unowned.is(readResult); // A resource-scoped `adopt(...)` (captured on the resource at diff --git a/packages/alchemy/src/Vercel/AccessGroups/AccessGroup.ts b/packages/alchemy/src/Vercel/AccessGroups/AccessGroup.ts new file mode 100644 index 0000000000..e5d76a7fb7 --- /dev/null +++ b/packages/alchemy/src/Vercel/AccessGroups/AccessGroup.ts @@ -0,0 +1,250 @@ +import * as accessGroups from "@distilled.cloud/vercel/access_groups"; +import * as Effect from "effect/Effect"; +import { createPhysicalName } from "../../PhysicalName.ts"; +import * as Provider from "../../Provider.ts"; +import { Resource } from "../../Resource.ts"; +import type { Providers } from "../Providers.ts"; +import { listAllAccessGroups, teamScope } from "./internal.ts"; + +export type AccessGroupProps = { + /** + * Name of the access group. If omitted, a unique name is generated from + * `${app}-${stage}-${id}`. Renaming an existing access group updates it + * in place. + */ + name?: string; + /** + * Team member user IDs that belong to the access group. When set (even to + * an empty array), membership is converged exactly — members present on + * the group but absent from this list are removed. When omitted, + * membership is left unmanaged. + */ + members?: string[]; +}; + +export type AccessGroup = Resource< + "Vercel.AccessGroup", + AccessGroupProps, + { + /** ID of the access group (`ag_…`). */ + accessGroupId: string; + /** Name of the access group. */ + name: string; + /** ID of the team the access group belongs to. */ + teamId: string; + /** Timestamp in milliseconds when the access group was created. */ + createdAt: string; + /** Timestamp in milliseconds when the access group was last updated. */ + updatedAt: string; + /** Number of members in the access group. */ + membersCount: number; + /** Number of projects attached to the access group. */ + projectsCount: number; + /** User IDs of the group's members as last observed. */ + members: string[]; + }, + never, + Providers +>; + +/** + * A Vercel Access Group — a named collection of team members that can be + * granted a shared role on specific projects. + * + * Access Groups are an Enterprise-plan feature: on non-Enterprise teams every + * access-group API call (including reads) fails with a typed `Forbidden` + * error ("You don't have permission to … the access group"). + * + * @resource + * @section Creating an Access Group + * @example Basic access group + * ```typescript + * const group = yield* Vercel.AccessGroup("my-group"); + * ``` + * + * @example Access group with an explicit name + * ```typescript + * const group = yield* Vercel.AccessGroup("my-group", { + * name: "frontend-team", + * }); + * ``` + * + * @section Managing members + * @example Access group with managed membership + * ```typescript + * const group = yield* Vercel.AccessGroup("my-group", { + * members: ["uid1", "uid2"], + * }); + * ``` + * + * @section Attaching projects + * @example Grant the group a role on a project + * ```typescript + * const group = yield* Vercel.AccessGroup("my-group"); + * const grant = yield* Vercel.AccessGroupProject("my-grant", { + * accessGroup: group, + * projectId: "prj_123", + * role: "PROJECT_VIEWER", + * }); + * ``` + * + * @see https://vercel.com/docs/rbac/access-groups + */ +export const AccessGroup = Resource("Vercel.AccessGroup"); + +export const AccessGroupProvider = () => + Provider.succeed(AccessGroup, { + stables: ["accessGroupId", "teamId", "createdAt"], + read: Effect.fn(function* ({ id, output, olds }) { + const scope = yield* teamScope; + // Prefer the stable id from prior output; fall back to the + // deterministic name for the state-loss recovery path (`idOrName` + // accepts either). + const idOrName = + output?.accessGroupId ?? + olds?.name ?? + (yield* createAccessGroupName(id)); + const observed = yield* accessGroups + .readAccessGroup({ idOrName, ...scope }) + .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))); + if (observed === undefined) return undefined; + const members = yield* listAllAccessGroupMembers( + observed.accessGroupId, + scope, + ); + return { + accessGroupId: observed.accessGroupId, + name: observed.name, + teamId: observed.teamId, + createdAt: observed.createdAt, + updatedAt: observed.updatedAt, + membersCount: observed.membersCount, + projectsCount: observed.projectsCount, + members, + }; + }), + reconcile: Effect.fn(function* ({ id, news, output }) { + const scope = yield* teamScope; + // Prefer the deployed name: regenerating would target a different + // resource if the generator's output for this id ever drifts. An + // explicit `news.name` still renames the group in place. + const name = + news.name ?? output?.name ?? (yield* createAccessGroupName(id)); + + // Observe — cloud state is authoritative; `output` is only a cache of + // the stable id. + const observed = yield* accessGroups + .readAccessGroup({ + idOrName: output?.accessGroupId ?? name, + ...scope, + }) + .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))); + + // Ensure — missing → create with the desired membership. + if (observed === undefined) { + const created = yield* accessGroups.createAccessGroup({ + name, + ...(news.members !== undefined && news.members.length > 0 + ? { membersToAdd: news.members } + : {}), + ...scope, + }); + return { + accessGroupId: created.accessGroupId, + name: created.name, + teamId: created.teamId, + createdAt: created.createdAt, + updatedAt: created.updatedAt, + membersCount: created.membersCount, + projectsCount: created.projectsCount, + members: news.members ?? [], + }; + } + + // Sync — diff OBSERVED members (not olds/output) against desired and + // apply only the delta. `members` undefined = membership unmanaged. + const observedMembers = yield* listAllAccessGroupMembers( + observed.accessGroupId, + scope, + ); + const desiredMembers = news.members; + const membersToAdd = + desiredMembers?.filter((m) => !observedMembers.includes(m)) ?? []; + const membersToRemove = + desiredMembers !== undefined + ? observedMembers.filter((m) => !desiredMembers.includes(m)) + : []; + const rename = name !== observed.name; + if (rename || membersToAdd.length > 0 || membersToRemove.length > 0) { + const updated = yield* accessGroups.updateAccessGroup({ + idOrName: observed.accessGroupId, + ...(rename ? { name } : {}), + ...(membersToAdd.length > 0 ? { membersToAdd } : {}), + ...(membersToRemove.length > 0 ? { membersToRemove } : {}), + ...scope, + }); + return { + accessGroupId: updated.accessGroupId, + name: updated.name, + teamId: updated.teamId, + createdAt: updated.createdAt, + updatedAt: updated.updatedAt, + membersCount: updated.membersCount, + projectsCount: updated.projectsCount, + members: news.members ?? observedMembers, + }; + } + return { + accessGroupId: observed.accessGroupId, + name: observed.name, + teamId: observed.teamId, + createdAt: observed.createdAt, + updatedAt: observed.updatedAt, + membersCount: observed.membersCount, + projectsCount: observed.projectsCount, + members: observedMembers, + }; + }), + delete: Effect.fn(function* ({ output }) { + const scope = yield* teamScope; + yield* accessGroups + .deleteAccessGroup({ idOrName: output.accessGroupId, ...scope }) + .pipe(Effect.catchTag("NotFound", () => Effect.void)); + }), + list: Effect.fn(function* () { + const scope = yield* teamScope; + const groups = yield* listAllAccessGroups(scope); + return groups.map((g) => ({ + accessGroupId: g.accessGroupId, + name: g.name, + teamId: g.teamId, + createdAt: g.createdAt, + updatedAt: g.updatedAt, + membersCount: g.membersCount, + projectsCount: g.projectsCount, + members: [...(g.members ?? [])], + })); + }), + }); + +const createAccessGroupName = (id: string) => createPhysicalName({ id }); + +const listAllAccessGroupMembers = ( + idOrName: string, + scope: { teamId?: string }, +) => + Effect.gen(function* () { + const members: string[] = []; + let next: string | undefined; + do { + const page = yield* accessGroups.listAccessGroupMembers({ + idOrName, + limit: 100, + ...(next !== undefined ? { next } : {}), + ...scope, + }); + for (const m of page.members) members.push(m.uid); + next = page.pagination.next ?? undefined; + } while (next !== undefined); + return members; + }); diff --git a/packages/alchemy/src/Vercel/AccessGroups/AccessGroupProject.ts b/packages/alchemy/src/Vercel/AccessGroups/AccessGroupProject.ts new file mode 100644 index 0000000000..3880b1f795 --- /dev/null +++ b/packages/alchemy/src/Vercel/AccessGroups/AccessGroupProject.ts @@ -0,0 +1,280 @@ +import * as accessGroups from "@distilled.cloud/vercel/access_groups"; +import * as Effect from "effect/Effect"; +import { isResolved } from "../../Diff.ts"; +import * as Provider from "../../Provider.ts"; +import { Resource } from "../../Resource.ts"; +import type { Providers } from "../Providers.ts"; +import type { AccessGroup } from "./AccessGroup.ts"; +import { listAllAccessGroups, teamScope } from "./internal.ts"; + +/** The access group to attach the project to. */ +export type AccessGroupSource = AccessGroup | { accessGroupId: string }; + +/** The project role granted to the access group. */ +export type AccessGroupProjectRole = + | "ADMIN" + | "PROJECT_VIEWER" + | "PROJECT_DEVELOPER"; + +export type AccessGroupProjectProps = { + /** + * The access group (or `{ accessGroupId }`) to grant the role to. + * Changing it replaces the attachment. + */ + accessGroup: AccessGroupSource; + /** + * The ID of the project the role is granted on. Changing it replaces the + * attachment. + */ + projectId: string; + /** + * The project role granted to the access group's members. + */ + role: AccessGroupProjectRole; +}; + +export type AccessGroupProject = Resource< + "Vercel.AccessGroupProject", + AccessGroupProjectProps, + { + /** ID of the access group (`ag_…`). */ + accessGroupId: string; + /** ID of the project the role is granted on. */ + projectId: string; + /** The granted project role. */ + role: AccessGroupProjectRole | "PROJECT_GUEST"; + /** ID of the team the access group belongs to. */ + teamId: string; + /** Timestamp in milliseconds when the attachment was created. */ + createdAt: string; + /** Timestamp in milliseconds when the attachment was last updated. */ + updatedAt: string; + }, + never, + Providers +>; + +/** + * Attaches a Vercel Access Group to a project with a project-level role, + * granting every member of the group that role on the project. + * + * Access Groups are an Enterprise-plan feature: on non-Enterprise teams every + * access-group API call (including reads) fails with a typed `Forbidden` + * error. + * + * @resource + * @section Granting a role on a project + * @example Viewer role for a group + * ```typescript + * const group = yield* Vercel.AccessGroup("my-group"); + * const grant = yield* Vercel.AccessGroupProject("my-grant", { + * accessGroup: group, + * projectId: "prj_123", + * role: "PROJECT_VIEWER", + * }); + * ``` + * + * @example Developer role by access group id + * ```typescript + * const grant = yield* Vercel.AccessGroupProject("my-grant", { + * accessGroup: { accessGroupId: "ag_123" }, + * projectId: "prj_123", + * role: "PROJECT_DEVELOPER", + * }); + * ``` + * + * @see https://vercel.com/docs/rbac/access-groups + */ +export const AccessGroupProject = Resource( + "Vercel.AccessGroupProject", +); + +export const AccessGroupProjectProvider = () => + Provider.succeed(AccessGroupProject, { + stables: ["accessGroupId", "projectId", "teamId", "createdAt"], + diff: Effect.fn(function* ({ olds, news, output }) { + // The attachment's identity is (accessGroupId, projectId) — changing + // either replaces it. `accessGroupId` is a stable attribute of + // AccessGroup, so the planner resolves `news.accessGroup` to a plain + // object carrying it even when the group updates in place; an + // Output-valued source that didn't survive a round-trip resolves to + // undefined and falls through to the default update path. + const oldAccessGroupId = + output?.accessGroupId ?? + (olds.accessGroup !== undefined + ? maybeResolveAccessGroupId(olds.accessGroup as AccessGroupSource) + : undefined); + const newAccessGroupId = + "accessGroup" in news + ? maybeResolveAccessGroupId(news.accessGroup as AccessGroupSource) + : undefined; + if ( + oldAccessGroupId !== undefined && + newAccessGroupId !== undefined && + oldAccessGroupId !== newAccessGroupId + ) { + return { action: "replace" } as const; + } + if (!isResolved(news)) return undefined; + if ( + output?.projectId !== undefined && + news.projectId !== output.projectId + ) { + return { action: "replace" } as const; + } + return undefined; + }), + read: Effect.fn(function* ({ output, olds }) { + const scope = yield* teamScope; + const accessGroupId = + output?.accessGroupId ?? + (olds?.accessGroup !== undefined + ? maybeResolveAccessGroupId(olds.accessGroup as AccessGroupSource) + : undefined); + const projectId = output?.projectId ?? olds?.projectId; + if (accessGroupId === undefined || projectId === undefined) { + return undefined; + } + return yield* accessGroups + .readAccessGroupProject({ + accessGroupIdOrName: accessGroupId, + projectId, + ...scope, + }) + .pipe( + Effect.map(toAttributes), + Effect.catchTag("NotFound", () => Effect.succeed(undefined)), + ); + }), + reconcile: Effect.fn(function* ({ news, output }) { + const scope = yield* teamScope; + const accessGroupId = + output?.accessGroupId ?? resolveAccessGroupId(news.accessGroup); + const projectId = output?.projectId ?? news.projectId; + + // Observe — the attachment may or may not exist regardless of output. + const observed = yield* accessGroups + .readAccessGroupProject({ + accessGroupIdOrName: accessGroupId, + projectId, + ...scope, + }) + .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))); + + // Ensure — missing → create the attachment with the desired role. + if (observed === undefined) { + const created = yield* accessGroups.createAccessGroupProject({ + accessGroupIdOrName: accessGroupId, + projectId, + role: news.role, + ...scope, + }); + return toAttributes(created); + } + + // Sync — the role is the only mutable aspect; apply only the delta. + if (observed.role !== news.role) { + const updated = yield* accessGroups.updateAccessGroupProject({ + accessGroupIdOrName: accessGroupId, + projectId, + role: news.role, + ...scope, + }); + return toAttributes(updated); + } + return toAttributes(observed); + }), + delete: Effect.fn(function* ({ output }) { + const scope = yield* teamScope; + // Idempotent — a 404 (attachment or the whole group already gone) is + // success. + yield* accessGroups + .deleteAccessGroupProject({ + accessGroupIdOrName: output.accessGroupId, + projectId: output.projectId, + ...scope, + }) + .pipe(Effect.catchTag("NotFound", () => Effect.void)); + }), + // Parent fan-out: attachments are scoped to an access group and there is + // no account-wide enumeration API — enumerate every group, then list its + // project attachments. + list: Effect.fn(function* () { + const scope = yield* teamScope; + const groups = yield* listAllAccessGroups(scope); + const perGroup = yield* Effect.forEach( + groups, + (group) => + Effect.gen(function* () { + const rows = yield* listAllAccessGroupProjects( + group.accessGroupId, + scope, + ); + return rows.map((row) => ({ + accessGroupId: group.accessGroupId, + projectId: row.projectId, + role: row.role, + teamId: group.teamId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + })); + }), + { concurrency: 5 }, + ); + return perGroup.flat(); + }), + }); + +const toAttributes = (row: { + accessGroupId: string; + projectId: string; + role: AccessGroupProjectRole | "PROJECT_GUEST"; + teamId: string; + createdAt: string; + updatedAt: string; +}) => ({ + accessGroupId: row.accessGroupId, + projectId: row.projectId, + role: row.role, + teamId: row.teamId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, +}); + +const maybeResolveAccessGroupId = ( + source: AccessGroupSource, +): string | undefined => { + if (source && "accessGroupId" in source && source.accessGroupId) { + return source.accessGroupId as unknown as string; + } + return undefined; +}; + +const resolveAccessGroupId = (source: AccessGroupSource): string => { + const accessGroupId = maybeResolveAccessGroupId(source); + if (accessGroupId) return accessGroupId; + throw new Error( + "Invalid Vercel access group source: must be an AccessGroup or { accessGroupId }", + ); +}; + +const listAllAccessGroupProjects = ( + accessGroupId: string, + scope: { teamId?: string }, +) => + Effect.gen(function* () { + const rows: accessGroups.ListAccessGroupProjectsResponse["projects"][number][] = + []; + let next: string | undefined; + do { + const page = yield* accessGroups.listAccessGroupProjects({ + idOrName: accessGroupId, + limit: 100, + ...(next !== undefined ? { next } : {}), + ...scope, + }); + rows.push(...page.projects); + next = page.pagination.next ?? undefined; + } while (next !== undefined); + return rows; + }); diff --git a/packages/alchemy/src/Vercel/AccessGroups/index.ts b/packages/alchemy/src/Vercel/AccessGroups/index.ts new file mode 100644 index 0000000000..69fb6c37bd --- /dev/null +++ b/packages/alchemy/src/Vercel/AccessGroups/index.ts @@ -0,0 +1,2 @@ +export * from "./AccessGroup.ts"; +export * from "./AccessGroupProject.ts"; diff --git a/packages/alchemy/src/Vercel/AccessGroups/internal.ts b/packages/alchemy/src/Vercel/AccessGroups/internal.ts new file mode 100644 index 0000000000..3f5bcc640f --- /dev/null +++ b/packages/alchemy/src/Vercel/AccessGroups/internal.ts @@ -0,0 +1,41 @@ +// Shared scaffolding for the AccessGroups service — NOT exported from +// `AccessGroups/index.ts` (generic helper names must not leak into the flat +// `Vercel` namespace). +import * as accessGroups from "@distilled.cloud/vercel/access_groups"; +import * as Effect from "effect/Effect"; +import { VercelEnvironment } from "../VercelEnvironment.ts"; + +/** + * Vercel scopes team requests via a per-op `teamId` query parameter, resolved + * INSIDE lifecycle operations and omitted entirely when undefined (personal + * scope). + */ +export const teamScope: Effect.Effect< + { teamId?: string }, + never, + VercelEnvironment +> = Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + return teamId === undefined ? {} : { teamId }; +}); + +/** + * Exhaustively enumerate every access group in the team/account scope. + * Shared by both providers' `list` fan-outs. + */ +export const listAllAccessGroups = (scope: { teamId?: string }) => + Effect.gen(function* () { + const groups: accessGroups.ListAccessGroupsResponse["accessGroups"][number][] = + []; + let next: string | undefined; + do { + const page = yield* accessGroups.listAccessGroups({ + limit: 100, + ...(next !== undefined ? { next } : {}), + ...scope, + }); + groups.push(...page.accessGroups); + next = page.pagination.next ?? undefined; + } while (next !== undefined); + return groups; + }); diff --git a/packages/alchemy/src/Vercel/Aliases/Alias.ts b/packages/alchemy/src/Vercel/Aliases/Alias.ts new file mode 100644 index 0000000000..513b162cb0 --- /dev/null +++ b/packages/alchemy/src/Vercel/Aliases/Alias.ts @@ -0,0 +1,227 @@ +import * as aliases from "@distilled.cloud/vercel/aliases"; +import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; +import { isResolved } from "../../Diff.ts"; +import * as Provider from "../../Provider.ts"; +import { Resource } from "../../Resource.ts"; +import { VercelEnvironment } from "../VercelEnvironment.ts"; +import type { Providers } from "../Providers.ts"; + +/** + * The deployment an alias points at — a resource carrying a `deploymentId` + * (e.g. a `Vercel.Function`'s attributes) or a plain deployment id/URL. + */ +export type AliasDeploymentSource = { deploymentId: string } | string; + +export interface AliasProps { + /** + * The alias hostname to assign, e.g. `my-app-staging.vercel.app` or a + * custom domain already attached to the deployment's project. A bare + * name without a dot is normalized to `{name}.vercel.app`. Changing the + * alias name replaces the resource. + */ + alias: string; + /** + * The deployment the alias points at. Accepts a `Vercel.Function` (or + * anything carrying a `deploymentId`) or a plain deployment id. Changing + * the deployment re-points the alias in place (Vercel's assign endpoint + * is an upsert: an alias already assigned elsewhere is atomically moved + * to the new deployment). + */ + deployment: AliasDeploymentSource; +} + +export type Alias = Resource< + "Vercel.Alias", + AliasProps, + { + /** The unique identifier of the alias record. */ + uid: string; + /** The normalized alias hostname, e.g. `my-app-staging.vercel.app`. */ + alias: string; + /** `https://{alias}` — the URL the alias serves on. */ + url: string; + /** The deployment the alias currently points at. */ + deploymentId: string | undefined; + /** The project owning the aliased deployment. */ + projectId: string | undefined; + }, + never, + Providers +>; + +type AliasAttributes = Alias["Attributes"]; + +/** + * A stable hostname pointed at a specific Vercel deployment. + * + * Deployments are immutable and retained (alchemy never prunes them by + * default), so an `Alias` is the primitive for pinned version URLs and + * instant traffic re-points: re-pointing the alias at another retained + * deployment is a pure traffic operation — no rebuild. For re-pointing the + * *production* alias itself, use the {@link promoteToProduction} / + * {@link rollbackProduction} runtime actions instead of managing the + * production alias as a resource. + * + * Note (live-verified): on team accounts with default deployment + * protection, a `.vercel.app` alias is SSO-gated like any deployment URL — + * only the auto-assigned production domain and custom domains are public. + * Drive gated aliases with an automation bypass secret + * (`x-vercel-protection-bypass`), minted *before* the aliased deployment + * was created. + * + * Note (live-verified): {@link rollbackProduction} / + * {@link promoteToProduction} SWEEP custom aliases — every alias riding the + * outgoing production deployment is re-pointed to the rollback/promote + * target along with the production domains. An `Alias` pinned to the + * *active production* deployment therefore tracks production through those + * actions; pin a non-production (e.g. superseded) deployment when you need + * a version URL that stays put. + * + * @resource + * @section Aliasing a deployment + * @example Pin a stable URL to the current deployment + * ```typescript + * const api = yield* Vercel.Function("Api", { main: "./src/api.ts" }); + * const stable = yield* Vercel.Alias("Stable", { + * alias: "my-app-staging.vercel.app", + * deployment: api, + * }); + * // stable.url -> https://my-app-staging.vercel.app + * ``` + * + * @example Alias a custom domain to a specific deployment + * ```typescript + * yield* Vercel.Alias("Canary", { + * alias: "canary.acme.com", // domain already on the project + * deployment: "dpl_123abc", + * }); + * ``` + * + * @see https://vercel.com/docs/deployments/promoting-a-deployment + */ +export const Alias = Resource("Vercel.Alias"); + +/** + * Normalize an alias name: strip a protocol prefix and trailing slash, + * lowercase, and append `.vercel.app` when the name carries no dot. + */ +export const normalizeAlias = (name: string): string => { + const bare = name + .replace(/^https?:\/\//, "") + .replace(/\/+$/, "") + .toLowerCase(); + return bare.includes(".") ? bare : `${bare}.vercel.app`; +}; + +const resolveDeploymentId = ( + source: AliasDeploymentSource | undefined, +): string | undefined => { + if (source === undefined) return undefined; + if (typeof source === "string") return source; + if ("deploymentId" in source && typeof source.deploymentId === "string") { + return source.deploymentId; + } + return undefined; +}; + +const toAttributes = (observed: aliases.GetAliasResponse): AliasAttributes => ({ + uid: observed.uid, + alias: observed.alias, + url: `https://${observed.alias}`, + deploymentId: observed.deploymentId ?? undefined, + projectId: observed.projectId ?? undefined, +}); + +const observeAlias = (aliasName: string) => + Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + return yield* aliases + .getAlias({ idOrAlias: aliasName, teamId }) + .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))); + }); + +export const AliasProvider = () => + Provider.succeed(Alias, { + stables: ["alias", "url"], + diff: Effect.fn(function* ({ news, output }) { + if (!isResolved(news)) return undefined; + if (!output) return undefined; + if (normalizeAlias(news.alias) !== output.alias) { + return { action: "replace" } as const; + } + return undefined; + }), + read: Effect.fn(function* ({ olds, output }) { + const aliasName = + output?.alias ?? + (olds?.alias !== undefined ? normalizeAlias(olds.alias) : undefined); + if (aliasName === undefined) return undefined; + const observed = yield* observeAlias(aliasName); + if (observed === undefined) return undefined; + return toAttributes(observed); + }), + reconcile: Effect.fn(function* ({ id, news, output }) { + const { teamId } = yield* VercelEnvironment.current; + const aliasName = output?.alias ?? normalizeAlias(news.alias); + const deploymentId = resolveDeploymentId(news.deployment); + if (deploymentId === undefined) { + return yield* Effect.die( + `Vercel.Alias(${id}): invalid deployment source — must be a Function, { deploymentId } or a plain deployment id`, + ); + } + if (deploymentId === "") { + // The upstream is a precreate STUB (a Function's `deploymentId` is + // `""` until its first deploy resolves — engine phase 1 may hand + // consumers of cycle members the stub output). Return an inert row + // instead of calling the API with an empty id; phase-3 convergence + // re-runs reconcile with the final deployment id (the props delta + // `"" -> dpl_…` marks this node changed). + return { + uid: "", + alias: aliasName, + url: `https://${aliasName}`, + deploymentId: undefined, + projectId: undefined, + } satisfies AliasAttributes; + } + + // Observe — the alias may already exist (crash recovery, re-point). + let observed = yield* observeAlias(aliasName); + + if (observed === undefined || observed.deploymentId !== deploymentId) { + // Ensure/sync — assignAlias is an upsert (live-verified): it + // creates the alias or atomically re-points an existing one at the + // new deployment. + yield* aliases.assignAlias({ + id: deploymentId, + alias: aliasName, + teamId, + }); + // Bounded wait for read-your-write consistency on the alias record. + observed = yield* observeAlias(aliasName).pipe( + Effect.repeat({ + schedule: Schedule.spaced("1 second"), + until: (a) => a !== undefined && a.deploymentId === deploymentId, + times: 5, + }), + ); + if (observed === undefined) { + return yield* Effect.die( + `Vercel.Alias(${id}): alias ${aliasName} not observable after assign to deployment ${deploymentId}`, + ); + } + } + + return toAttributes(observed); + }), + delete: Effect.fn(function* ({ output }) { + const { teamId } = yield* VercelEnvironment.current; + // deleteAlias accepts the alias id or hostname; the uid is stable + // across re-points, the hostname is the fallback for legacy rows and + // for inert stub rows (uid `""`, never assigned — NotFound is caught). + yield* aliases + .deleteAlias({ aliasId: output.uid || output.alias, teamId }) + .pipe(Effect.catchTag("NotFound", () => Effect.void)); + }), + }); diff --git a/packages/alchemy/src/Vercel/Aliases/Promote.ts b/packages/alchemy/src/Vercel/Aliases/Promote.ts new file mode 100644 index 0000000000..ab421a6d80 --- /dev/null +++ b/packages/alchemy/src/Vercel/Aliases/Promote.ts @@ -0,0 +1,105 @@ +/** + * Promote / rollback runtime actions — plain Effects, not resources. + * + * The platform's promote and rollback are dedicated endpoints on the + * `projects` service (`POST /v10/projects/{projectId}/promote/{deploymentId}` + * and `POST /v1/projects/{projectId}/rollback/{deploymentId}`), NOT alias + * re-assignment by the caller: Vercel re-points the project's production + * alias(es) itself and records the operation in the project's + * `lastAliasRequest` (`type: "promote" | "rollback"`). Both are pure + * traffic operations over already-built deployments — the promote endpoint + * documents "this does NOT rebuild the deployment". + */ +import * as projects from "@distilled.cloud/vercel/projects"; +import * as Effect from "effect/Effect"; +import { VercelEnvironment } from "../VercelEnvironment.ts"; + +/** + * The project (and optionally the deployment) a promote/rollback targets — + * a resource carrying `projectId` (e.g. a `Vercel.Function`'s attributes, + * whose `deploymentId` is its current deployment) or a plain project id. + */ +export type PromoteTarget = + | { projectId: string; deploymentId?: string } + | string; + +const resolveTarget = ( + target: PromoteTarget, + deploymentId: string | undefined, + action: string, +) => + Effect.gen(function* () { + const projectId = typeof target === "string" ? target : target.projectId; + const resolved = + deploymentId ?? + (typeof target === "string" ? undefined : target.deploymentId); + if (resolved === undefined) { + return yield* Effect.die( + `Vercel.${action}: no deploymentId — pass one explicitly or target a resource carrying a deploymentId (e.g. a Vercel.Function)`, + ); + } + return { projectId, deploymentId: resolved }; + }); + +/** + * Point a project's production alias(es) at the given deployment. + * + * A pure traffic re-point over an already-built deployment (retained + * deployments are never rebuilt). Defaults to the target's own + * `deploymentId` when a `Vercel.Function` (or its attributes) is passed, + * so `promoteToProduction(fn)` promotes the function's current deployment. + * + * ```typescript + * // promote a specific retained deployment to production + * yield* Vercel.promoteToProduction(fn.projectId, "dpl_123abc"); + * ``` + */ +export const promoteToProduction = ( + target: PromoteTarget, + deploymentId?: string, +) => + Effect.gen(function* () { + const resolved = yield* resolveTarget( + target, + deploymentId, + "promoteToProduction", + ); + const { teamId } = yield* VercelEnvironment.current; + yield* projects.requestPromote({ ...resolved, teamId }); + }); + +/** + * Roll production back to a previous production deployment. + * + * Instant rollback: like {@link promoteToProduction} this only re-points + * production traffic — nothing is rebuilt — but the endpoint additionally + * verifies the target was previously promoted and records the operation as + * a rollback (with the optional `description`) in the project's + * `lastAliasRequest`. + * + * ```typescript + * yield* Vercel.rollbackProduction(fn.projectId, previousDeploymentId, { + * description: "beta.46 regression", + * }); + * ``` + */ +export const rollbackProduction = ( + target: PromoteTarget, + deploymentId?: string, + options?: { description?: string }, +) => + Effect.gen(function* () { + const resolved = yield* resolveTarget( + target, + deploymentId, + "rollbackProduction", + ); + const { teamId } = yield* VercelEnvironment.current; + yield* projects.requestRollback({ + ...resolved, + teamId, + ...(options?.description !== undefined + ? { description: options.description } + : {}), + }); + }); diff --git a/packages/alchemy/src/Vercel/Aliases/index.ts b/packages/alchemy/src/Vercel/Aliases/index.ts new file mode 100644 index 0000000000..86a7b25e1b --- /dev/null +++ b/packages/alchemy/src/Vercel/Aliases/index.ts @@ -0,0 +1,2 @@ +export * from "./Alias.ts"; +export * from "./Promote.ts"; diff --git a/packages/alchemy/src/Vercel/Analytics/WebAnalytics.ts b/packages/alchemy/src/Vercel/Analytics/WebAnalytics.ts new file mode 100644 index 0000000000..edccc1deb2 --- /dev/null +++ b/packages/alchemy/src/Vercel/Analytics/WebAnalytics.ts @@ -0,0 +1,164 @@ +import * as projects from "@distilled.cloud/vercel/projects"; +import * as webAnalytics from "@distilled.cloud/vercel/web_analytics"; +import * as Effect from "effect/Effect"; +import { Unowned } from "../../AdoptPolicy.ts"; +import { isResolved } from "../../Diff.ts"; +import * as Provider from "../../Provider.ts"; +import { Resource } from "../../Resource.ts"; +import { VercelEnvironment } from "../VercelEnvironment.ts"; +import type { Providers } from "../Providers.ts"; + +export interface WebAnalyticsProps { + /** + * The project (ID or name) to enable Web Analytics on. Changing the + * project replaces the resource (analytics is toggled off on the old + * project and on for the new one). + */ + project: string; +} + +export type WebAnalytics = Resource< + "Vercel.WebAnalytics", + WebAnalyticsProps, + { + /** Resolved ID of the project analytics is enabled on. */ + projectId: string; + /** ID of the project's Web Analytics instance. */ + analyticsId: string; + /** Timestamp (ms) when analytics was (last) enabled. */ + enabledAt: number | undefined; + /** Timestamp (ms) when analytics was last disabled, if ever. */ + disabledAt: number | undefined; + }, + never, + Providers +>; + +type WebAnalyticsAttributes = WebAnalytics["Attributes"]; + +/** + * Vercel Web Analytics enablement for a project. + * + * The platform models analytics as a per-project toggle (`POST + * /web/insights/toggle`); this resource declares the toggle **on** while it + * exists and toggles it back **off** on destroy. The observed state lives on + * the project itself (`project.webAnalytics`). + * + * Note that collecting page views additionally requires the + * `@vercel/analytics` script in the deployed app — this resource manages + * only the platform-side enablement. + * + * @resource + * @section Enabling Web Analytics + * @example Enable analytics on a project + * ```typescript + * const project = yield* Vercel.Project("Site", {}); + * yield* Vercel.WebAnalytics("Analytics", { + * project: project.projectId, + * }); + * ``` + * + * @see https://vercel.com/docs/analytics + */ +export const WebAnalytics = Resource("Vercel.WebAnalytics"); + +/** + * Observe the project's analytics state. Returns `undefined` when the + * project doesn't exist; `{ projectId, analytics }` otherwise (with + * `analytics` undefined when never enabled). + */ +const observeProjectAnalytics = (idOrName: string) => + Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + return yield* projects.getProject({ idOrName, teamId }).pipe( + Effect.map((project) => ({ + projectId: project.id, + analytics: project.webAnalytics, + })), + Effect.catchTag("NotFound", () => Effect.succeed(undefined)), + ); + }); + +const isEnabled = ( + analytics: projects.CreateProjectResponseWebAnalytics | undefined, +): analytics is projects.CreateProjectResponseWebAnalytics => + analytics !== undefined && + (analytics.disabledAt === undefined || + (analytics.enabledAt ?? 0) > analytics.disabledAt); + +const toAttributes = ( + projectId: string, + analytics: projects.CreateProjectResponseWebAnalytics, +): WebAnalyticsAttributes => ({ + projectId, + analyticsId: analytics.id, + enabledAt: analytics.enabledAt, + disabledAt: analytics.disabledAt, +}); + +export const WebAnalyticsProvider = () => + Provider.succeed(WebAnalytics, { + stables: ["projectId", "analyticsId"], + diff: Effect.fn(function* ({ olds, news, output }) { + if (!isResolved(news)) return undefined; + if (!output) return undefined; + // The toggle is bound to its project — a different project is a + // different resource. + if (news.project !== olds.project) { + return { action: "replace" } as const; + } + return undefined; + }), + read: Effect.fn(function* ({ olds, output }) { + const idOrName = output?.projectId ?? olds?.project; + if (idOrName === undefined) return undefined; + const observed = yield* observeProjectAnalytics(idOrName); + if (observed === undefined || !isEnabled(observed.analytics)) { + return undefined; + } + const attrs = toAttributes(observed.projectId, observed.analytics); + // Analytics already enabled without prior state may have been enabled + // out-of-band — gate takeover behind `--adopt`. + return output !== undefined ? attrs : Unowned(attrs); + }), + reconcile: Effect.fn(function* ({ news }) { + const { teamId } = yield* VercelEnvironment.current; + + // Observe — the project's own `webAnalytics` field is the truth. + const observed = yield* observeProjectAnalytics(news.project); + if (observed === undefined) { + return yield* Effect.die( + `Vercel.WebAnalytics: project ${news.project} not found`, + ); + } + + // Ensure — toggle on only when not already enabled. + if (!isEnabled(observed.analytics)) { + yield* webAnalytics.createWebInsightsToggle({ + projectId: observed.projectId, + value: true, + teamId, + }); + } + + // Return — re-read the final state. + const fresh = yield* observeProjectAnalytics(observed.projectId); + if (fresh === undefined || !isEnabled(fresh.analytics)) { + return yield* Effect.die( + `Vercel.WebAnalytics: analytics not observable on project ${observed.projectId} after enabling`, + ); + } + return toAttributes(fresh.projectId, fresh.analytics); + }), + delete: Effect.fn(function* ({ output }) { + const { teamId } = yield* VercelEnvironment.current; + // Toggling off a project that's already gone is not an error. + yield* webAnalytics + .createWebInsightsToggle({ + projectId: output.projectId, + value: false, + teamId, + }) + .pipe(Effect.catchTag("NotFound", () => Effect.void)); + }), + }); diff --git a/packages/alchemy/src/Vercel/Analytics/index.ts b/packages/alchemy/src/Vercel/Analytics/index.ts new file mode 100644 index 0000000000..2c841df08b --- /dev/null +++ b/packages/alchemy/src/Vercel/Analytics/index.ts @@ -0,0 +1 @@ +export * from "./WebAnalytics.ts"; diff --git a/packages/alchemy/src/Vercel/AuthProvider.ts b/packages/alchemy/src/Vercel/AuthProvider.ts new file mode 100644 index 0000000000..cc19dde045 --- /dev/null +++ b/packages/alchemy/src/Vercel/AuthProvider.ts @@ -0,0 +1,331 @@ +import * as VercelCredentials from "@distilled.cloud/vercel/Credentials"; +import * as vercelTeams from "@distilled.cloud/vercel/teams"; +import * as vercelUser from "@distilled.cloud/vercel/user"; +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Match from "effect/Match"; +import * as Redacted from "effect/Redacted"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import { + AuthError, + AuthProviderLayer, + type ConfigureContext, +} from "../Auth/AuthProvider.ts"; +import { CredentialsStore, displayRedacted } from "../Auth/Credentials.ts"; +import { getEnv, getEnvRedacted, retryOnce } from "../Auth/Env.ts"; +import { AlchemyProfile } from "../Auth/Profile.ts"; +import * as Clank from "../Util/Clank.ts"; + +export const VERCEL_AUTH_PROVIDER_NAME = "Vercel"; + +const STORAGE_KEY = "vercel-stored"; + +const TOKEN_CREATION_URL = "https://vercel.com/account/settings/tokens"; + +export type VercelAuthConfig = { method: "env" } | { method: "stored" }; + +export type VercelStoredCredentials = { + type: "apiToken"; + apiToken: string; + teamId?: string; +}; + +export type VercelResolvedCredentials = { + type: "apiToken"; + apiToken: Redacted.Redacted; + teamId?: string; + source: { type: VercelAuthConfig["method"]; details?: string }; +}; + +const options: Array<{ + value: VercelAuthConfig["method"]; + label: string; + hint?: string; +}> = [ + { + value: "env", + label: "Environment Variables", + hint: "VERCEL_TOKEN (or VERCEL_API_TOKEN) + optional VERCEL_TEAM_ID", + }, + { + value: "stored", + label: "API Token", + hint: "enter interactively, stored in ~/.alchemy/credentials", + }, +]; + +/** + * Provide a temporary in-memory distilled credentials layer so the token can + * be verified (and teams listed) before anything is persisted. + */ +const withTokenCredentials = ( + token: string, + effect: Effect.Effect< + A, + E, + VercelCredentials.Credentials | HttpClient.HttpClient + >, +): Effect.Effect => + Effect.provide( + effect, + Layer.mergeAll( + VercelCredentials.credentials({ token }), + FetchHttpClient.layer, + ), + ); + +/** Verify the token works by fetching the authenticated user. */ +const verifyToken = (token: string) => + Effect.gen(function* () { + const getAuthUser = yield* vercelUser.getAuthUser; + yield* getAuthUser({}); + }).pipe( + (e) => withTokenCredentials(token, e), + Effect.mapError( + (e) => + new AuthError({ + message: + "Vercel: token verification failed. Check the token and its scope.", + cause: e, + }), + ), + ); + +/** + * List the teams the token can see and prompt for one (Cloudflare + * `selectAccount` pattern). A full-account token spans every team the user + * belongs to; a team-scoped token only lists its own team. Returns + * `undefined` for personal scope (no teams, or the user skipped). + */ +const selectTeam = (token: string) => + Effect.gen(function* () { + const getTeams = yield* vercelTeams.getTeams; + const response = yield* getTeams({ limit: 100 }).pipe( + Effect.mapError( + (e) => + new AuthError({ message: "Vercel: failed to list teams", cause: e }), + ), + ); + const teams = response.teams; + if (teams.length === 0) { + return undefined; + } + const selected = yield* Clank.select({ + message: "Select a Vercel team", + options: [ + { + value: "", + label: "Personal account", + hint: "Enter to skip — personal scope", + }, + ...teams.map((team) => ({ + value: team.id, + label: team.name ?? team.slug, + hint: team.id, + })), + ], + }).pipe(retryOnce); + return selected.length === 0 ? undefined : selected; + }).pipe((e) => withTokenCredentials(token, e)); + +/** + * Layer that registers the Vercel {@link AuthProvider} into the + * {@link AuthProviders} registry. + */ +export const VercelAuth = AuthProviderLayer< + VercelAuthConfig, + VercelResolvedCredentials +>()( + VERCEL_AUTH_PROVIDER_NAME, + Effect.gen(function* () { + const profiles = yield* AlchemyProfile; + const store = yield* CredentialsStore; + + const loginStored = Effect.fn(function* (profileName: string) { + yield* Clank.info( + `Create a token at ${TOKEN_CREATION_URL} (scope it to your team, or Full Account)`, + ); + const apiToken = yield* Clank.password({ + message: "Vercel API Token", + validate: (v) => (v.length === 0 ? "Required" : undefined), + }).pipe(retryOnce); + + yield* verifyToken(apiToken); + const teamId = yield* selectTeam(apiToken); + + yield* store.write(profileName, STORAGE_KEY, { + type: "apiToken", + apiToken, + teamId, + }); + yield* Clank.success("Vercel: credentials saved."); + return { method: "stored" as const }; + }); + + const configureInteractive = (profileName: string) => + Clank.select({ + message: "Vercel authentication method", + options, + }).pipe( + Effect.flatMap((method) => + Match.value(method).pipe( + Match.when("env", () => Effect.succeed({ method: "env" as const })), + Match.when("stored", () => loginStored(profileName)), + Match.exhaustive, + ), + ), + ); + + const configureCredentials = (profileName: string, ctx: ConfigureContext) => + Effect.gen(function* () { + if (ctx.ci) { + return { method: "env" as const }; + } + return yield* configureInteractive(profileName); + }).pipe( + Effect.mapError( + (e) => + new AuthError({ + message: "failed to configure credentials", + cause: e, + }), + ), + ); + + const resolveCredentials = ( + profileName: string, + config: VercelAuthConfig, + ): Effect.Effect => + Match.value(config).pipe( + Match.when( + { method: "env" }, + Effect.fn(function* () { + const apiToken = + (yield* getEnvRedacted("VERCEL_TOKEN")) ?? + (yield* getEnvRedacted("VERCEL_API_TOKEN")); + if (!apiToken) { + return yield* new AuthError({ + message: + "Vercel env credentials not found. Set VERCEL_TOKEN (or VERCEL_API_TOKEN).", + }); + } + const teamId = yield* getEnv("VERCEL_TEAM_ID"); + return { + type: "apiToken" as const, + apiToken, + teamId, + source: { type: "env" as const }, + }; + }), + ), + Match.when({ method: "stored" }, () => + store.read(profileName, STORAGE_KEY).pipe( + Effect.flatMap((creds) => + creds == null + ? Effect.fail( + new AuthError({ + message: + "Vercel stored credentials not found. Run: alchemy login --configure", + }), + ) + : Effect.succeed({ + type: "apiToken" as const, + apiToken: Redacted.make(creds.apiToken), + teamId: creds.teamId, + source: { type: "stored" as const }, + }), + ), + ), + ), + Match.exhaustive, + ); + + const logout = (profileName: string, config: VercelAuthConfig) => + Match.value(config).pipe( + Match.when({ method: "env" }, () => Effect.void), + Match.when({ method: "stored" }, () => + store + .delete(profileName, STORAGE_KEY) + .pipe( + Effect.andThen( + Clank.success("Vercel: stored credentials removed"), + ), + ), + ), + Match.exhaustive, + ); + + const login = (profileName: string, config: VercelAuthConfig) => + Match.value(config) + .pipe( + Match.when({ method: "env" }, () => + // If VERCEL_TOKEN isn't set, fall through to the interactive picker + // so the user can switch to `stored` (or be told to set the env + // var) instead of silently failing later in `read`. The new + // selection is persisted to the profile so subsequent logins + // don't re-prompt. + getEnvRedacted("VERCEL_TOKEN").pipe( + Effect.flatMap((token) => + token + ? Effect.void + : getEnvRedacted("VERCEL_API_TOKEN").pipe( + Effect.flatMap((fallback) => + fallback + ? Effect.void + : Effect.gen(function* () { + const next = + yield* configureInteractive(profileName); + const existing = + yield* profiles.getProfile(profileName); + yield* profiles.setProfile(profileName, { + ...existing, + [VERCEL_AUTH_PROVIDER_NAME]: next, + }); + }), + ), + ), + ), + ), + ), + Match.when({ method: "stored" }, () => + store + .read(profileName, STORAGE_KEY) + .pipe( + Effect.flatMap((creds) => + creds == null ? loginStored(profileName) : Effect.void, + ), + ), + ), + Match.exhaustive, + ) + .pipe( + Effect.mapError( + (e) => new AuthError({ message: "login failed", cause: e }), + ), + ); + + const prettyPrint = (profileName: string, config: VercelAuthConfig) => + resolveCredentials(profileName, config).pipe( + Effect.tap((creds) => { + const sourceStr = creds.source.details + ? `${creds.source.type} - ${creds.source.details}` + : creds.source.type; + return Effect.all([ + Console.log(` apiToken: ${displayRedacted(creds.apiToken, 9)}`), + Console.log(` team: ${creds.teamId ?? "(personal)"}`), + Console.log(` source: ${sourceStr}`), + ]); + }), + ); + + return { + configure: configureCredentials, + logout, + login, + prettyPrint, + read: resolveCredentials, + }; + }), +); diff --git a/packages/alchemy/src/Vercel/Blob/BlobFromEnv.ts b/packages/alchemy/src/Vercel/Blob/BlobFromEnv.ts new file mode 100644 index 0000000000..abc0feb51b --- /dev/null +++ b/packages/alchemy/src/Vercel/Blob/BlobFromEnv.ts @@ -0,0 +1,350 @@ +/** + * Promise-based Blob client for **plain async Functions** (no Effect + * runtime exposed to the caller). Rides the same distilled Blob data-plane + * operations (`@distilled.cloud/vercel/blob_data`) as the Effect bindings — + * one live-verified wire protocol for every client — run one-shot on a + * fetch-backed HttpClient with failures surfaced as plain `Error`s. + * For Effect code use the `ReadBlob`/`WriteBlob`/`ReadWriteBlob` bindings. + */ +import * as blobData from "@distilled.cloud/vercel/blob_data"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import * as Result from "effect/Result"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import { + BLOB_API_URL_ENV, + bareStoreId, + blobUrlOf, + contentUrlOf, + DEFAULT_BLOB_API_URL, + type BlobScope, +} from "./BlobHttp.ts"; +import { + BLOB_TOKEN_ENV, + type BlobObject, + type ListBlobsOptions, + type ListBlobsResult, + type PutBlobBody, + type PutBlobOptions, + type PutBlobResult, +} from "./BlobTypes.ts"; + +export interface BlobFromEnvOptions { + /** + * The store's RW token. Defaults to the `BLOB_READ_WRITE_TOKEN` env var + * the platform injects into every connected project. + */ + readonly token?: string; + /** + * The store's access mode — determines the canonical content URL host and + * the `x-vercel-blob-access` header on writes. + * @default "public" + */ + readonly access?: "public" | "private"; + /** + * Explicit data-plane endpoint override for THIS client (out-of-band use, + * e.g. a test process pointing at the local emulator). Inside a Function + * you never need it: the distilled operations read the + * `VERCEL_BLOB_API_URL` env var per request, which dev-mode emulation + * injects automatically. + */ + readonly apiUrl?: string; +} + +/** Result of an async `get`. */ +export interface AsyncGetBlobResult { + readonly pathname: string; + readonly url: string; + readonly contentType?: string | undefined; + readonly etag?: string | undefined; + readonly size: number; + readonly bytes: Uint8Array; + readonly text: string; +} + +/** Promise-based read/write Blob client for plain async Functions. */ +export interface AsyncReadWriteBlobClient { + put( + pathname: string, + body: PutBlobBody, + options?: PutBlobOptions, + ): Promise; + head(pathname: string): Promise; + get(pathname: string): Promise; + list(options?: ListBlobsOptions): Promise; + del(pathnames: string | readonly string[]): Promise; +} + +const resolveToken = (options?: BlobFromEnvOptions): string => { + const token = options?.token ?? process.env[BLOB_TOKEN_ENV]; + if (token === undefined || token === "") { + throw new Error( + `Vercel.readWriteBlobFromEnv: no ${BLOB_TOKEN_ENV} in the environment — is the Function's project connected to the blob store?`, + ); + } + return token; +}; + +/** Derive the bare store id from the token (`vercel_blob_rw_{storeId}_…`). */ +const storeIdOfToken = (token: string): string => { + const part = token.split("_")[3]; + if (part === undefined || part === "") { + throw new Error( + "Vercel.readWriteBlobFromEnv: cannot derive the store id from the token (expected a vercel_blob_rw_… token)", + ); + } + return part; +}; + +/** The full error union across the distilled Blob data-plane operations. */ +type BlobDataError = + | blobData.PutBlobError + | blobData.HeadBlobError + | blobData.GetBlobContentError + | blobData.ListBlobsError + | blobData.DeleteBlobsError; + +/** HTTP status behind each typed data-plane error tag (for error messages). */ +const ERROR_STATUS: Record = { + BlobAlreadyExists: 400, + BlobBadRequest: 400, + BlobUnauthorized: 401, + BlobForbidden: 403, + BlobNotFound: 404, + BlobPreconditionFailed: 412, + Unauthorized: 401, + PaymentRequired: 402, + Gone: 410, + TooManyRequests: 429, + InternalServerError: 500, + BadGateway: 502, + ServiceUnavailable: 503, + GatewayTimeout: 504, +}; + +const statusOf = (error: BlobDataError): number | undefined => + error._tag === "HttpClientError" + ? error.response?.status + : ERROR_STATUS[error._tag]; + +const stripTrailingSlash = (value: string): string => value.replace(/\/+$/, ""); + +const PER_STORE_HOST_SUFFIX = ".blob.vercel-storage.com"; + +/** + * Re-home a data-plane request onto an explicit `apiUrl` override (used + * out-of-band, where injecting `VERCEL_BLOB_API_URL` process-wide would + * race concurrent live-endpoint calls). Mirrors the distilled protocol's + * own override mapping: control ops move to `{override}`, per-store content + * reads to `{override}/{store}`. + */ +const rehomeRequest = + (override: string) => + ( + request: HttpClientRequest.HttpClientRequest, + ): HttpClientRequest.HttpClientRequest => { + const base = stripTrailingSlash(override); + // Already re-homed by a VERCEL_BLOB_API_URL env override — swap prefixes. + const envOverride = process.env[BLOB_API_URL_ENV]; + if (envOverride !== undefined && envOverride !== "") { + const envBase = stripTrailingSlash(envOverride); + return request.url.startsWith(envBase) + ? HttpClientRequest.setUrl( + request, + `${base}${request.url.slice(envBase.length)}`, + ) + : request; + } + const url = new URL(request.url); + // Control operations ride the default endpoint. + if (url.origin === DEFAULT_BLOB_API_URL) { + return HttpClientRequest.setUrl( + request, + `${base}${url.pathname}${url.search}`, + ); + } + // Content reads ride the per-store host — move to the override's path form. + if (url.hostname.endsWith(PER_STORE_HOST_SUFFIX)) { + const store = url.hostname.slice(0, -PER_STORE_HOST_SUFFIX.length); + return HttpClientRequest.setUrl( + request, + `${base}/${store}${url.pathname}${url.search}`, + ); + } + return request; + }; + +/** + * Build a promise-based Blob client for a plain async Function from the + * ambient environment: the platform-injected `BLOB_READ_WRITE_TOKEN` of the + * connected store, plus the store's access mode (needed to address blob + * content; pass `access: "private"` for private stores). + * + * @example Async handler + * ```typescript + * import { readWriteBlobFromEnv } from "alchemy/Vercel"; + * const uploads = readWriteBlobFromEnv({ access: "private" }); + * + * export default { + * async fetch(request: Request): Promise { + * const put = await uploads.put("hello.txt", "hi", { contentType: "text/plain" }); + * const blob = await uploads.get("hello.txt"); + * return Response.json({ etag: put.etag, text: blob.text }); + * }, + * }; + * ``` + */ +export const readWriteBlobFromEnv = ( + options?: BlobFromEnvOptions, +): AsyncReadWriteBlobClient => { + const access = options?.access ?? "public"; + const apiUrlOverride = options?.apiUrl; + const scopeOf = (token: string): BlobScope => ({ + token: Redacted.make(token), + storeId: storeIdOfToken(token), + access, + apiUrl: + apiUrlOverride ?? process.env[BLOB_API_URL_ENV] ?? DEFAULT_BLOB_API_URL, + }); + // Same distilled data-plane ops as the Effect clients — endpoint + // resolution (`VERCEL_BLOB_API_URL`), `x-api-version`, and the typed + // error matchers all live there. Run one-shot on a fetch-backed client; + // failures become plain Errors. + const run = async ( + operation: string, + effect: Effect.Effect, + ): Promise => { + const rehomed = + apiUrlOverride === undefined + ? effect + : effect.pipe( + Effect.updateService(HttpClient.HttpClient, (client) => + HttpClient.mapRequest(client, rehomeRequest(apiUrlOverride)), + ), + ); + const result = await Effect.runPromise( + Effect.result(rehomed).pipe(Effect.provide(FetchHttpClient.layer)), + ); + if (Result.isFailure(result)) { + const cause = result.failure; + const status = statusOf(cause); + const message = + "message" in cause && cause.message !== undefined + ? String(cause.message) + : cause._tag; + throw new Error( + `Vercel.readWriteBlobFromEnv: ${operation} failed with ${status ?? cause._tag}: ${message}`, + { cause }, + ); + } + return result.success; + }; + + return { + async put(pathname, body, putOptions) { + const scope = scopeOf(resolveToken(options)); + const response = await run( + "put", + blobData.putBlob({ + token: scope.token, + pathname, + access, + contentType: putOptions?.contentType ?? "application/octet-stream", + ifMatch: putOptions?.ifMatch, + // The data plane's WIRE default (header absent) is to REFUSE + // overwrites — send the header explicitly so the documented + // client default (`allowOverwrite: true`) actually overwrites, + // matching the Effect client (`putBlobRaw`). + allowOverwrite: putOptions?.allowOverwrite === false ? "0" : "1", + body, + }), + ); + return { + url: response.url, + downloadUrl: response.downloadUrl, + pathname: response.pathname, + contentType: response.contentType, + contentDisposition: response.contentDisposition, + etag: response.etag, + } satisfies PutBlobResult; + }, + async head(pathname) { + const scope = scopeOf(resolveToken(options)); + const response = await run( + "head", + blobData.headBlob({ + token: scope.token, + url: blobUrlOf(scope, pathname), + }), + ); + return { + url: response.url, + downloadUrl: response.downloadUrl, + pathname: response.pathname, + contentType: response.contentType, + contentDisposition: response.contentDisposition, + size: response.size, + uploadedAt: new Date(response.uploadedAt), + cacheControl: response.cacheControl, + etag: response.etag, + } satisfies BlobObject; + }, + async get(pathname) { + const scope = scopeOf(resolveToken(options)); + const response = await run( + "get", + blobData.getBlobContent({ + store: `${bareStoreId(scope.storeId).toLowerCase()}.${access}`, + token: scope.token, + pathname, + }), + ); + const bytes = response.body; + return { + pathname, + url: contentUrlOf(scope, pathname), + contentType: response.contentType, + etag: response.etag, + size: bytes.byteLength, + bytes, + text: new TextDecoder().decode(bytes), + }; + }, + async list(listOptions) { + const scope = scopeOf(resolveToken(options)); + const response = await run( + "list", + blobData.listBlobs({ + token: scope.token, + prefix: listOptions?.prefix, + limit: listOptions?.limit, + cursor: listOptions?.cursor, + }), + ); + return { + hasMore: response.hasMore, + cursor: response.cursor, + blobs: response.blobs.map((blob) => ({ + url: blob.url, + downloadUrl: blob.downloadUrl, + pathname: blob.pathname, + size: blob.size, + uploadedAt: new Date(blob.uploadedAt), + etag: blob.etag, + })), + } satisfies ListBlobsResult; + }, + async del(pathnames) { + const scope = scopeOf(resolveToken(options)); + const list = typeof pathnames === "string" ? [pathnames] : pathnames; + const urls = list.map((pathname) => + // Accept full content URLs (canonical https, or the local + // emulator's http form) as well as bare pathnames. + /^https?:\/\//.test(pathname) ? pathname : blobUrlOf(scope, pathname), + ); + await run("del", blobData.deleteBlobs({ token: scope.token, urls })); + }, + }; +}; diff --git a/packages/alchemy/src/Vercel/Blob/BlobHttp.ts b/packages/alchemy/src/Vercel/Blob/BlobHttp.ts new file mode 100644 index 0000000000..aca4ec16af --- /dev/null +++ b/packages/alchemy/src/Vercel/Blob/BlobHttp.ts @@ -0,0 +1,438 @@ +import * as blobData from "@distilled.cloud/vercel/blob_data"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import * as Binding from "../../Binding.ts"; +import { isFunction } from "../Functions/Function.ts"; +import { FunctionEnvironment } from "../Functions/FunctionBridge.ts"; +import type { BlobStore, BlobStoreAccess } from "./BlobStore.ts"; +import { + BLOB_TOKEN_ENV, + BlobAlreadyExists, + BlobBadRequest, + BlobForbidden, + BlobInternalError, + BlobNotFound, + BlobPreconditionFailed, + BlobRateLimited, + BlobUnauthorized, + type BlobCommonError, + type BlobListItem, + type BlobObject, + type GetBlobResult, + type ListBlobsOptions, + type ListBlobsResult, + type PutBlobBody, + type PutBlobOptions, + type PutBlobResult, +} from "./BlobTypes.ts"; + +/** + * INTERNAL scaffolding shared by the `ReadBlob`/`WriteBlob`/`ReadWriteBlob` + * implementations — NOT exported from the Vercel `index.ts` (shared- + * scaffolding convention). Thin wrappers over the distilled Blob data-plane + * service (`@distilled.cloud/vercel/blob_data` — generated from the + * hand-authored manual spec, live-verified wire protocol) that adapt the + * generated request/response/error shapes onto the public types in + * `BlobTypes.ts`, plus the shared binding builder. + */ + +/** Default data-plane endpoint; `VERCEL_BLOB_API_URL` overrides (local emulation). */ +export const DEFAULT_BLOB_API_URL = "https://blob.vercel-storage.com"; + +/** + * Env var that overrides the data-plane endpoint (probe-verified + * injectable). The generated distilled operations read it per request, so + * injecting it into a Function's env (dev-mode emulation) reroutes every + * data-plane call. + */ +export const BLOB_API_URL_ENV = "VERCEL_BLOB_API_URL"; + +/** Everything a data-plane operation needs, resolved per call. */ +export interface BlobScope { + /** The store's RW token (`vercel_blob_rw_…`, platform-injected on connect). */ + readonly token: Redacted.Redacted; + /** Bare store id (no `store_` prefix), case-preserved. */ + readonly storeId: string; + /** The store's access mode — sent as `x-vercel-blob-access` on writes. */ + readonly access: BlobStoreAccess; + /** + * Data-plane endpoint for control operations — used only to derive + * {@link contentUrlOf}'s URL shape; the distilled operations resolve + * their actual endpoint from `VERCEL_BLOB_API_URL` per request. + */ + readonly apiUrl: string; +} + +/** + * Strip the management `store_` prefix (and the local provider's `dev:` + * marker) off a store id, leaving the bare host-label id. + */ +export const bareStoreId = (storeId: string): string => { + const withoutMode = storeId.startsWith("dev:") + ? storeId.slice("dev:".length) + : storeId; + return withoutMode.startsWith("store_") + ? withoutMode.slice("store_".length) + : withoutMode; +}; + +/** Encode a pathname for use inside a URL, preserving `/` separators. */ +const encodePathname = (pathname: string): string => + pathname.split("/").map(encodeURIComponent).join("/"); + +/** + * The canonical content URL of a blob: + * `https://{storeIdLower}.{access}.blob.vercel-storage.com/{pathname}`. + */ +export const blobUrlOf = ( + scope: Pick, + pathname: string, +): string => + `https://${bareStoreId(scope.storeId).toLowerCase()}.${scope.access}.blob.vercel-storage.com/${encodePathname(pathname)}`; + +/** + * The URL blob CONTENT is fetched from. On the live platform this is the + * canonical `…blob.vercel-storage.com` URL; with a data-plane override + * (`VERCEL_BLOB_API_URL`, dev-mode emulation) content is served by the + * override host under `/{storeIdLower}.{access}/{pathname}`. + */ +export const contentUrlOf = (scope: BlobScope, pathname: string): string => + scope.apiUrl === DEFAULT_BLOB_API_URL + ? blobUrlOf(scope, pathname) + : `${scope.apiUrl}/${bareStoreId(scope.storeId).toLowerCase()}.${scope.access}/${encodePathname(pathname)}`; + +/** The content-host handle filling distilled's `{store}` endpoint label. */ +const storeHostLabel = (scope: Pick): string => + `${bareStoreId(scope.storeId).toLowerCase()}.${scope.access}`; + +const textDecoder = new TextDecoder(); + +/** The full error union any distilled Blob data-plane operation can fail with. */ +type DistilledBlobError = + | blobData.BlobAlreadyExists + | blobData.BlobPreconditionFailed + | blobData.BlobNotFound + | blobData.BlobBadRequest + | blobData.BlobUnauthorized + | blobData.BlobForbidden + | blobData.VercelDataOpError; + +/** + * Map a distilled data-plane error onto the public `Vercel.Blob.*` taxonomy + * (`BlobTypes.ts`). `pathname` scopes the not-found/CAS/conditional-create + * errors; operations without one (list, delete) surface those statuses as + * {@link BlobInternalError}, mirroring the pre-distilled behavior. + */ +const toBlobError = ( + operation: string, + pathname: string | undefined, + error: DistilledBlobError, +): + | BlobNotFound + | BlobAlreadyExists + | BlobPreconditionFailed + | BlobCommonError => { + switch (error._tag) { + case "BlobAlreadyExists": + return pathname !== undefined + ? new BlobAlreadyExists({ message: error.message, pathname }) + : new BlobBadRequest({ message: error.message, operation }); + case "BlobPreconditionFailed": + return pathname !== undefined + ? new BlobPreconditionFailed({ message: error.message, pathname }) + : new BlobInternalError({ + message: error.message, + operation, + status: 412, + }); + case "BlobNotFound": + return pathname !== undefined + ? new BlobNotFound({ message: error.message, pathname }) + : new BlobInternalError({ + message: error.message, + operation, + status: 404, + }); + case "BlobBadRequest": + return new BlobBadRequest({ message: error.message, operation }); + case "BlobUnauthorized": + case "Unauthorized": + return new BlobUnauthorized({ message: error.message, operation }); + case "BlobForbidden": + return new BlobForbidden({ message: error.message, operation }); + case "TooManyRequests": + return new BlobRateLimited({ + message: error.message, + operation, + retryAfterSeconds: + error.retryAfter !== undefined + ? Duration.toSeconds(error.retryAfter) + : undefined, + }); + case "InternalServerError": + return new BlobInternalError({ + message: error.message, + operation, + status: 500, + }); + case "BadGateway": + return new BlobInternalError({ + message: error.message, + operation, + status: 502, + }); + case "ServiceUnavailable": + return new BlobInternalError({ + message: error.message, + operation, + status: 503, + }); + case "GatewayTimeout": + return new BlobInternalError({ + message: error.message, + operation, + status: 504, + }); + case "PaymentRequired": + return new BlobInternalError({ + message: error.message, + operation, + status: 402, + }); + case "Gone": + return new BlobInternalError({ + message: error.message, + operation, + status: 410, + }); + case "UnknownVercelError": + return new BlobInternalError({ + message: error.message ?? "unknown data-plane failure", + operation, + }); + case "VercelParseError": + return new BlobInternalError({ + message: `Failed to decode the data-plane response: ${String(error.cause)}`, + operation, + }); + default: + // HttpClientError — already a member of BlobCommonError. + return error; + } +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Raw operations (distilled-backed) +// ───────────────────────────────────────────────────────────────────────────── + +/** PUT {apiUrl}/?pathname={pathname} */ +export const putBlobRaw = Effect.fn("Vercel.Blob.put")(function* ( + scope: BlobScope, + pathname: string, + body: PutBlobBody, + options?: PutBlobOptions, +) { + const response = yield* blobData + .putBlob({ + token: scope.token, + pathname, + access: scope.access, + contentType: options?.contentType ?? "application/octet-stream", + ifMatch: options?.ifMatch, + // The data plane's WIRE default (header absent) is to REFUSE + // overwrites (400 "This blob already exists…") — live-verified. Send + // the header explicitly on every put so the client's documented + // default (`allowOverwrite: true`) actually overwrites; `false` opts + // into the typed conditional-create failure. + allowOverwrite: options?.allowOverwrite === false ? "0" : "1", + body, + }) + .pipe(Effect.mapError((error) => toBlobError("put", pathname, error))); + return { + url: response.url, + downloadUrl: response.downloadUrl, + pathname: response.pathname, + contentType: response.contentType, + contentDisposition: response.contentDisposition, + etag: response.etag, + } satisfies PutBlobResult; +}); + +/** GET {apiUrl}/?url={blobUrl} — metadata only. */ +export const headBlobRaw = Effect.fn("Vercel.Blob.head")(function* ( + scope: BlobScope, + pathname: string, +) { + const response = yield* blobData + .headBlob({ token: scope.token, url: blobUrlOf(scope, pathname) }) + .pipe(Effect.mapError((error) => toBlobError("head", pathname, error))); + return { + url: response.url, + downloadUrl: response.downloadUrl, + pathname: response.pathname, + contentType: response.contentType, + contentDisposition: response.contentDisposition, + size: response.size, + uploadedAt: new Date(response.uploadedAt), + cacheControl: response.cacheControl, + etag: response.etag, + } satisfies BlobObject; +}); + +/** GET {apiUrl}/?prefix=&limit=&cursor= — one page of blobs. */ +export const listBlobsRaw = Effect.fn("Vercel.Blob.list")(function* ( + scope: BlobScope, + options?: ListBlobsOptions, +) { + const response = yield* blobData + .listBlobs({ + token: scope.token, + prefix: options?.prefix, + limit: options?.limit, + cursor: options?.cursor, + }) + .pipe(Effect.mapError((error) => toBlobError("list", undefined, error))); + return { + hasMore: response.hasMore, + cursor: response.cursor, + blobs: response.blobs.map( + (blob): BlobListItem => ({ + url: blob.url, + downloadUrl: blob.downloadUrl, + pathname: blob.pathname, + size: blob.size, + uploadedAt: new Date(blob.uploadedAt), + etag: blob.etag, + }), + ), + } satisfies ListBlobsResult; +}); + +/** POST {apiUrl}/delete — idempotent batch delete by canonical URL. */ +export const deleteBlobsRaw = Effect.fn("Vercel.Blob.del")(function* ( + scope: BlobScope, + pathnames: readonly string[], +) { + const urls = pathnames.map((pathname) => + // Accept full content URLs (canonical https, or the local emulator's + // http form) as well as bare pathnames. + /^https?:\/\//.test(pathname) ? pathname : blobUrlOf(scope, pathname), + ); + yield* blobData + .deleteBlobs({ token: scope.token, urls }) + .pipe(Effect.mapError((error) => toBlobError("del", undefined, error))); +}); + +/** GET the blob's content from its per-store host (bearer works for both access modes). */ +export const getBlobRaw = Effect.fn("Vercel.Blob.get")(function* ( + scope: BlobScope, + pathname: string, +) { + const url = contentUrlOf(scope, pathname); + const response = yield* blobData + .getBlobContent({ + store: storeHostLabel(scope), + token: scope.token, + pathname, + }) + .pipe(Effect.mapError((error) => toBlobError("get", pathname, error))); + const bytes = response.body; + return { + pathname, + url, + contentType: response.contentType, + etag: response.etag, + size: bytes.byteLength, + bytes, + text: Effect.sync(() => textDecoder.decode(bytes)), + json: () => + Effect.try({ + try: () => JSON.parse(textDecoder.decode(bytes)) as T, + catch: (cause) => + new BlobInternalError({ + message: `Failed to parse blob ${pathname} as JSON: ${String(cause)}`, + operation: "get", + }), + }), + } satisfies GetBlobResult; +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Shared binding builder +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Build the shared deploy/runtime halves of a Blob capability: + * + * - **Deploy half** (guarded by `__ALCHEMY_RUNTIME__`): contributes the host + * Function's `projectId` onto the store's binding contract, so the store's + * reconciler connects the project — which is what makes the platform + * inject the `BLOB_READ_WRITE_TOKEN` env var the runtime half reads. The + * `storeId`/`access` accessors captured below flow through the host's env + * channel (the init-capture mechanism), creating the reverse dependency + * edge so the engine sequences store-connect BEFORE the Function's deploy + * (project env only takes effect on new deployments). + * - **Runtime half**: resolves the {@link BlobScope} (token from the + * platform-injected env, store identity from the captured accessors) and + * hands it to `makeClient`. + */ +export const makeBlobHttpBinding = (options: { + readonly makeClient: (scope: Effect.Effect) => Client; +}) => + Effect.gen(function* () { + // Instance-scoped: the FunctionEnvironment record is the live process + // env at runtime (and `{}` during plan), so per-op key reads stay fresh. + const env = yield* FunctionEnvironment; + + return Effect.fn(function* (store: BlobStore) { + if (!globalThis.__ALCHEMY_RUNTIME__) { + const host = yield* Binding.Host; + if (isFunction(host)) { + // Connect the host's project to the store. The store's provider + // owns the connection lifecycle (create/remove on unbind), so the + // capability never calls the connections API itself. + yield* store.bind`Blob(${store.LogicalId}, ${host.LogicalId})`({ + projects: [host.projectId], + }); + // Dev-mode env injection: the LOCAL store provider publishes its + // emulator endpoint + deterministic token as attributes; both are + // `undefined` on the live platform (undefined env values are + // skipped by env sync — the InvokeFunction `protectionBypass` + // precedent), where the platform injects `BLOB_READ_WRITE_TOKEN` + // through the store↔project connection instead. Injecting real + // env vars (not just captures) keeps `@vercel/blob` and the + // async `readWriteBlobFromEnv` client working locally too. + yield* host.bind`BlobEnv(${store.LogicalId}, ${host.LogicalId})`({ + env: { + [BLOB_API_URL_ENV]: store.localApiUrl, + [BLOB_TOKEN_ENV]: store.localToken, + }, + }); + } + } + // Init captures: registered on the host's env channel at deploy, + // read back from the same env at runtime. + const storeId = yield* store.storeId; + const access = yield* store.access; + + const scope: Effect.Effect = Effect.gen(function* () { + const token = env[BLOB_TOKEN_ENV]; + if (token === undefined || token === "") { + return yield* Effect.die( + new Error( + `Vercel.Blob: ${BLOB_TOKEN_ENV} is not set — the Function's project is not connected to the blob store (the binding registers the connection at deploy; a deployment created before the connection cannot see the token)`, + ), + ); + } + return { + token: Redacted.make(token), + storeId: yield* storeId, + access: (yield* access) as BlobStoreAccess, + apiUrl: env[BLOB_API_URL_ENV] ?? DEFAULT_BLOB_API_URL, + } satisfies BlobScope; + }); + + return options.makeClient(scope); + }); + }); diff --git a/packages/alchemy/src/Vercel/Blob/BlobStore.ts b/packages/alchemy/src/Vercel/Blob/BlobStore.ts new file mode 100644 index 0000000000..31ec6becbf --- /dev/null +++ b/packages/alchemy/src/Vercel/Blob/BlobStore.ts @@ -0,0 +1,495 @@ +import { + createStorageStoreConnection, + createStorageStoresBlob, + deleteStorageStoreConnection, + getStorageStoreConnections, + getStorageStores, + getStorageStoresById, + type GetStorageStoresByIdResponse, +} from "@distilled.cloud/vercel/storage"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import type * as Redacted from "effect/Redacted"; +import { isResolved } from "../../Diff.ts"; +import { createPhysicalName } from "../../PhysicalName.ts"; +import * as Provider from "../../Provider.ts"; +import { Resource, type ResourceBinding } from "../../Resource.ts"; +import { VercelEnvironment } from "../VercelEnvironment.ts"; +import type { Providers } from "../Providers.ts"; +import { purgeAndDeleteBlobStore } from "./PurgeStore.ts"; + +const DEFAULT_ACCESS: BlobStoreAccess = "public"; +const DEFAULT_REGION: BlobStoreRegion = "iad1"; +const ALL_ENVIRONMENTS: BlobStoreEnvironment[] = [ + "production", + "preview", + "development", +]; + +export type BlobStoreAccess = "public" | "private"; + +export type BlobStoreEnvironment = "production" | "preview" | "development"; + +export type BlobStoreRegion = + | "arn1" + | "bom1" + | "cdg1" + | "cle1" + | "cpt1" + | "dub1" + | "dxb1" + | "fra1" + | "gru1" + | "hkg1" + | "hnd1" + | "iad1" + | "icn1" + | "kix1" + | "lhr1" + | "pdx1" + | "sfo1" + | "sin1" + | "syd1" + | "yul1"; + +export interface BlobStoreProps { + /** + * Name of the store. If omitted, a unique name is generated from + * `${app}-${stage}-${id}`. Changing the name forces a replacement. + */ + name?: string; + /** + * Access mode of the store. Public blobs are world-readable at their + * blob URL; private blobs require an authenticated data-plane request. + * Cannot be changed after creation (forces a replacement). + * + * @default "public" + */ + access?: BlobStoreAccess; + /** + * Region the store's data is placed in. Cannot be changed after + * creation (forces a replacement). + * + * @default "iad1" + */ + region?: BlobStoreRegion; + /** + * Whether to delete all blobs when the store is destroyed. Vercel + * refuses to delete a non-empty store (409 `not_empty`) and offers no + * force parameter, so with `forceDestroy` the provider purges the + * store's contents through the data plane before deletion. Without it, + * destroying a non-empty store fails with the typed Conflict — the + * data-protection default (mirrors S3's `forceDestroy`). + * @default false + */ + forceDestroy?: boolean; + /** + * Project IDs to connect the store to. Connecting a project injects a + * `BLOB_READ_WRITE_TOKEN` (encrypted) environment variable into the + * project for the environments in {@link BlobStoreProps.envVarEnvironments}, + * which is how deployed functions (and `alchemy` itself) acquire the + * store's data-plane token. Removing a project from this list + * disconnects it and removes the injected variable. + */ + projects?: string[]; + /** + * Environments that receive the injected token env var on each project + * connection. + * + * @default ["production", "preview", "development"] + */ + envVarEnvironments?: BlobStoreEnvironment[]; +} + +/** + * The Binding Contract of a {@link BlobStore}: capabilities (the + * `ReadBlob`/`WriteBlob`/`ReadWriteBlob` bindings) contribute the host + * Function's project id here, and the store's reconciler merges the + * contributions with the `projects` prop before syncing connections. This + * is how binding a store to a Function transparently connects the + * Function's project (which injects the platform's data-plane token env). + */ +export interface BlobStoreBinding { + /** Project IDs to connect (merged with {@link BlobStoreProps.projects}). */ + projects?: string[]; +} + +export type BlobStore = Resource< + "Vercel.BlobStore", + BlobStoreProps, + { + /** Unique store identifier, e.g. `store_XXXXXXXXXXXXXXXX`. */ + storeId: string; + /** Name of the store. */ + name: string; + /** Access mode of the store. */ + access: BlobStoreAccess; + /** Region the store's data is placed in. */ + region: string; + /** IDs of the projects currently connected to the store (sorted). */ + projectIds: string[]; + /** Environments the injected token env var targets on each connection. */ + envVarEnvironments: BlobStoreEnvironment[]; + /** + * Dev-mode only (`alchemy dev`): the local blob emulator's data-plane + * endpoint (`http://localhost:`). Always `undefined` on the live + * platform. The Blob capability bindings inject it into the host + * Function as `VERCEL_BLOB_API_URL` — undefined values are skipped by + * env sync, so live deployments are untouched. + */ + localApiUrl?: string | undefined; + /** + * Dev-mode only: the emulator's deterministic RW token for this store + * (`vercel_blob_rw_…`, same shape as the platform's). Injected into the + * host Function as `BLOB_READ_WRITE_TOKEN` in dev; `undefined` live + * (there the platform injects the token through the store↔project + * connection instead). + */ + localToken?: Redacted.Redacted | undefined; + }, + BlobStoreBinding, + Providers +>; + +type BlobStoreAttributes = BlobStore["Attributes"]; + +/** + * A Vercel Blob store — object storage addressed by pathname, served from + * a global CDN. Public stores serve blobs from world-readable URLs; + * private stores require authenticated reads. + * + * Vercel has no resource tags, so ownership is tracked through + * deterministic naming plus alchemy state. Projects are connected through + * the store↔project connection API, which injects a + * `BLOB_READ_WRITE_TOKEN` environment variable into the connected + * project — the data-plane credential used at runtime. + * + * @resource + * @section Creating a Blob Store + * @example Basic store + * ```typescript + * const uploads = yield* Vercel.BlobStore("Uploads"); + * ``` + * + * @example Private store in a specific region + * ```typescript + * const uploads = yield* Vercel.BlobStore("Uploads", { + * access: "private", + * region: "fra1", + * }); + * ``` + * + * @section Connecting Projects + * @example Inject the store token into a project + * ```typescript + * const uploads = yield* Vercel.BlobStore("Uploads", { + * access: "private", + * projects: ["prj_XXXXXXXXXXXXXXXXXXXXXXXXXXXX"], + * }); + * // the project now has BLOB_READ_WRITE_TOKEN in production/preview/development + * ``` + * + * @example Limit the injected env var to production + * ```typescript + * const uploads = yield* Vercel.BlobStore("Uploads", { + * projects: ["prj_XXXXXXXXXXXXXXXXXXXXXXXXXXXX"], + * envVarEnvironments: ["production"], + * }); + * ``` + * + * @section Binding to a Function + * @example Connect through the ReadWriteBlob capability + * Inside an Effect-native Function, binding the store connects the + * Function's project automatically — no `projects:` prop needed (see + * {@link BlobStoreBinding}). + * ```typescript + * const uploads = yield* Vercel.ReadWriteBlob(Uploads); + * // deploy: connects the Function's project (injects BLOB_READ_WRITE_TOKEN) + * // runtime: uploads.put / get / head / list / del + * ``` + * + * @see https://vercel.com/docs/vercel-blob + */ +export const BlobStore = Resource("Vercel.BlobStore"); + +export const BlobStoreProvider = () => + Provider.succeed(BlobStore, { + stables: ["storeId"], + list: Effect.fn(function* () { + const { teamId } = yield* VercelEnvironment.current; + const { stores } = yield* getStorageStores({ teamId }); + const rows = yield* Effect.forEach( + stores.filter((store) => store.type === "blob"), + (store) => + hydrateStoreAttributes(store.id).pipe( + // A store can be deleted between the list call and hydration — + // skip it rather than fail the whole enumeration. + Effect.catchTag("NotFound", () => Effect.succeed(undefined)), + ), + { concurrency: 5 }, + ); + return rows.filter( + (row): row is BlobStoreAttributes => row !== undefined, + ); + }), + diff: Effect.fn(function* ({ id, olds = {}, news = {}, output }) { + if (!isResolved(news)) return undefined; + const oldName = output?.name ?? (yield* createStoreName(id, olds.name)); + // Auto-generated names are engine-owned: the deployed name stays + // authoritative even if the generator would name this id differently + // today. Only an explicit user-provided name can force a replace. + const name = news.name ?? oldName; + if ( + oldName !== name || + (news.access ?? output?.access ?? DEFAULT_ACCESS) !== + (output?.access ?? olds.access ?? DEFAULT_ACCESS) || + (news.region ?? output?.region ?? DEFAULT_REGION) !== + (output?.region ?? olds.region ?? DEFAULT_REGION) + ) { + return { + action: "replace", + // A project can hold at most ONE blob-store connection (the + // injected env name is fixed: `BLOB_READ_WRITE_TOKEN`, live- + // verified 400 `project_env_var_not_unique` on a second connect). + // A create-first replacement's successor therefore cannot connect + // while the predecessor's connection is still live — delete the + // old store first whenever it is connected to any project. + deleteFirst: (output?.projectIds.length ?? 0) > 0, + } as const; + } + return undefined; + }), + read: Effect.fn(function* ({ id, output, olds }) { + if (output?.storeId) { + return yield* hydrateStoreAttributes(output.storeId).pipe( + Effect.catchTag("NotFound", () => Effect.succeed(undefined)), + ); + } + const name = yield* createStoreName(id, olds?.name); + const match = yield* findStoreByName(name); + if (!match) return undefined; + return yield* hydrateStoreAttributes(match.id).pipe( + Effect.catchTag("NotFound", () => Effect.succeed(undefined)), + ); + }), + reconcile: Effect.fn(function* ({ id, news = {}, output, bindings }) { + const { teamId } = yield* VercelEnvironment.current; + + // Observe — `output.storeId` is a cache, not a guarantee: fall + // through to find-by-name / create when the store no longer exists. + const name = output?.name ?? (yield* createStoreName(id, news.name)); + let store = output?.storeId + ? yield* getStorageStoresById({ id: output.storeId, teamId }).pipe( + Effect.map((r) => r.store), + Effect.catchTag("NotFound", () => Effect.succeed(undefined)), + ) + : yield* findStoreByName(name).pipe( + Effect.flatMap((match) => + match + ? getStorageStoresById({ id: match.id, teamId }).pipe( + Effect.map( + (r): GetStorageStoresByIdResponse["store"] | undefined => + r.store, + ), + Effect.catchTag("NotFound", () => + Effect.succeed(undefined), + ), + ) + : Effect.succeed(undefined), + ), + ); + + // Ensure — create when missing; tolerate a concurrent create of the + // same name as a race and re-observe. + if (store === undefined) { + store = yield* createStorageStoresBlob({ + name, + access: news.access ?? DEFAULT_ACCESS, + region: news.region ?? DEFAULT_REGION, + teamId, + }).pipe( + Effect.flatMap((created) => + created.store + ? getStorageStoresById({ id: created.store.id, teamId }).pipe( + Effect.map((r) => r.store), + ) + : // The API declares the created store nullable; re-observe by + // name so a null body cannot leave us without attributes. + requireStoreByName(name), + ), + Effect.catchTag("Conflict", () => requireStoreByName(name)), + ); + } + const storeId = store.id; + + // Sync connections — diff OBSERVED connections against the desired + // project list (props ∪ binding-contributed project ids, see + // {@link BlobStoreBinding}); apply only the delta. Each half is + // idempotent. + const contributedProjects = (bindings ?? []) + .filter( + (b: ResourceBinding & { action?: string }) => + b.action !== "delete", + ) + .flatMap((b) => b?.data?.projects ?? []) + // A LOCAL (dev-emulated) Function binding a live (`Alchemy.remote()`) + // store contributes its `dev:`-marked project id — not a real + // project, so never hand it to the connections API. (The local + // Function cannot receive the platform-injected token anyway; its + // runtime client reports the missing token with guidance.) + // An EMPTY id is a Function precreate's inert stub (its tenant + // `project:` ref hadn't resolved yet) — skip it rather than 500 + // against the API; the connection reconciles once the host's real + // project id flows through the binding. + .filter( + (projectId) => projectId !== "" && !projectId.startsWith("dev:"), + ); + const desiredProjects = [ + ...new Set([...(news.projects ?? []), ...contributedProjects]), + ]; + const desiredEnvs = normalizeEnvironments(news.envVarEnvironments); + const observed = yield* getStorageStoreConnections({ storeId, teamId }); + + for (const connection of observed.connections) { + const wanted = desiredProjects.includes(connection.projectId); + const sameEnvs = + normalizeEnvironments([...connection.envVarEnvironments]).join( + ",", + ) === desiredEnvs.join(","); + if (!wanted || !sameEnvs) { + // Disconnect removes the injected env var from the project; a + // changed environment list is disconnect + reconnect below. + yield* deleteStorageStoreConnection({ + storeId, + connectionId: connection.id, + teamId, + }).pipe(Effect.catchTag("NotFound", () => Effect.void)); + } + } + const connected = new Set( + observed.connections + .filter( + (connection) => + desiredProjects.includes(connection.projectId) && + normalizeEnvironments([...connection.envVarEnvironments]).join( + ",", + ) === desiredEnvs.join(","), + ) + .map((connection) => connection.projectId), + ); + for (const projectId of desiredProjects) { + if (connected.has(projectId)) continue; + yield* createStorageStoreConnection({ + storeId, + projectId, + envVarEnvironments: desiredEnvs, + type: "integration", + teamId, + }).pipe( + // A 400 here can be the already-connected race + // (`store_project_connection_not_unique`) — trust observation: + // re-read the connections and only propagate the error if the + // project is genuinely not connected. + Effect.catchTag("BadRequest", (error) => + getStorageStoreConnections({ storeId, teamId }).pipe( + Effect.flatMap((after) => + after.connections.some( + (connection) => connection.projectId === projectId, + ) + ? Effect.void + : Effect.fail(error), + ), + ), + ), + ); + } + + // Return — re-read the final connection state. + const final = yield* getStorageStoreConnections({ storeId, teamId }); + return { + storeId, + name: store.name, + access: (store.access ?? + news.access ?? + DEFAULT_ACCESS) as BlobStoreAccess, + region: store.region ?? news.region ?? DEFAULT_REGION, + projectIds: final.connections + .map((connection) => connection.projectId) + .sort(), + envVarEnvironments: desiredEnvs, + }; + }), + delete: Effect.fn(function* ({ olds, output }) { + // Vercel refuses to delete a non-empty store (409 `not_empty`, no + // force parameter). With `forceDestroy` the helper purges the + // contents through the data plane first — harvesting the token + // through a live connection, or a throwaway reaper project when the + // store is unconnected; without it, a non-empty store surfaces the + // platform's typed Conflict (data protection). Either way the helper + // disconnects every connection BEFORE the delete (store deletion + // does not remove the env vars its connections injected). + yield* purgeAndDeleteBlobStore({ + storeId: output.storeId, + name: output.name, + access: output.access, + forceDestroy: olds?.forceDestroy === true, + }); + }), + }); + +const createStoreName = (id: string, name: string | undefined) => + Effect.gen(function* () { + return name ?? (yield* createPhysicalName({ id, lowercase: true })); + }); + +const normalizeEnvironments = ( + environments: BlobStoreEnvironment[] | undefined, +): BlobStoreEnvironment[] => + [...new Set(environments?.length ? environments : ALL_ENVIRONMENTS)].sort(); + +const findStoreByName = (name: string) => + Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + const { stores } = yield* getStorageStores({ teamId }); + return stores.find((store) => store.type === "blob" && store.name === name); + }); + +class BlobStoreVanished extends Data.TaggedError("BlobStoreVanished")<{ + message: string; +}> {} + +const requireStoreByName = (name: string) => + Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + const match = yield* findStoreByName(name); + if (!match) { + return yield* new BlobStoreVanished({ + message: `Blob store '${name}' was expected to exist but was not found`, + }); + } + return (yield* getStorageStoresById({ id: match.id, teamId })).store; + }); + +const hydrateStoreAttributes = (storeId: string) => + Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + const { store } = yield* getStorageStoresById({ id: storeId, teamId }); + if (store.type !== "blob") return undefined; + const { connections } = yield* getStorageStoreConnections({ + storeId, + teamId, + }); + const envVarEnvironments = normalizeEnvironments( + connections[0] + ? [...connections[0].envVarEnvironments] + : ALL_ENVIRONMENTS, + ); + return { + storeId: store.id, + name: store.name, + access: (store.access ?? DEFAULT_ACCESS) as BlobStoreAccess, + region: store.region ?? DEFAULT_REGION, + projectIds: connections.map((connection) => connection.projectId).sort(), + envVarEnvironments, + } satisfies BlobStoreAttributes; + }); diff --git a/packages/alchemy/src/Vercel/Blob/BlobTypes.ts b/packages/alchemy/src/Vercel/Blob/BlobTypes.ts new file mode 100644 index 0000000000..0621ee08f7 --- /dev/null +++ b/packages/alchemy/src/Vercel/Blob/BlobTypes.ts @@ -0,0 +1,215 @@ +import * as Data from "effect/Data"; +import type * as Effect from "effect/Effect"; +import type * as HttpClientError from "effect/unstable/http/HttpClientError"; + +/** + * Shared types and tagged errors for the Vercel Blob data plane + * (`https://blob.vercel-storage.com`). + * + * The Blob data plane is not part of Vercel's management OpenAPI, so these + * errors are hand-typed from the live-verified wire protocol (see + * processes/Vercel/PROBES.md, "Blob data-plane + connect probes") rather + * than generated by distilled — the same manual-spec gap noted for the + * Queues data plane in `Queues/QueueApi.ts`. + */ + +/** The env var Vercel injects into every project connected to a Blob store. */ +export const BLOB_TOKEN_ENV = "BLOB_READ_WRITE_TOKEN"; + +/** + * Metadata row returned by `list` — the data plane's list rows carry no + * content type/disposition. + */ +export interface BlobListItem { + /** Canonical blob URL (`https://{store}.{access}.blob.vercel-storage.com/{pathname}`). */ + readonly url: string; + /** The blob URL with `?download=1` (forces attachment disposition). */ + readonly downloadUrl: string; + /** Pathname of the blob within the store. */ + readonly pathname: string; + /** Size in bytes. */ + readonly size: number; + /** Upload timestamp. */ + readonly uploadedAt: Date; + /** Quoted entity tag (e.g. `"ad314f..."`) — pass to `ifMatch` for CAS. */ + readonly etag: string; +} + +/** Full metadata returned by `head`. */ +export interface BlobObject extends BlobListItem { + /** Content type recorded at upload. */ + readonly contentType?: string | undefined; + /** Content disposition served with the blob. */ + readonly contentDisposition?: string | undefined; + /** Cache-control policy served with the blob. */ + readonly cacheControl?: string | undefined; +} + +/** Result of a successful `put`. */ +export interface PutBlobResult { + /** Canonical blob URL. */ + readonly url: string; + /** The blob URL with `?download=1`. */ + readonly downloadUrl: string; + /** Pathname of the blob within the store. */ + readonly pathname: string; + /** Content type recorded at upload. */ + readonly contentType?: string | undefined; + /** Content disposition served with the blob. */ + readonly contentDisposition?: string | undefined; + /** Quoted entity tag of the NEW content — the CAS handle for the next write. */ + readonly etag: string; +} + +/** Options for `put`. */ +export interface PutBlobOptions { + /** Content type stored and served with the blob. */ + readonly contentType?: string | undefined; + /** + * Compare-and-swap: only write if the blob's current etag matches (the + * quoted etag returned by `put`/`head`/`list`). A mismatch fails with the + * typed {@link BlobPreconditionFailed} (wire: 412 `precondition_failed`). + */ + readonly ifMatch?: string | undefined; + /** + * Set `false` for a conditional CREATE: the write fails with the typed + * {@link BlobAlreadyExists} if the pathname already exists (wire: + * `x-allow-overwrite: 0` → 400). The client default is `true` + * (overwrite) — note the data plane's WIRE default with the header + * absent is to refuse overwrites, so the client always sends the + * header explicitly. + * @default true + */ + readonly allowOverwrite?: boolean | undefined; +} + +/** Options for `list`. */ +export interface ListBlobsOptions { + /** Only list blobs whose pathname starts with this prefix. */ + readonly prefix?: string | undefined; + /** Page size. */ + readonly limit?: number | undefined; + /** Continuation cursor from a previous page. */ + readonly cursor?: string | undefined; +} + +/** One page of `list` results. */ +export interface ListBlobsResult { + readonly blobs: BlobListItem[]; + readonly hasMore: boolean; + readonly cursor?: string | undefined; +} + +/** Result of `get` — buffered content plus the served metadata. */ +export interface GetBlobResult { + /** Pathname of the blob within the store. */ + readonly pathname: string; + /** Canonical blob URL the content was fetched from. */ + readonly url: string; + /** Content type served with the blob. */ + readonly contentType?: string | undefined; + /** Quoted entity tag served with the content. */ + readonly etag?: string | undefined; + /** Size in bytes. */ + readonly size: number; + /** The blob's content. */ + readonly bytes: Uint8Array; + /** The content decoded as UTF-8. */ + readonly text: Effect.Effect; + /** The content parsed as JSON. */ + readonly json: () => Effect.Effect; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tagged errors (live-verified wire codes) +// ───────────────────────────────────────────────────────────────────────────── + +/** 401 — the data-plane token was rejected. */ +export class BlobUnauthorized extends Data.TaggedError( + "Vercel.Blob.Unauthorized", +)<{ + readonly message: string; + readonly operation: string; +}> {} + +/** 403 — the token is not allowed to perform the operation (or an unauthenticated private read). */ +export class BlobForbidden extends Data.TaggedError("Vercel.Blob.Forbidden")<{ + readonly message: string; + readonly operation: string; +}> {} + +/** 404 `not_found` — the blob does not exist. */ +export class BlobNotFound extends Data.TaggedError("Vercel.Blob.NotFound")<{ + readonly message: string; + readonly pathname: string; +}> {} + +/** + * 400 `bad_request` "This blob already exists…" — a conditional create + * (`allowOverwrite: false`) hit an existing pathname. + */ +export class BlobAlreadyExists extends Data.TaggedError( + "Vercel.Blob.AlreadyExists", +)<{ + readonly message: string; + readonly pathname: string; +}> {} + +/** 412 `precondition_failed` — the `ifMatch` etag no longer matches. */ +export class BlobPreconditionFailed extends Data.TaggedError( + "Vercel.Blob.PreconditionFailed", +)<{ + readonly message: string; + readonly pathname: string; +}> {} + +/** Any other 400 from the data plane (e.g. access-mode mismatch on a put). */ +export class BlobBadRequest extends Data.TaggedError("Vercel.Blob.BadRequest")<{ + readonly message: string; + readonly operation: string; +}> {} + +/** 429 — data-plane rate limit. */ +export class BlobRateLimited extends Data.TaggedError( + "Vercel.Blob.RateLimited", +)<{ + readonly message: string; + readonly operation: string; + readonly retryAfterSeconds?: number | undefined; +}> {} + +/** Unexpected data-plane failure (5xx / unmapped status / body decode). */ +export class BlobInternalError extends Data.TaggedError( + "Vercel.Blob.InternalError", +)<{ + readonly message: string; + readonly operation: string; + readonly status?: number | undefined; +}> {} + +/** Statuses every data-plane operation can produce. */ +export type BlobCommonError = + | BlobUnauthorized + | BlobForbidden + | BlobBadRequest + | BlobRateLimited + | BlobInternalError + | HttpClientError.HttpClientError; + +/** Errors of `head`/`get` (the blob may not exist). */ +export type BlobReadError = BlobNotFound | BlobCommonError; + +/** Errors of `list`. */ +export type BlobListError = BlobCommonError; + +/** Errors of `put` (CAS + conditional create are typed). */ +export type BlobPutError = + | BlobAlreadyExists + | BlobPreconditionFailed + | BlobCommonError; + +/** Errors of `del` (idempotent — deleting a missing blob succeeds). */ +export type BlobDeleteError = BlobCommonError; + +/** Body shapes accepted by `put`. */ +export type PutBlobBody = string | Uint8Array | ArrayBuffer | Blob; diff --git a/packages/alchemy/src/Vercel/Blob/LocalBlobServer.ts b/packages/alchemy/src/Vercel/Blob/LocalBlobServer.ts new file mode 100644 index 0000000000..5e4050353c --- /dev/null +++ b/packages/alchemy/src/Vercel/Blob/LocalBlobServer.ts @@ -0,0 +1,576 @@ +/** + * INTERNAL — the dev-mode (`alchemy dev`) Vercel Blob data-plane emulator. + * NOT exported from the Vercel `index.ts` (shared-scaffolding convention). + * + * One HTTP server per sidecar serves EVERY local blob store, speaking the + * live-verified wire protocol subset our clients (and `@vercel/blob`) use + * (see processes/Vercel/PROBES.md, "Blob capability probes"): + * + * - `PUT /?pathname=…` — write (`x-if-match` CAS → 412 `precondition_failed`; + * wire default REFUSES overwrites, `x-allow-overwrite: 1` opts in; the + * `x-vercel-blob-access` header must match the store's access mode) + * - `GET /?url=…` — head metadata (404 `not_found`) + * - `GET /?prefix=&limit=&cursor=` — list page + * - `POST /delete` `{urls}` — idempotent batch delete + * - `GET /{store}.{access}/{pathname}` — blob content (`?download=1` forces + * attachment disposition; private stores require the store's bearer token) + * + * Clients are pointed here through the probe-verified `VERCEL_BLOB_API_URL` + * override, injected into the local Function's env by the Blob capability + * bindings (see `makeBlobHttpBinding`). Etags are quoted sha256 content + * hashes, so CAS semantics match the platform's. + * + * The data plane is directory-backed under + * `.alchemy/local/blob/{storeId}/` — content lives in `objects/{sha256}`, + * metadata in `index.json` — so blobs survive dev-session restarts. + */ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import type * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as HttpServer from "effect/unstable/http/HttpServer"; +import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import { httpServer } from "../../Util/PlatformServices.ts"; +import { sha256 } from "../../Util/sha256.ts"; +import { bareStoreId } from "./BlobHttp.ts"; +import type { BlobStoreAccess } from "./BlobStore.ts"; + +/** A registered local store (one per `dev:store_…` resource row). */ +export interface LocalBlobStoreHandle { + /** The resource's storeId (`dev:store_{name}`). */ + readonly storeId: string; + /** Lowercased bare id — the host/path label clients derive from the storeId. */ + readonly slug: string; + /** Access mode (private content reads require the bearer token). */ + readonly access: BlobStoreAccess; + /** + * The store's deterministic local RW token + * (`vercel_blob_rw_{slug}_{secret}`) — same shape as the platform's, so + * `storeIdOfToken`-style derivation in the async client keeps working. + */ + readonly token: string; +} + +export interface LocalBlobServer { + /** Local data-plane origin, e.g. `http://localhost:53211` (no trailing slash). */ + readonly url: string; + /** Register (or re-open, hydrating from disk) a store. Idempotent. */ + readonly openStore: (input: { + readonly storeId: string; + readonly access: BlobStoreAccess; + }) => Effect.Effect; + /** Deregister a store and remove its directory. Idempotent. */ + readonly dropStore: (storeId: string) => Effect.Effect; + /** Whether the store is currently registered. */ + readonly hasStore: (storeId: string) => Effect.Effect; +} + +/** One blob's metadata row (the persisted `index.json` shape). */ +interface BlobRow { + readonly pathname: string; + readonly size: number; + /** epoch millis */ + readonly uploadedAt: number; + /** Quoted sha256 content hash. */ + readonly etag: string; + readonly contentType: string; + readonly contentDisposition: string; + readonly cacheControl: string; + /** Content address — `objects/{sha256}` (unquoted hash). */ + readonly object: string; +} + +interface StoreEntry { + readonly handle: LocalBlobStoreHandle; + readonly directory: string; + readonly blobs: Map; + /** Serializes mutations so CAS / conditional creates are atomic. */ + readonly lock: Semaphore.Semaphore; +} + +const INDEX_FILE = "index.json"; +const OBJECTS_DIR = "objects"; +const DEFAULT_LIST_LIMIT = 1000; +const DEFAULT_CACHE_CONTROL = "public, max-age=0, must-revalidate"; + +/** The host/path label a storeId maps to (matches `blobUrlOf`'s host). */ +export const localStoreSlug = (storeId: string): string => + bareStoreId(storeId).toLowerCase(); + +const encodePathname = (pathname: string): string => + pathname.split("/").map(encodeURIComponent).join("/"); + +const unquoteEtag = (etag: string): string => + etag.startsWith('"') && etag.endsWith('"') ? etag.slice(1, -1) : etag; + +const errorJson = (status: number, code: string, message: string) => + HttpServerResponse.jsonUnsafe({ error: { code, message } }, { status }); + +/** + * Parse a blob content URL — either the platform-canonical + * `https://{slug}.{access}.blob.vercel-storage.com/{pathname}` or this + * emulator's local form `http://…/{slug}.{access}/{pathname}`. + */ +const parseBlobUrl = ( + raw: string, +): { slug: string; access: string; pathname: string } | undefined => { + let url: URL; + try { + url = new URL(raw); + } catch { + return undefined; + } + const decodedPath = decodeURIComponent(url.pathname.replace(/^\//, "")); + if (url.hostname.endsWith(".blob.vercel-storage.com")) { + const [slug, access] = url.hostname.split("."); + if (slug === undefined || access === undefined) return undefined; + return { slug, access, pathname: decodedPath }; + } + const match = /^([^/]+)\.(public|private)\/(.*)$/.exec(decodedPath); + if (match === null) return undefined; + return { slug: match[1], access: match[2], pathname: match[3] }; +}; + +/** + * Serve the local Blob data plane in the ambient `Scope` (one server per + * sidecar; stores register/deregister through the returned handle as their + * resource rows reconcile and delete). + */ +export const makeLocalBlobServer = Effect.fn("Vercel.LocalBlobServer")( + function* (options: { readonly baseDirectory: string }) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + /** keyed by slug — the label every wire shape addresses stores by. */ + const stores = new Map(); + const bySlug = (slug: string) => stores.get(slug); + const byStoreId = (storeId: string) => bySlug(localStoreSlug(storeId)); + + const storeDirectory = (storeId: string) => + path.join(options.baseDirectory, encodeURIComponent(storeId)); + + const persistIndex = (entry: StoreEntry) => + fs.writeFileString( + path.join(entry.directory, INDEX_FILE), + JSON.stringify( + { + version: 1, + storeId: entry.handle.storeId, + access: entry.handle.access, + blobs: [...entry.blobs.values()], + }, + null, + 2, + ), + ); + + /** Remove a content object unless another row still references it. */ + const gcObject = (entry: StoreEntry, object: string) => + Effect.suspend(() => { + for (const row of entry.blobs.values()) { + if (row.object === object) return Effect.void; + } + return fs + .remove(path.join(entry.directory, OBJECTS_DIR, object)) + .pipe(Effect.ignore); + }); + + const contentUrl = (entry: StoreEntry, pathname: string) => + `${serverUrl}/${entry.handle.slug}.${entry.handle.access}/${encodePathname(pathname)}`; + + const rowJson = (entry: StoreEntry, row: BlobRow) => ({ + url: contentUrl(entry, row.pathname), + downloadUrl: `${contentUrl(entry, row.pathname)}?download=1`, + pathname: row.pathname, + size: row.size, + uploadedAt: new Date(row.uploadedAt).toISOString(), + etag: row.etag, + }); + + /** Resolve the bearer token to its registered store. */ + const authenticate = ( + request: HttpServerRequest.HttpServerRequest, + ): StoreEntry | undefined => { + const header = request.headers.authorization; + if (header === undefined || !header.startsWith("Bearer ")) { + return undefined; + } + const token = header.slice("Bearer ".length); + const match = /^vercel_blob_rw_([^_]+)_/.exec(token); + if (match === null) return undefined; + const entry = bySlug(match[1].toLowerCase()); + if (entry === undefined || entry.handle.token !== token) { + return undefined; + } + return entry; + }; + + const handlePut = Effect.fn(function* ( + request: HttpServerRequest.HttpServerRequest, + url: URL, + ) { + const entry = authenticate(request); + if (entry === undefined) { + return errorJson(401, "unauthorized", "Access denied (invalid token)"); + } + const pathname = url.searchParams.get("pathname") ?? ""; + if (pathname === "") { + return errorJson(400, "bad_request", "pathname is required"); + } + const accessHeader = request.headers["x-vercel-blob-access"]; + if ( + entry.handle.access === "private" + ? accessHeader !== "private" + : accessHeader === "private" + ) { + return errorJson( + 400, + "bad_request", + entry.handle.access === "private" + ? "Cannot use public access on a private store" + : "Cannot use private access on a public store", + ); + } + const bytes = new Uint8Array(yield* request.arrayBuffer); + return yield* Semaphore.withPermits( + entry.lock, + 1, + )( + Effect.gen(function* () { + const existing = entry.blobs.get(pathname); + const ifMatch = request.headers["x-if-match"]; + if (ifMatch !== undefined) { + if ( + existing === undefined || + unquoteEtag(existing.etag) !== unquoteEtag(ifMatch) + ) { + return errorJson( + 412, + "precondition_failed", + "The blob's etag does not match the provided x-if-match value", + ); + } + } else if ( + existing !== undefined && + request.headers["x-allow-overwrite"] !== "1" + ) { + // The live wire default (header absent or `0`) REFUSES + // overwrites — probe-verified. + return errorJson( + 400, + "bad_request", + "This blob already exists. Use `allowOverwrite: true` if you want to overwrite it.", + ); + } + const object = yield* sha256(bytes); + yield* fs.makeDirectory(path.join(entry.directory, OBJECTS_DIR), { + recursive: true, + }); + yield* fs.writeFile( + path.join(entry.directory, OBJECTS_DIR, object), + bytes, + ); + const basename = pathname.split("/").at(-1) ?? pathname; + const row: BlobRow = { + pathname, + size: bytes.byteLength, + uploadedAt: Date.now(), + etag: `"${object}"`, + contentType: + request.headers["content-type"] ?? "application/octet-stream", + contentDisposition: `inline; filename="${basename}"`, + cacheControl: DEFAULT_CACHE_CONTROL, + object, + }; + entry.blobs.set(pathname, row); + if (existing !== undefined && existing.object !== object) { + yield* gcObject(entry, existing.object); + } + yield* persistIndex(entry); + return HttpServerResponse.jsonUnsafe({ + url: contentUrl(entry, pathname), + downloadUrl: `${contentUrl(entry, pathname)}?download=1`, + pathname, + contentType: row.contentType, + contentDisposition: row.contentDisposition, + etag: row.etag, + }); + }), + ); + }); + + const handleHead = ( + request: HttpServerRequest.HttpServerRequest, + url: URL, + ) => + Effect.sync(() => { + const entry = authenticate(request); + if (entry === undefined) { + return errorJson( + 401, + "unauthorized", + "Access denied (invalid token)", + ); + } + const target = parseBlobUrl(url.searchParams.get("url") ?? ""); + const row = + target !== undefined && target.slug === entry.handle.slug + ? entry.blobs.get(target.pathname) + : undefined; + if (row === undefined) { + return errorJson( + 404, + "not_found", + "The requested blob does not exist", + ); + } + return HttpServerResponse.jsonUnsafe({ + ...rowJson(entry, row), + contentType: row.contentType, + contentDisposition: row.contentDisposition, + cacheControl: row.cacheControl, + }); + }); + + const handleList = ( + request: HttpServerRequest.HttpServerRequest, + url: URL, + ) => + Effect.sync(() => { + const entry = authenticate(request); + if (entry === undefined) { + return errorJson( + 401, + "unauthorized", + "Access denied (invalid token)", + ); + } + const prefix = url.searchParams.get("prefix") ?? ""; + const cursor = url.searchParams.get("cursor"); + const limitParam = Number(url.searchParams.get("limit") ?? ""); + const limit = + Number.isFinite(limitParam) && limitParam > 0 + ? limitParam + : DEFAULT_LIST_LIMIT; + const rows = [...entry.blobs.values()] + .filter((row) => row.pathname.startsWith(prefix)) + .filter((row) => cursor === null || row.pathname > cursor) + .sort((a, b) => (a.pathname < b.pathname ? -1 : 1)); + const page = rows.slice(0, limit); + const hasMore = rows.length > limit; + return HttpServerResponse.jsonUnsafe({ + hasMore, + ...(hasMore ? { cursor: page.at(-1)?.pathname } : {}), + blobs: page.map((row) => rowJson(entry, row)), + }); + }); + + const handleDelete = Effect.fn(function* ( + request: HttpServerRequest.HttpServerRequest, + ) { + const entry = authenticate(request); + if (entry === undefined) { + return errorJson(401, "unauthorized", "Access denied (invalid token)"); + } + const body = (yield* request.json.pipe( + Effect.catch(() => Effect.succeed(undefined)), + )) as { urls?: unknown } | undefined; + const urls = Array.isArray(body?.urls) ? body.urls : []; + return yield* Semaphore.withPermits( + entry.lock, + 1, + )( + Effect.gen(function* () { + let mutated = false; + for (const raw of urls) { + if (typeof raw !== "string") continue; + const target = parseBlobUrl(raw); + if (target === undefined || target.slug !== entry.handle.slug) { + continue; + } + const row = entry.blobs.get(target.pathname); + if (row === undefined) continue; // idempotent + entry.blobs.delete(target.pathname); + yield* gcObject(entry, row.object); + mutated = true; + } + if (mutated) yield* persistIndex(entry); + return HttpServerResponse.jsonUnsafe(null); + }), + ); + }); + + const handleContent = Effect.fn(function* ( + request: HttpServerRequest.HttpServerRequest, + url: URL, + ) { + const target = parseBlobUrl(`${serverUrl}${url.pathname}`); + const entry = target === undefined ? undefined : bySlug(target.slug); + if ( + target === undefined || + entry === undefined || + target.access !== entry.handle.access + ) { + return errorJson(404, "not_found", "The requested blob does not exist"); + } + if ( + entry.handle.access === "private" && + authenticate(request) !== entry + ) { + return errorJson( + 403, + "forbidden", + "Private blobs require an authenticated request", + ); + } + const row = entry.blobs.get(target.pathname); + if (row === undefined) { + return errorJson(404, "not_found", "The requested blob does not exist"); + } + const bytes = yield* fs + .readFile(path.join(entry.directory, OBJECTS_DIR, row.object)) + .pipe(Effect.catch(() => Effect.succeed(undefined))); + if (bytes === undefined) { + return errorJson(404, "not_found", "The requested blob does not exist"); + } + const download = url.searchParams.get("download") === "1"; + return HttpServerResponse.uint8Array(bytes, { + contentType: row.contentType, + }).pipe( + HttpServerResponse.setHeaders({ + etag: row.etag, + "cache-control": row.cacheControl, + "content-disposition": download + ? `attachment; filename="${target.pathname.split("/").at(-1) ?? target.pathname}"` + : row.contentDisposition, + }), + ); + }); + + const handler = Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const url = new URL(request.url, "http://local"); + if (url.pathname === "/") { + if (request.method === "PUT" && url.searchParams.has("pathname")) { + return yield* handlePut(request, url); + } + if (request.method === "GET" && url.searchParams.has("url")) { + return yield* handleHead(request, url); + } + if (request.method === "GET") { + return yield* handleList(request, url); + } + } + if (request.method === "POST" && url.pathname === "/delete") { + return yield* handleDelete(request); + } + if (request.method === "GET" || request.method === "HEAD") { + return yield* handleContent(request, url); + } + return errorJson(404, "not_found", "Unknown local blob route"); + }).pipe( + Effect.catchCause((cause) => + Effect.as( + Effect.logWarning("[alchemy dev] local blob server error", cause), + errorJson(500, "internal_server_error", "local blob server error"), + ), + ), + ); + + // A bind failure on an ephemeral port is unrecoverable dev machinery + // damage — die rather than surface an error the engine can't act on. + const context = (yield* Layer.build(httpServer(0, "127.0.0.1")).pipe( + Effect.orDie, + )) as Context.Context; + const server = Context.get(context, HttpServer.HttpServer); + yield* Effect.orDie(server.serve(handler)); + const serverUrl = HttpServer.formatAddress(server.address) + .replace("127.0.0.1", "localhost") + .replace(/\/$/, ""); + + const openStore = Effect.fn(function* (input: { + readonly storeId: string; + readonly access: BlobStoreAccess; + }) { + const slug = localStoreSlug(input.storeId); + const existing = bySlug(slug); + if (existing !== undefined) { + if (existing.handle.access === input.access) return existing.handle; + // Access-mode change: swap the handle in place (the local provider + // treats access as an in-place update — replacing would nuke the + // shared directory out from under the successor generation). + const swapped: StoreEntry = { + ...existing, + handle: { ...existing.handle, access: input.access }, + }; + stores.set(slug, swapped); + yield* Effect.orDie(persistIndex(swapped)); + return swapped.handle; + } + const secret = (yield* sha256(`alchemy-local-blob:${slug}`)).slice(0, 24); + const handle: LocalBlobStoreHandle = { + storeId: input.storeId, + slug, + access: input.access, + token: `vercel_blob_rw_${slug}_${secret}`, + }; + const directory = storeDirectory(input.storeId); + yield* Effect.orDie( + fs.makeDirectory(path.join(directory, OBJECTS_DIR), { + recursive: true, + }), + ); + const blobs = new Map(); + // Hydrate from a previous dev session's index (best-effort — a + // corrupt index starts the store empty rather than failing dev). + const index = yield* fs + .readFileString(path.join(directory, INDEX_FILE)) + .pipe(Effect.catch(() => Effect.succeed(undefined))); + if (index !== undefined) { + try { + const parsed = JSON.parse(index) as { blobs?: BlobRow[] }; + for (const row of parsed.blobs ?? []) { + if (typeof row?.pathname === "string") blobs.set(row.pathname, row); + } + } catch { + // start empty + } + } + stores.set(slug, { + handle, + directory, + blobs, + lock: Semaphore.makeUnsafe(1), + }); + return handle; + }); + + const dropStore = Effect.fn(function* (storeId: string) { + const entry = byStoreId(storeId); + if (entry !== undefined) stores.delete(entry.handle.slug); + yield* fs + .remove(storeDirectory(storeId), { recursive: true }) + .pipe(Effect.ignore); + }); + + const hasStore = (storeId: string) => + Effect.sync(() => byStoreId(storeId) !== undefined); + + return { + url: serverUrl, + openStore, + dropStore, + hasStore, + } satisfies LocalBlobServer; + }, +); + +/** Effect requirements of {@link makeLocalBlobServer}. */ +export type LocalBlobServerRequirements = + | FileSystem.FileSystem + | Path.Path + | Scope.Scope; diff --git a/packages/alchemy/src/Vercel/Blob/LocalBlobStoreProvider.ts b/packages/alchemy/src/Vercel/Blob/LocalBlobStoreProvider.ts new file mode 100644 index 0000000000..9fcee27e56 --- /dev/null +++ b/packages/alchemy/src/Vercel/Blob/LocalBlobStoreProvider.ts @@ -0,0 +1,163 @@ +/** + * The dev-mode (`alchemy dev`) provider for `Vercel.BlobStore` — emulates + * the store on the developer's machine instead of creating it on Vercel + * (ProviderMode doctrine; registry-style local provider, no per-resource + * process, so plain `RpcProvider.effect` rather than `LocalProvider.make`). + * + * One {@link makeLocalBlobServer} per sidecar serves every local store's + * data plane (directory-backed under `.alchemy/local/blob/{storeId}/`). + * Store rows carry the `dev:store_…` identity marker plus the emulator's + * endpoint + deterministic token as the `localApiUrl`/`localToken` + * attributes — the Blob capability bindings inject those into the host + * Function's env (`VERCEL_BLOB_API_URL` + `BLOB_READ_WRITE_TOKEN`, the + * probe-verified override), so our R/W/RW clients, the async + * `readWriteBlobFromEnv` client, and `@vercel/blob` all hit the emulator + * unchanged. Project connections are a deliberate no-op locally (there is + * no platform to inject env through — the binding env IS the injection). + */ +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; +import * as Redacted from "effect/Redacted"; +import { AlchemyContext } from "../../AlchemyContext.ts"; +import { isResolved } from "../../Diff.ts"; +import * as RpcProvider from "../../Local/RpcProvider.ts"; +import { createPhysicalName } from "../../PhysicalName.ts"; +import type { ResourceBinding } from "../../Resource.ts"; +import { LOCAL_ENTRY_URL } from "../LocalRuntime.ts"; +import { + BlobStore, + type BlobStoreAccess, + type BlobStoreBinding, + type BlobStoreEnvironment, + type BlobStoreRegion, +} from "./BlobStore.ts"; +import { makeLocalBlobServer } from "./LocalBlobServer.ts"; + +const DEFAULT_ACCESS: BlobStoreAccess = "public"; +const DEFAULT_REGION: BlobStoreRegion = "iad1"; +const ALL_ENVIRONMENTS: BlobStoreEnvironment[] = [ + "production", + "preview", + "development", +]; + +const createStoreName = (id: string, name: string | undefined) => + Effect.gen(function* () { + return name ?? (yield* createPhysicalName({ id, lowercase: true })); + }); + +const normalizeEnvironments = ( + environments: BlobStoreEnvironment[] | undefined, +): BlobStoreEnvironment[] => + [...new Set(environments?.length ? environments : ALL_ENVIRONMENTS)].sort(); + +const isLocalId = (storeId: string) => storeId.startsWith("dev:"); + +export const LocalBlobStoreProvider = () => + RpcProvider.effect( + BlobStore, + LOCAL_ENTRY_URL, + Effect.gen(function* () { + const path = yield* Path.Path; + const { dotAlchemy } = yield* AlchemyContext; + // One data-plane server per sidecar, started with the provider (the + // dual `local` thunk builds lazily — a plain deploy never constructs + // it unless a local-mode row needs acting on). + const server = yield* makeLocalBlobServer({ + baseDirectory: path.join(dotAlchemy, "local", "blob"), + }); + + return { + stables: ["storeId"], + diff: Effect.fn(function* ({ id, olds = {}, news = {}, output }) { + if (!isResolved(news)) return undefined; + if (!output?.storeId) return { action: "update" as const }; + // A real (non-`dev:`) storeId on a local-mode row is legacy + // damage — replace so the new generation mints a true local + // identity. + if (!isLocalId(output.storeId)) { + return { action: "replace" as const }; + } + const oldName = + output.name ?? (yield* createStoreName(id, olds.name)); + // Engine-owned names: the deployed name stays authoritative; only + // an explicit user-provided name can force a replace. + const name = news.name ?? oldName; + if (name !== oldName) { + return { action: "replace" as const }; + } + // Access/region change in-place locally (a live replace would + // nuke the shared directory out from under the successor), and a + // stale emulator endpoint (fresh dev session, new port) must + // republish attributes so bound Functions pick up the new env. + if ( + (news.access ?? output.access ?? DEFAULT_ACCESS) !== + (output.access ?? DEFAULT_ACCESS) || + (news.region ?? output.region ?? DEFAULT_REGION) !== + (output.region ?? DEFAULT_REGION) || + output.localApiUrl !== server.url + ) { + return { action: "update" as const }; + } + // No-op path: make sure the store is registered with THIS + // session's server anyway (idempotent; hydrates a previous + // session's directory) so the data plane serves it. + yield* server.openStore({ + storeId: output.storeId, + access: (output.access ?? DEFAULT_ACCESS) as BlobStoreAccess, + }); + return undefined; + }), + read: Effect.fn(function* ({ output }) { + if (!output?.storeId || !isLocalId(output.storeId)) return undefined; + const handle = yield* server.openStore({ + storeId: output.storeId, + access: (output.access ?? DEFAULT_ACCESS) as BlobStoreAccess, + }); + return { + ...output, + localApiUrl: server.url, + localToken: Redacted.make(handle.token), + }; + }), + reconcile: Effect.fn(function* ({ id, news = {}, output, bindings }) { + const name = output?.name ?? (yield* createStoreName(id, news.name)); + const access = (news.access ?? DEFAULT_ACCESS) as BlobStoreAccess; + // Never carry a real (non-`dev:`) id forward onto a local row. + const storeId = + output?.storeId && isLocalId(output.storeId) + ? output.storeId + : `dev:store_${name}`; + const handle = yield* server.openStore({ storeId, access }); + // Connections are a no-op locally — echo the desired project list + // (props ∪ binding contributions) so plans stay stable. + const contributedProjects = (bindings ?? []) + .filter( + (b: ResourceBinding & { action?: string }) => + b.action !== "delete", + ) + .flatMap((b) => b?.data?.projects ?? []); + const projectIds = [ + ...new Set([...(news.projects ?? []), ...contributedProjects]), + ].sort(); + return { + storeId, + name, + access, + region: news.region ?? DEFAULT_REGION, + projectIds, + envVarEnvironments: normalizeEnvironments(news.envVarEnvironments), + localApiUrl: server.url, + localToken: Redacted.make(handle.token), + }; + }), + delete: Effect.fn(function* ({ output }) { + // Idempotent: deregisters and removes the store directory. A + // legacy live id never had a local directory — dropStore's + // removal is best-effort and the live store (if any) is not + // touched from the local provider. + yield* server.dropStore(output.storeId); + }), + }; + }), + ); diff --git a/packages/alchemy/src/Vercel/Blob/PurgeStore.ts b/packages/alchemy/src/Vercel/Blob/PurgeStore.ts new file mode 100644 index 0000000000..e9a34ead76 --- /dev/null +++ b/packages/alchemy/src/Vercel/Blob/PurgeStore.ts @@ -0,0 +1,227 @@ +/** + * INTERNAL scaffolding for {@link BlobStore} deletion — NOT exported from + * the Vercel `index.ts`. + * + * Vercel refuses to delete a non-empty blob store (409 `not_empty`, + * live-verified — see processes/Vercel/PROBES.md) and offers no force + * parameter, so deleting a store means purging every blob through the data + * plane first. The data-plane token only materializes as a project env var + * injected by a store↔project connection, so the purge harvests one: + * + * 1. through an EXISTING connection's project when the store is still + * connected (the common owned-store case), and otherwise + * 2. through a throwaway `${name}-reaper` project created, connected, + * harvested from, and deleted on the spot — only reached when the + * delete actually 409s (an unconnected store holding blobs). + */ +import * as projects from "@distilled.cloud/vercel/projects"; +import { + createStorageStoreConnection, + deleteStorageStoreConnection, + deleteStorageStoresBlobById, + getStorageStoreConnections, +} from "@distilled.cloud/vercel/storage"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import * as Schedule from "effect/Schedule"; +import { deleteProjectByIdOrName, ensureProject } from "../Deploy/Engine.ts"; +import { VercelEnvironment } from "../VercelEnvironment.ts"; +import { + DEFAULT_BLOB_API_URL, + deleteBlobsRaw, + listBlobsRaw, + type BlobScope, +} from "./BlobHttp.ts"; +import type { BlobStoreAccess } from "./BlobStore.ts"; +import { BLOB_TOKEN_ENV } from "./BlobTypes.ts"; + +class BlobTokenNotInjected extends Data.TaggedError("BlobTokenNotInjected")<{ + projectId: string; +}> {} + +/** + * Read a project's platform-injected `BLOB_READ_WRITE_TOKEN` via the + * management API's single-env GET (the injected row is `encrypted`; the + * listing returns a sealed envelope but the single-row GET returns the + * plaintext). `undefined` when the row has not (yet) been injected. + */ +const readBlobTokenFromProject = (projectId: string) => + Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + const envsBody = yield* projects.filterProjectEnvs({ + idOrName: projectId, + teamId, + }); + const rows = ( + Array.isArray(envsBody) + ? envsBody + : typeof envsBody === "object" && + envsBody !== null && + "envs" in envsBody + ? (envsBody as { envs: unknown[] }).envs + : [] + ) as Array<{ key?: string; id?: string }>; + const tokenRow = rows.find((row) => row.key === BLOB_TOKEN_ENV); + if (tokenRow?.id === undefined) return undefined; + const decrypted = yield* projects.getProjectEnv({ + idOrName: projectId, + id: tokenRow.id, + teamId, + }); + return typeof decrypted === "object" && + decrypted !== null && + "value" in decrypted && + typeof decrypted.value === "string" && + decrypted.value !== "" + ? decrypted.value + : undefined; + }); + +/** Bounded retry around the harvest — injection can lag the connect call. */ +const harvestToken = (projectId: string, times: number) => + readBlobTokenFromProject(projectId).pipe( + Effect.flatMap((value) => + value === undefined + ? Effect.fail(new BlobTokenNotInjected({ projectId })) + : Effect.succeed(value), + ), + Effect.retry({ + while: (error) => error._tag === "BlobTokenNotInjected", + schedule: Schedule.max([ + Schedule.spaced("1 second"), + Schedule.recurs(times), + ]), + }), + Effect.catchTag("BlobTokenNotInjected", () => Effect.succeed(undefined)), + ); + +/** Empty the store through the data plane (batched by page; bounded). */ +const purgeBlobs = (scope: BlobScope) => + Effect.gen(function* () { + for (let page = 0; page < 100; page++) { + const listed = yield* listBlobsRaw(scope, { limit: 1000 }); + if (listed.blobs.length === 0) break; + // Delete by canonical URL so re-encoding pathnames can't mismatch. + yield* deleteBlobsRaw( + scope, + listed.blobs.map((row) => row.url), + ); + if (!listed.hasMore) break; + } + }); + +/** + * Delete a blob store; with `forceDestroy`, purge its contents first — + * harvesting the data-plane token through an existing connection, or a + * throwaway reaper project when the store is unconnected but non-empty. + * Without `forceDestroy` a non-empty store fails with the platform's typed + * `Conflict` (`not_empty`) — the data-protection default. Idempotent: a + * store that is already gone is not an error. + */ +export const purgeAndDeleteBlobStore = Effect.fn(function* (options: { + storeId: string; + name: string; + access: BlobStoreAccess; + forceDestroy: boolean; +}) { + const { storeId, name, access, forceDestroy } = options; + const { teamId } = yield* VercelEnvironment.current; + + const connectionsOf = getStorageStoreConnections({ storeId, teamId }).pipe( + Effect.map((response) => response.connections), + Effect.catchTag("NotFound", () => + Effect.succeed([] as Array<{ id: string; projectId: string }>), + ), + ); + + const disconnectAll = Effect.gen(function* () { + const connections = yield* connectionsOf; + for (const connection of connections) { + yield* deleteStorageStoreConnection({ + storeId, + connectionId: connection.id, + teamId, + }).pipe(Effect.catchTag("NotFound", () => Effect.void)); + } + }); + + const deleteStore = deleteStorageStoresBlobById({ + id: storeId, + teamId, + }).pipe(Effect.catchTag("NotFound", () => Effect.void)); + + const scopeWith = (token: string): BlobScope => ({ + token: Redacted.make(token), + storeId, + access, + apiUrl: DEFAULT_BLOB_API_URL, + }); + + // 1. With forceDestroy: purge through an existing connection while it + // still exists — the common case for an owned store bound to a + // Function. + if (forceDestroy) { + const connections = yield* connectionsOf; + if (connections.length > 0) { + const token = yield* harvestToken(connections[0]!.projectId, 5); + if (token !== undefined) { + yield* purgeBlobs(scopeWith(token)); + } + } + } + + // 2. Disconnect BEFORE deleting: store deletion does not remove the env + // vars its connections injected into projects (observed live). + yield* disconnectAll; + + // 3. Delete. Without forceDestroy a `not_empty` Conflict propagates + // typed (data protection). With it, the Conflict means no usable + // token was available above (unconnected store, or a connection whose + // project we could not read) — build a throwaway reaper project, + // purge through it, retry. + if (!forceDestroy) { + return yield* deleteStore; + } + yield* deleteStore.pipe( + Effect.catchTag("Conflict", () => + Effect.gen(function* () { + const reaper = yield* ensureProject({ name: `${name}-reaper` }); + yield* Effect.gen(function* () { + yield* createStorageStoreConnection({ + storeId, + projectId: reaper.id, + envVarEnvironments: ["production", "preview", "development"], + type: "integration", + teamId, + }).pipe( + // Already-connected race (`store_project_connection_not_unique`): + // trust observation. + Effect.catchTag("BadRequest", (error) => + connectionsOf.pipe( + Effect.flatMap((after) => + after.some((connection) => connection.projectId === reaper.id) + ? Effect.void + : Effect.fail(error), + ), + ), + ), + ); + const token = yield* harvestToken(reaper.id, 15); + if (token !== undefined) { + yield* purgeBlobs(scopeWith(token)); + } + yield* disconnectAll; + yield* deleteStore; + }).pipe( + // The reaper is transient bookkeeping: remove it whether or not + // the purge succeeded (deleting the project also removes the + // injected env row; the connection was already disconnected). + Effect.ensuring( + deleteProjectByIdOrName(reaper.id).pipe(Effect.ignore), + ), + ); + }), + ), + ); +}); diff --git a/packages/alchemy/src/Vercel/Blob/ReadBlob.ts b/packages/alchemy/src/Vercel/Blob/ReadBlob.ts new file mode 100644 index 0000000000..dcd431c316 --- /dev/null +++ b/packages/alchemy/src/Vercel/Blob/ReadBlob.ts @@ -0,0 +1,77 @@ +import type * as Effect from "effect/Effect"; +import * as Binding from "../../Binding.ts"; +import type { RuntimeContext } from "../../RuntimeContext.ts"; +import type { BlobStore } from "./BlobStore.ts"; +import type { + BlobListError, + BlobObject, + BlobReadError, + GetBlobResult, + ListBlobsOptions, + ListBlobsResult, +} from "./BlobTypes.ts"; + +/** + * Read-only runtime client for a {@link BlobStore} — head, get, and list. + */ +export interface ReadBlobClient { + /** Fetch a blob's metadata; fails with the typed `BlobNotFound` when missing. */ + head( + pathname: string, + ): Effect.Effect; + /** Fetch a blob's content (buffered); fails with `BlobNotFound` when missing. */ + get( + pathname: string, + ): Effect.Effect; + /** List one page of blobs. */ + list( + options?: ListBlobsOptions, + ): Effect.Effect; +} + +/** + * Read access to a Vercel Blob store from inside a deployed Function — + * the least-privilege half of the `ReadBlob`/`WriteBlob`/`ReadWriteBlob` + * split. The client exposes only `head`/`get`/`list`; write operations are + * not present on the type. + * + * Binding a store to a Function transparently connects the Function's + * project to the store (the platform then injects the data-plane token + * env), so there is nothing else to wire. + * + * Provide the implementation with `Effect.provide(Vercel.ReadBlobHttp)`. + * + * @binding + * @section Reading Blobs + * @example Read inside an Effect-native Function + * ```typescript + * export default class Api extends Vercel.Function()( + * "Api", + * { main: import.meta.url }, + * Effect.gen(function* () { + * const uploads = yield* Vercel.ReadBlob(Uploads); + * return { + * fetch: Effect.gen(function* () { + * const blob = yield* uploads.get("hello.txt").pipe(Effect.orDie); + * return yield* HttpServerResponse.text(yield* blob.text); + * }), + * }; + * }).pipe(Effect.provide(Vercel.ReadBlobHttp)), + * ) {} + * ``` + * + * @example Handle a missing blob with the typed tag + * ```typescript + * const body = yield* uploads.get("maybe.txt").pipe( + * Effect.flatMap((blob) => blob.text), + * Effect.catchTag("Vercel.Blob.NotFound", () => Effect.succeed("")), + * ); + * ``` + */ +export interface ReadBlob extends Binding.Service< + ReadBlob, + "Vercel.ReadBlob", + (store: BlobStore) => Effect.Effect +> {} + +export const ReadBlob = Binding.Service("Vercel.ReadBlob"); diff --git a/packages/alchemy/src/Vercel/Blob/ReadBlobHttp.ts b/packages/alchemy/src/Vercel/Blob/ReadBlobHttp.ts new file mode 100644 index 0000000000..648ce20c16 --- /dev/null +++ b/packages/alchemy/src/Vercel/Blob/ReadBlobHttp.ts @@ -0,0 +1,94 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import type { FunctionEnvironment } from "../Functions/FunctionBridge.ts"; +import { + getBlobRaw, + headBlobRaw, + listBlobsRaw, + makeBlobHttpBinding, + type BlobScope, +} from "./BlobHttp.ts"; +import { ReadBlob, type ReadBlobClient } from "./ReadBlob.ts"; +import type { ListBlobsOptions } from "./BlobTypes.ts"; + +/** + * Build a {@link ReadBlobClient} over a resolved data-plane scope. The + * `HttpClient` context is captured once; every operation resolves the scope + * lazily (so the platform-injected token is read from the live env). + */ +export const makeReadBlobClient = ( + scope: Effect.Effect, + context: Context.Context, +): ReadBlobClient => ({ + head: (pathname: string) => + scope.pipe( + Effect.flatMap((s) => headBlobRaw(s, pathname)), + // Reads cannot conflict on create or fail an etag precondition — + // those tags are impossible by construction; a hit is a defect. + Effect.catchTag( + ["Vercel.Blob.AlreadyExists", "Vercel.Blob.PreconditionFailed"], + (e) => Effect.die(e), + ), + Effect.provideContext(context), + ), + get: (pathname: string) => + scope.pipe( + Effect.flatMap((s) => getBlobRaw(s, pathname)), + Effect.catchTag( + ["Vercel.Blob.AlreadyExists", "Vercel.Blob.PreconditionFailed"], + (e) => Effect.die(e), + ), + Effect.provideContext(context), + ), + list: (options?: ListBlobsOptions) => + scope.pipe( + Effect.flatMap((s) => listBlobsRaw(s, options)), + Effect.catchTag( + [ + "Vercel.Blob.AlreadyExists", + "Vercel.Blob.NotFound", + "Vercel.Blob.PreconditionFailed", + ], + (e) => Effect.die(e), + ), + Effect.provideContext(context), + ), +}); + +/** + * HTTP (data-plane) implementation of {@link ReadBlob}. + * + * Deploy half: contributes the host Function's project onto the store's + * binding contract (the store's reconciler creates the connection, which + * makes the platform inject `BLOB_READ_WRITE_TOKEN`). Runtime half: the + * read-only client over `https://blob.vercel-storage.com`. + * + * ## Runtime authorization + * + * Deployed compute is authorized by the **platform-injected + * `BLOB_READ_WRITE_TOKEN`** that Vercel provisions when the store is + * connected to the Function's project — alchemy never mints, stores, or + * syncs a Blob credential itself; the deploy half only declares the + * store↔project connection. The token is store-scoped but read-write: the + * platform offers no read-only variant, so this binding's least-privilege + * guarantee is enforced at the **client surface** (no `put`/`del` methods + * exist on {@link ReadBlobClient}), not at the credential. + * + * Provide on the Function's init Effect: + * `Effect.provide(Vercel.ReadBlobHttp)`. + */ +export const ReadBlobHttp: Layer.Layer< + ReadBlob, + never, + HttpClient.HttpClient | FunctionEnvironment +> = Layer.effect( + ReadBlob, + Effect.gen(function* () { + const context = yield* Effect.context(); + return yield* makeBlobHttpBinding({ + makeClient: (scope) => makeReadBlobClient(scope, context), + }); + }), +); diff --git a/packages/alchemy/src/Vercel/Blob/ReadWriteBlob.ts b/packages/alchemy/src/Vercel/Blob/ReadWriteBlob.ts new file mode 100644 index 0000000000..859af4faa1 --- /dev/null +++ b/packages/alchemy/src/Vercel/Blob/ReadWriteBlob.ts @@ -0,0 +1,70 @@ +import type * as Effect from "effect/Effect"; +import * as Binding from "../../Binding.ts"; +import type { BlobStore } from "./BlobStore.ts"; +import type { ReadBlobClient } from "./ReadBlob.ts"; +import type { WriteBlobClient } from "./WriteBlob.ts"; + +/** + * Full-access runtime client for a {@link BlobStore} — the union of + * {@link ReadBlobClient} and {@link WriteBlobClient}. + */ +export interface ReadWriteBlobClient extends ReadBlobClient, WriteBlobClient {} + +/** + * Read + write access to a Vercel Blob store from inside a deployed + * Function. Binding a store to a Function transparently connects the + * Function's project to the store — the platform then injects the + * `BLOB_READ_WRITE_TOKEN` data-plane env var the client authenticates + * with — so there is nothing else to wire. + * + * Provide the implementation with `Effect.provide(Vercel.ReadWriteBlobHttp)`. + * + * @binding + * @section Reading and Writing Blobs + * @example Round-trip inside an Effect-native Function + * ```typescript + * import * as Vercel from "alchemy/Vercel"; + * import * as Effect from "effect/Effect"; + * import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; + * + * export const Uploads = Vercel.BlobStore("Uploads"); + * + * export default class Api extends Vercel.Function()( + * "Api", + * { main: import.meta.url }, + * Effect.gen(function* () { + * const uploads = yield* Vercel.ReadWriteBlob(Uploads); + * return { + * fetch: Effect.gen(function* () { + * const put = yield* uploads + * .put("greeting.txt", "Hello!", { contentType: "text/plain" }) + * .pipe(Effect.orDie); + * const blob = yield* uploads.get("greeting.txt").pipe(Effect.orDie); + * return yield* HttpServerResponse.json({ + * etag: put.etag, + * text: yield* blob.text, + * }); + * }), + * }; + * }).pipe(Effect.provide(Vercel.ReadWriteBlobHttp)), + * ) {} + * ``` + * + * @section Least Privilege + * @example Split access with `ReadBlob` / `WriteBlob` + * When a Function only reads (or only writes), bind the narrower service — + * the client type simply has no write (or read) methods. + * ```typescript + * const reader = yield* Vercel.ReadBlob(Uploads); // head / get / list only + * const writer = yield* Vercel.WriteBlob(Uploads); // put / del only + * ``` + */ +export interface ReadWriteBlob extends Binding.Service< + ReadWriteBlob, + "Vercel.ReadWriteBlob", + (store: BlobStore) => Effect.Effect +> {} + +export const ReadWriteBlob = Binding.Service( + "Vercel.ReadWriteBlob", +); diff --git a/packages/alchemy/src/Vercel/Blob/ReadWriteBlobHttp.ts b/packages/alchemy/src/Vercel/Blob/ReadWriteBlobHttp.ts new file mode 100644 index 0000000000..40d9d2a323 --- /dev/null +++ b/packages/alchemy/src/Vercel/Blob/ReadWriteBlobHttp.ts @@ -0,0 +1,45 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import type { FunctionEnvironment } from "../Functions/FunctionBridge.ts"; +import { makeBlobHttpBinding } from "./BlobHttp.ts"; +import { makeReadBlobClient } from "./ReadBlobHttp.ts"; +import { makeWriteBlobClient } from "./WriteBlobHttp.ts"; +import { ReadWriteBlob, type ReadWriteBlobClient } from "./ReadWriteBlob.ts"; + +/** + * HTTP (data-plane) implementation of {@link ReadWriteBlob} — composes the + * read and write client builders over one shared scope. + * + * Deploy half: contributes the host Function's project onto the store's + * binding contract (the store's reconciler creates the connection, which + * makes the platform inject `BLOB_READ_WRITE_TOKEN`). Runtime half: the + * full read/write client over `https://blob.vercel-storage.com`. + * + * ## Runtime authorization + * + * Deployed compute is authorized by the **platform-injected + * `BLOB_READ_WRITE_TOKEN`** provisioned via the store↔project connection + * the deploy half declares — alchemy never mints or syncs a Blob + * credential itself, and the token is exactly the store-scoped read-write + * authority this binding's client exposes. + * + * Provide on the Function's init Effect: + * `Effect.provide(Vercel.ReadWriteBlobHttp)`. + */ +export const ReadWriteBlobHttp: Layer.Layer< + ReadWriteBlob, + never, + HttpClient.HttpClient | FunctionEnvironment +> = Layer.effect( + ReadWriteBlob, + Effect.gen(function* () { + const context = yield* Effect.context(); + return yield* makeBlobHttpBinding({ + makeClient: (scope): ReadWriteBlobClient => ({ + ...makeReadBlobClient(scope, context), + ...makeWriteBlobClient(scope, context), + }), + }); + }), +); diff --git a/packages/alchemy/src/Vercel/Blob/WriteBlob.ts b/packages/alchemy/src/Vercel/Blob/WriteBlob.ts new file mode 100644 index 0000000000..562351bc39 --- /dev/null +++ b/packages/alchemy/src/Vercel/Blob/WriteBlob.ts @@ -0,0 +1,88 @@ +import type * as Effect from "effect/Effect"; +import * as Binding from "../../Binding.ts"; +import type { RuntimeContext } from "../../RuntimeContext.ts"; +import type { BlobStore } from "./BlobStore.ts"; +import type { + BlobDeleteError, + BlobPutError, + PutBlobBody, + PutBlobOptions, + PutBlobResult, +} from "./BlobTypes.ts"; + +/** + * Write-only runtime client for a {@link BlobStore} — put and delete. + */ +export interface WriteBlobClient { + /** + * Write a blob. `ifMatch` (CAS) and `allowOverwrite: false` (conditional + * create) fail with the typed `BlobPreconditionFailed` / + * `BlobAlreadyExists` respectively. + */ + put( + pathname: string, + body: PutBlobBody, + options?: PutBlobOptions, + ): Effect.Effect; + /** Delete one or more blobs by pathname (idempotent). */ + del( + pathnames: string | readonly string[], + ): Effect.Effect; +} + +/** + * Write access to a Vercel Blob store from inside a deployed Function — + * the write half of the `ReadBlob`/`WriteBlob`/`ReadWriteBlob` split. + * + * Binding a store to a Function transparently connects the Function's + * project to the store (the platform then injects the data-plane token + * env), so there is nothing else to wire. + * + * Provide the implementation with `Effect.provide(Vercel.WriteBlobHttp)`. + * + * @binding + * @section Writing Blobs + * @example Write inside an Effect-native Function + * ```typescript + * export default class Api extends Vercel.Function()( + * "Api", + * { main: import.meta.url }, + * Effect.gen(function* () { + * const uploads = yield* Vercel.WriteBlob(Uploads); + * return { + * fetch: Effect.gen(function* () { + * const put = yield* uploads + * .put("hello.txt", "Hello, World!", { contentType: "text/plain" }) + * .pipe(Effect.orDie); + * return yield* HttpServerResponse.json({ etag: put.etag }); + * }), + * }; + * }).pipe(Effect.provide(Vercel.WriteBlobHttp)), + * ) {} + * ``` + * + * @example Compare-and-swap with `ifMatch` + * ```typescript + * const updated = yield* uploads + * .put("state.json", JSON.stringify(next), { ifMatch: current.etag }) + * .pipe( + * Effect.catchTag("Vercel.Blob.PreconditionFailed", () => + * Effect.fail(new ConcurrentWrite()), + * ), + * ); + * ``` + * + * @example Conditional create with `allowOverwrite: false` + * ```typescript + * yield* uploads.put("lock", "1", { allowOverwrite: false }).pipe( + * Effect.catchTag("Vercel.Blob.AlreadyExists", () => Effect.void), + * ); + * ``` + */ +export interface WriteBlob extends Binding.Service< + WriteBlob, + "Vercel.WriteBlob", + (store: BlobStore) => Effect.Effect +> {} + +export const WriteBlob = Binding.Service("Vercel.WriteBlob"); diff --git a/packages/alchemy/src/Vercel/Blob/WriteBlobHttp.ts b/packages/alchemy/src/Vercel/Blob/WriteBlobHttp.ts new file mode 100644 index 0000000000..317b81593b --- /dev/null +++ b/packages/alchemy/src/Vercel/Blob/WriteBlobHttp.ts @@ -0,0 +1,85 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import type { FunctionEnvironment } from "../Functions/FunctionBridge.ts"; +import { + deleteBlobsRaw, + makeBlobHttpBinding, + putBlobRaw, + type BlobScope, +} from "./BlobHttp.ts"; +import { WriteBlob, type WriteBlobClient } from "./WriteBlob.ts"; +import type { PutBlobBody, PutBlobOptions } from "./BlobTypes.ts"; + +/** + * Build a {@link WriteBlobClient} over a resolved data-plane scope. The + * `HttpClient` context is captured once; every operation resolves the scope + * lazily (so the platform-injected token is read from the live env). + */ +export const makeWriteBlobClient = ( + scope: Effect.Effect, + context: Context.Context, +): WriteBlobClient => ({ + put: (pathname: string, body: PutBlobBody, options?: PutBlobOptions) => + scope.pipe( + Effect.flatMap((s) => putBlobRaw(s, pathname, body, options)), + // A put never observes a missing blob — impossible by construction. + Effect.catchTag("Vercel.Blob.NotFound", (e) => Effect.die(e)), + Effect.provideContext(context), + ), + del: (pathnames: string | readonly string[]) => + scope.pipe( + Effect.flatMap((s) => + deleteBlobsRaw( + s, + typeof pathnames === "string" ? [pathnames] : pathnames, + ), + ), + // Delete is idempotent (missing blobs succeed) and carries no + // conditional-write options — these tags are impossible here. + Effect.catchTag( + [ + "Vercel.Blob.AlreadyExists", + "Vercel.Blob.NotFound", + "Vercel.Blob.PreconditionFailed", + ], + (e) => Effect.die(e), + ), + Effect.provideContext(context), + ), +}); + +/** + * HTTP (data-plane) implementation of {@link WriteBlob}. + * + * Deploy half: contributes the host Function's project onto the store's + * binding contract (the store's reconciler creates the connection, which + * makes the platform inject `BLOB_READ_WRITE_TOKEN`). Runtime half: the + * write-only client over `https://blob.vercel-storage.com`. + * + * ## Runtime authorization + * + * Deployed compute is authorized by the **platform-injected + * `BLOB_READ_WRITE_TOKEN`** provisioned via the store↔project connection + * the deploy half declares — alchemy never mints or syncs a Blob credential + * itself. The platform token is store-scoped read-write (its only variant), + * so the write-only restriction lives at the **client surface** (no + * `head`/`get`/`list` on {@link WriteBlobClient}), not at the credential. + * + * Provide on the Function's init Effect: + * `Effect.provide(Vercel.WriteBlobHttp)`. + */ +export const WriteBlobHttp: Layer.Layer< + WriteBlob, + never, + HttpClient.HttpClient | FunctionEnvironment +> = Layer.effect( + WriteBlob, + Effect.gen(function* () { + const context = yield* Effect.context(); + return yield* makeBlobHttpBinding({ + makeClient: (scope) => makeWriteBlobClient(scope, context), + }); + }), +); diff --git a/packages/alchemy/src/Vercel/Blob/index.ts b/packages/alchemy/src/Vercel/Blob/index.ts new file mode 100644 index 0000000000..ff30eb3380 --- /dev/null +++ b/packages/alchemy/src/Vercel/Blob/index.ts @@ -0,0 +1,12 @@ +export * from "./BlobFromEnv.ts"; +export * from "./BlobStore.ts"; +export * from "./BlobTypes.ts"; +export * from "./ReadBlob.ts"; +export * from "./ReadBlobHttp.ts"; +export * from "./ReadWriteBlob.ts"; +export * from "./ReadWriteBlobHttp.ts"; +export * from "./WriteBlob.ts"; +export * from "./WriteBlobHttp.ts"; +// NOTE: BlobHttp.ts (raw data-plane client + shared binding scaffolding) is +// deliberately NOT exported — internal scaffolding per the shared-scaffolding +// convention. diff --git a/packages/alchemy/src/Vercel/Checks/Check.ts b/packages/alchemy/src/Vercel/Checks/Check.ts new file mode 100644 index 0000000000..99fe520196 --- /dev/null +++ b/packages/alchemy/src/Vercel/Checks/Check.ts @@ -0,0 +1,309 @@ +import * as checks from "@distilled.cloud/vercel/checks_v2"; +import * as Effect from "effect/Effect"; +import { isResolved } from "../../Diff.ts"; +import { createPhysicalName } from "../../PhysicalName.ts"; +import * as Provider from "../../Provider.ts"; +import { Resource } from "../../Resource.ts"; +import type { Providers } from "../Providers.ts"; +import { VercelEnvironment } from "../VercelEnvironment.ts"; + +/** What a check's runs need before they can start. */ +export type CheckRequires = "build-ready" | "deployment-url" | "none"; + +/** The deployment lifecycle stage a failing check blocks. */ +export type CheckBlocks = + | "build-start" + | "deployment-start" + | "deployment-alias" + | "deployment-promotion" + | "none"; + +export interface CheckProps { + /** + * The project the check is registered on: a project id (`prj_…`) or + * project name. Changing the project replaces the check. + */ + project: string; + /** + * Display name of the check. If omitted, a unique name is generated from + * `${app}-${stage}-${id}`. Renaming an existing check updates it in + * place. + */ + name?: string; + /** + * What the check's runs need before they can start: the build artifacts + * (`build-ready`), a live deployment URL (`deployment-url`), or nothing. + * + * @default "deployment-url" + */ + requires?: CheckRequires; + /** + * The deployment lifecycle stage a failing run blocks. NOTE: the live + * API currently accepts only `deployment-alias` and `none` (verified + * 2026-08-13: other documented values are rejected with "Only + * deployment-alias and none are currently supported for blocks"). + * + * @default "none" + */ + blocks?: CheckBlocks; + /** + * Deployment targets the check runs on (e.g. `["production"]`). + * Left to the platform default when omitted. + */ + targets?: string[]; + /** + * Whether a failed run can be re-requested from the dashboard. + * @default false + */ + isRerequestable?: boolean; + /** + * Seconds until a running check run is considered timed out. Left to the + * platform default when omitted. + */ + timeout?: number; +} + +export type Check = Resource< + "Vercel.Check", + CheckProps, + { + /** The check id (`chk_…`). */ + checkId: string; + /** The id of the project the check is registered on. */ + projectId: string; + /** Display name of the check. */ + name: string; + /** What the check's runs need before they can start. */ + requires: string; + /** The deployment lifecycle stage a failing run blocks. */ + blocks: string; + /** Deployment targets the check runs on (sorted). */ + targets: string[]; + /** Where the check's runs originate (`webhook` for API-registered checks). */ + sourceKind: string; + /** Whether a failed run can be re-requested. */ + isRerequestable: boolean; + /** Run timeout in seconds. */ + timeout: number; + /** Creation time in epoch milliseconds. */ + createdAt: number; + /** Last update time in epoch milliseconds. */ + updatedAt: number; + }, + never, + Providers +>; + +type CheckAttributes = Check["Attributes"]; + +/** + * A Vercel Check (checks v2) — an external quality gate registered on a + * project. Every deployment creates a run per check; an external system + * reports the run's conclusion, and a failing run can block aliasing or + * promotion. + * + * API-registered checks are webhook-sourced: pair the check with a + * `Vercel.Webhook` subscribed to `deployment.check.rerequested` (and drive + * run conclusions via the deployment check-run endpoints) to close the loop. + * + * @resource + * @section Registering a check + * @example A non-blocking check + * ```typescript + * const project = yield* Vercel.Project("Api", {}); + * const check = yield* Vercel.Check("Smoke", { + * project: project.projectId, + * }); + * ``` + * + * @example A check that gates promotion to production + * ```typescript + * yield* Vercel.Check("E2E", { + * project: project.projectId, + * name: "e2e-suite", + * requires: "deployment-url", + * blocks: "deployment-promotion", + * targets: ["production"], + * isRerequestable: true, + * timeout: 600, + * }); + * ``` + * + * @see https://vercel.com/docs/checks + */ +export const Check = Resource("Vercel.Check"); + +const createCheckName = (id: string) => createPhysicalName({ id }); + +const teamScope = Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + return teamId === undefined ? {} : { teamId }; +}); + +const toAttributes = (check: { + id: string; + projectId: string; + name: string; + requires: string; + blocks: string; + targets: ReadonlyArray; + sourceKind: string; + isRerequestable: boolean; + timeout: number; + createdAt: number; + updatedAt: number; +}): CheckAttributes => ({ + checkId: check.id, + projectId: check.projectId, + name: check.name, + requires: check.requires, + blocks: check.blocks, + targets: [...check.targets].sort(), + sourceKind: check.sourceKind, + isRerequestable: check.isRerequestable, + timeout: check.timeout, + createdAt: check.createdAt, + updatedAt: check.updatedAt, +}); + +/** + * Observe the check. The persisted id is a cache; with no id (state-loss + * recovery) an unambiguous name match among the project's webhook-sourced + * checks recovers it (platform-native checks — Lint etc. — are excluded by + * sourceKind). + */ +const findCheck = ( + project: string, + scope: { teamId?: string }, + checkId: string | undefined, + name: string, +) => + Effect.gen(function* () { + if (checkId !== undefined) { + // A definite id that 404s (typed NotFound — patched) means the check + // is gone; do NOT fall through to name recovery, which would risk + // adopting an unrelated same-named check. + return yield* checks + .getProjectCheck({ projectIdOrName: project, checkId, ...scope }) + .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))); + } + const { checks: all } = yield* checks.listProjectChecks({ + projectIdOrName: project, + ...scope, + }); + const matches = all.filter( + (c) => c.name === name && c.sourceKind === "webhook", + ); + return matches.length === 1 ? matches[0] : undefined; + }); + +export const CheckProvider = () => + Provider.succeed(Check, { + stables: ["checkId", "projectId", "sourceKind", "createdAt"], + diff: Effect.fn(function* ({ olds, news }) { + if (!isResolved(news)) return undefined; + if (olds?.project !== undefined && news.project !== olds.project) { + return { action: "replace" } as const; + } + return undefined; + }), + read: Effect.fn(function* ({ id, olds, output }) { + const scope = yield* teamScope; + const project = output?.projectId ?? olds?.project; + if (project === undefined) return undefined; + const name = output?.name ?? olds?.name ?? (yield* createCheckName(id)); + const observed = yield* findCheck(project, scope, output?.checkId, name); + return observed === undefined ? undefined : toAttributes(observed); + }), + reconcile: Effect.fn(function* ({ id, news, output }) { + const scope = yield* teamScope; + const name = news.name ?? output?.name ?? (yield* createCheckName(id)); + + // Observe — cloud state is authoritative; `output` only caches the + // stable id. + const observed = yield* findCheck( + news.project, + scope, + output?.checkId, + name, + ); + + // Ensure — missing → create (API-registered checks are + // webhook-sourced by default). + if (observed === undefined) { + const created = yield* checks.createProjectCheck({ + projectIdOrName: news.project, + name, + requires: news.requires ?? "deployment-url", + blocks: news.blocks ?? "none", + ...(news.targets !== undefined ? { targets: news.targets } : {}), + ...(news.isRerequestable !== undefined + ? { isRerequestable: news.isRerequestable } + : {}), + ...(news.timeout !== undefined ? { timeout: news.timeout } : {}), + ...scope, + }); + return toAttributes(created); + } + + // Sync — diff OBSERVED fields against the desired ones and PATCH + // only the delta. + const patch: { + name?: string; + requires?: CheckRequires; + blocks?: CheckBlocks; + targets?: string[]; + isRerequestable?: boolean; + timeout?: number; + } = {}; + if (observed.name !== name) patch.name = name; + const desiredRequires = news.requires ?? "deployment-url"; + if (observed.requires !== desiredRequires) { + patch.requires = desiredRequires; + } + const desiredBlocks = news.blocks ?? "none"; + if (observed.blocks !== desiredBlocks) patch.blocks = desiredBlocks; + if ( + news.targets !== undefined && + [...observed.targets].sort().join(",") !== + [...news.targets].sort().join(",") + ) { + patch.targets = news.targets; + } + if ( + news.isRerequestable !== undefined && + observed.isRerequestable !== news.isRerequestable + ) { + patch.isRerequestable = news.isRerequestable; + } + if (news.timeout !== undefined && observed.timeout !== news.timeout) { + patch.timeout = news.timeout; + } + + if (Object.keys(patch).length === 0) { + return toAttributes(observed); + } + const updated = yield* checks.updateProjectCheck({ + projectIdOrName: news.project, + checkId: observed.id, + ...patch, + ...scope, + }); + return toAttributes(updated); + }), + delete: Effect.fn(function* ({ output }) { + const scope = yield* teamScope; + // Already gone (out-of-band delete, or a deleted host project) is + // success, not an error. + yield* checks + .deleteProjectCheck({ + projectIdOrName: output.projectId, + checkId: output.checkId, + ...scope, + }) + .pipe( + Effect.asVoid, + Effect.catchTag("NotFound", () => Effect.void), + ); + }), + }); diff --git a/packages/alchemy/src/Vercel/Checks/index.ts b/packages/alchemy/src/Vercel/Checks/index.ts new file mode 100644 index 0000000000..9cf4ca9fef --- /dev/null +++ b/packages/alchemy/src/Vercel/Checks/index.ts @@ -0,0 +1 @@ +export * from "./Check.ts"; diff --git a/packages/alchemy/src/Vercel/Credentials.ts b/packages/alchemy/src/Vercel/Credentials.ts new file mode 100644 index 0000000000..3811f45455 --- /dev/null +++ b/packages/alchemy/src/Vercel/Credentials.ts @@ -0,0 +1,52 @@ +import { ConfigError } from "@distilled.cloud/core/errors"; +import { + Credentials, + DEFAULT_API_BASE_URL, +} from "@distilled.cloud/vercel/Credentials"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { getAuthProvider } from "../Auth/AuthProvider.ts"; +import { getEnv } from "../Auth/Env.ts"; +import { ALCHEMY_PROFILE, AlchemyProfile } from "../Auth/Profile.ts"; +import { + VERCEL_AUTH_PROVIDER_NAME, + type VercelAuthConfig, + type VercelResolvedCredentials, +} from "./AuthProvider.ts"; + +export { Credentials } from "@distilled.cloud/vercel/Credentials"; + +export const fromAuthProvider = () => + Layer.effect( + Credentials, + Effect.gen(function* () { + const profile = yield* AlchemyProfile; + const auth = yield* getAuthProvider< + VercelAuthConfig, + VercelResolvedCredentials + >(VERCEL_AUTH_PROVIDER_NAME); + const profileName = yield* ALCHEMY_PROFILE; + const ci = yield* Config.boolean("CI").pipe(Config.withDefault(false)); + const apiBaseUrl = + (yield* getEnv("VERCEL_API_URL")) ?? DEFAULT_API_BASE_URL; + + return yield* profile.loadOrConfigure(auth, profileName, { ci }).pipe( + Effect.flatMap((config) => + auth.read(profileName, config as VercelAuthConfig), + ), + Effect.map((creds) => ({ + token: creds.apiToken, + apiBaseUrl, + })), + Effect.mapError( + (e) => + new ConfigError({ + message: `Failed to resolve Vercel credentials for profile '${profileName}': ${(e as { message?: string }).message ?? String(e)}`, + }), + ), + Effect.orDie, + Effect.cached, + ); + }), + ); diff --git a/packages/alchemy/src/Vercel/Deploy/Artifact.ts b/packages/alchemy/src/Vercel/Deploy/Artifact.ts new file mode 100644 index 0000000000..b5e9fbcd8e --- /dev/null +++ b/packages/alchemy/src/Vercel/Deploy/Artifact.ts @@ -0,0 +1,127 @@ +/** + * Internal deploy-engine artifact model (DESIGN §6.1). + * + * A {@link DeploymentArtifact} is an immutable description of one Vercel + * deployment: the full `.vercel/output` (Build Output v3) file tree, each + * file content-addressed by its SHA1 (Vercel's content address for the + * upload API), plus a deterministic artifact hash used as THE diff key. + * + * NOT exported from `Vercel/index.ts` — this is a library shared by the + * `Function` (and later `Website`) providers, the same status as the + * bundling internals. + */ +import { createHash } from "node:crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import type { PlatformError } from "effect/PlatformError"; +import { sha256Object } from "../../Util/sha256.ts"; + +/** Source of an {@link ArtifactFile}'s bytes. */ +export type ArtifactFileSource = + | { readonly _tag: "Bytes"; readonly bytes: Uint8Array } + | { readonly _tag: "File"; readonly absPath: string }; + +export interface ArtifactFile { + /** + * POSIX path as sent to `createDeployment` — INCLUDES the + * `.vercel/output/` prefix for prebuilt deploys. + */ + readonly path: string; + /** SHA1 hex of the file contents — Vercel's content address. */ + readonly sha1: string; + /** File size in bytes. */ + readonly size: number; + /** Optional file mode (symlink / executable bits). */ + readonly mode?: number; + readonly source: ArtifactFileSource; +} + +/** + * Minimal `projectSettings` patch attached to a first deploy. Prebuilt + * deploys need none of the framework autodetection settings. + */ +export interface ProjectSettingsPatch { + readonly framework?: string | null; +} + +export interface DeploymentArtifact { + readonly kind: "prebuilt" | "sources"; + readonly files: ReadonlyArray; + /** + * sha256 over the sorted `(path, sha1, mode)` tuples — THE diff key for + * skip-on-hash and crash-recovery deployment lookup. + */ + readonly hash: string; + readonly projectSettings?: ProjectSettingsPatch; +} + +/** SHA1 hex digest (Vercel's upload content address). */ +export const sha1Hex = (bytes: Uint8Array): Effect.Effect => + Effect.sync(() => createHash("sha1").update(bytes).digest("hex")); + +/** Build an in-memory {@link ArtifactFile} from raw bytes. */ +export const artifactFileFromBytes = ( + path: string, + bytes: Uint8Array, + mode?: number, +): Effect.Effect => + Effect.map(sha1Hex(bytes), (sha1) => ({ + path, + sha1, + size: bytes.byteLength, + ...(mode !== undefined ? { mode } : {}), + source: { _tag: "Bytes", bytes }, + })); + +/** Build a disk-backed {@link ArtifactFile} (bytes read lazily at upload). */ +export const artifactFileFromDisk = ( + path: string, + absPath: string, +): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const bytes = yield* fs.readFile(absPath); + const sha1 = yield* sha1Hex(bytes); + return { + path, + sha1, + size: bytes.byteLength, + source: { _tag: "File", absPath }, + } satisfies ArtifactFile; + }); + +/** Read an {@link ArtifactFile}'s bytes (upload path). */ +export const readArtifactFile = ( + file: ArtifactFile, +): Effect.Effect => + file.source._tag === "Bytes" + ? Effect.succeed(file.source.bytes) + : Effect.flatMap(FileSystem.FileSystem, (fs) => + fs.readFile(file.source._tag === "File" ? file.source.absPath : ""), + ); + +/** + * Deterministic artifact hash: sha256 over the sorted `(path, sha1, mode)` + * tuples. Content-only — independent of source kind (bytes vs disk). + */ +export const artifactHash = ( + files: ReadonlyArray, +): Effect.Effect => + sha256Object( + [...files] + .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) + .map((f) => [f.path, f.sha1, f.mode ?? null]), + ); + +/** Assemble a {@link DeploymentArtifact} from its files. */ +export const makeArtifact = ( + kind: DeploymentArtifact["kind"], + files: ReadonlyArray, + projectSettings?: ProjectSettingsPatch, +): Effect.Effect => + Effect.map(artifactHash(files), (hash) => ({ + kind, + files, + hash, + ...(projectSettings !== undefined ? { projectSettings } : {}), + })); diff --git a/packages/alchemy/src/Vercel/Deploy/BuildOutput.ts b/packages/alchemy/src/Vercel/Deploy/BuildOutput.ts new file mode 100644 index 0000000000..77502054ac --- /dev/null +++ b/packages/alchemy/src/Vercel/Deploy/BuildOutput.ts @@ -0,0 +1,267 @@ +/** + * Build Output v3 artifact constructors (DESIGN §6.1 / §7.2). + * + * Every compute shape is a different way of producing a `.vercel/output` + * tree; these constructors turn each producer's output into a + * {@link DeploymentArtifact} for the shared engine. + * + * NOT exported from `Vercel/index.ts`. + */ +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import type { PlatformError } from "effect/PlatformError"; +import { + artifactFileFromBytes, + artifactFileFromDisk, + makeArtifact, + type ArtifactFile, + type DeploymentArtifact, +} from "./Artifact.ts"; + +const OUTPUT_PREFIX = ".vercel/output"; + +/** A route entry in Build Output v3 `config.json`. */ +export interface BuildOutputRoute { + readonly [key: string]: unknown; +} + +export interface CronEntry { + readonly path: string; + readonly schedule: string; +} + +/** + * A queue trigger contributed through the Function binding channel by the + * `subscribe` event source (DESIGN D9a). + */ +export interface QueueTriggerEntry { + /** Topic name the trigger consumes. */ + readonly topic: string; + /** Consumer group the platform consumes under (REQUIRED — live-verified). */ + readonly consumer: string; + /** Redelivery backoff after a failed delivery, in seconds. */ + readonly retryAfterSeconds?: number; + /** Delay before the first delivery attempt, in seconds. */ + readonly initialDelaySeconds?: number; +} + +/** The `experimentalTriggers` entry shape in `.vc-config.json`. */ +export interface QueueTriggerConfig extends QueueTriggerEntry { + readonly type: "queue/v2beta"; +} + +/** `.vc-config.json` for a Node serverless (Fluid) function. */ +export interface VcConfig { + readonly runtime: string; + readonly handler: string; + readonly launcherType: "Nodejs"; + readonly supportsResponseStreaming?: boolean; + readonly maxDuration?: number; + readonly regions?: ReadonlyArray; + readonly environment?: Record; + /** + * Queue triggers (`queue/v2beta`). Only ever set on the SEPARATE consumer + * function — a trigger on the public function kills ALL public HTTP + * routing (live-verified, D9a). + */ + readonly experimentalTriggers?: ReadonlyArray; +} + +/** + * The dedicated queue-consumer function name (D9a): the platform invokes it + * directly for queue deliveries; it is never routed in `config.json`, so it + * stays publicly unreachable while the `index` function keeps serving HTTP. + */ +export const QUEUE_CONSUMER_FUNCTION = "_alchemy-queue"; + +const encoder = new TextEncoder(); + +const jsonBytes = (value: unknown): Uint8Array => + encoder.encode(JSON.stringify(value)); + +/** + * Recursively list every file under `root` as sorted POSIX-relative paths. + */ +const walkFiles = ( + root: string, +): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const out: string[] = []; + const go: (rel: string) => Effect.Effect = Effect.fn( + function* (rel: string) { + const absolute = rel === "" ? root : `${root}/${rel}`; + const entries = yield* fs.readDirectory(absolute); + for (const entry of entries) { + const childRel = rel === "" ? entry : `${rel}/${entry}`; + const info = yield* fs.stat(`${root}/${childRel}`); + if (info.type === "Directory") { + yield* go(childRel); + } else { + out.push(childRel); + } + } + }, + ); + yield* go(""); + return out.sort(); + }); + +/** + * Build the artifact for a hand-written Function: a single `index` function + * plus optional static assets (DESIGN §7.2 catch-all shape). + * + * Contributed `routes` (binding channel) are merged BEFORE the filesystem + * handler; the catch-all `/index` rewrite comes last. + */ +export const fromFunctionBundle = (input: { + /** Bundled module files, paths relative to `index.func/` (entry `index.mjs`). */ + readonly bundle: ReadonlyArray<{ path: string; bytes: Uint8Array }>; + readonly vcConfig: VcConfig; + readonly crons?: ReadonlyArray; + readonly routes?: ReadonlyArray; + /** Optional directory of static assets shipped under `static/`. */ + readonly staticDir?: string; + /** + * Optional queue-consumer function (D9a): a SEPARATE + * `functions/_alchemy-queue.func` carrying the `experimentalTriggers` in + * ITS `.vc-config.json`, so the public `index` function keeps its HTTP + * routing. Deliberately absent from `config.json` routes — the platform + * invokes it directly for queue deliveries. + */ + readonly queueConsumer?: { + /** Bundled module files, paths relative to `_alchemy-queue.func/`. */ + readonly bundle: ReadonlyArray<{ path: string; bytes: Uint8Array }>; + readonly vcConfig: VcConfig; + }; +}): Effect.Effect => + Effect.gen(function* () { + const files: ArtifactFile[] = []; + + const config = { + version: 3, + routes: [ + ...(input.routes ?? []), + { handle: "filesystem" }, + { src: "/.*", dest: "/index" }, + ], + ...(input.crons !== undefined && input.crons.length > 0 + ? { crons: input.crons } + : {}), + }; + files.push( + yield* artifactFileFromBytes( + `${OUTPUT_PREFIX}/config.json`, + jsonBytes(config), + ), + ); + files.push( + yield* artifactFileFromBytes( + `${OUTPUT_PREFIX}/functions/index.func/.vc-config.json`, + jsonBytes(input.vcConfig), + ), + ); + for (const file of input.bundle) { + files.push( + yield* artifactFileFromBytes( + `${OUTPUT_PREFIX}/functions/index.func/${file.path}`, + file.bytes, + ), + ); + } + if (input.queueConsumer !== undefined) { + files.push( + yield* artifactFileFromBytes( + `${OUTPUT_PREFIX}/functions/${QUEUE_CONSUMER_FUNCTION}.func/.vc-config.json`, + jsonBytes(input.queueConsumer.vcConfig), + ), + ); + for (const file of input.queueConsumer.bundle) { + files.push( + yield* artifactFileFromBytes( + `${OUTPUT_PREFIX}/functions/${QUEUE_CONSUMER_FUNCTION}.func/${file.path}`, + file.bytes, + ), + ); + } + } + if (input.staticDir !== undefined) { + const rels = yield* walkFiles(input.staticDir); + for (const rel of rels) { + files.push( + yield* artifactFileFromDisk( + `${OUTPUT_PREFIX}/static/${rel}`, + `${input.staticDir}/${rel}`, + ), + ); + } + } + return yield* makeArtifact("prebuilt", files); + }); + +/** + * Pass an adapter-produced `.vercel/output` directory through as-is + * (the `prebuilt:` escape hatch and, later, `Website.*` adapters). + */ +export const fromBuildOutputDir = ( + dir: string, +): Effect.Effect => + Effect.gen(function* () { + const rels = yield* walkFiles(dir); + const files: ArtifactFile[] = []; + for (const rel of rels) { + files.push( + yield* artifactFileFromDisk(`${OUTPUT_PREFIX}/${rel}`, `${dir}/${rel}`), + ); + } + return yield* makeArtifact("prebuilt", files); + }); + +/** + * Static-only artifact: every file under `dir` ships under `static/` with a + * plain filesystem-routing `config.json`. + */ +export const fromStaticDir = ( + dir: string, + routes?: ReadonlyArray, +): Effect.Effect => + Effect.gen(function* () { + const rels = yield* walkFiles(dir); + const files: ArtifactFile[] = [ + yield* artifactFileFromBytes( + `${OUTPUT_PREFIX}/config.json`, + jsonBytes({ + version: 3, + routes: [...(routes ?? []), { handle: "filesystem" }], + }), + ), + ]; + for (const rel of rels) { + files.push( + yield* artifactFileFromDisk( + `${OUTPUT_PREFIX}/static/${rel}`, + `${dir}/${rel}`, + ), + ); + } + return yield* makeArtifact("prebuilt", files); + }); + +/** + * Remote-build (source upload) mode is not implemented in v1 — it is the + * opt-in Next.js fallback (DESIGN §7.3), a later wave. + */ +export class SourceDeployNotSupported extends Data.TaggedError( + "Vercel.SourceDeployNotSupported", +)<{ + readonly message: string; +}> {} + +export const fromSourceDir = (_dir: string): Effect.Effect => + Effect.die( + new SourceDeployNotSupported({ + message: + "Remote (source-upload) builds are not supported yet — provide a prebuilt .vercel/output tree or a bundleable entry module.", + }), + ); diff --git a/packages/alchemy/src/Vercel/Deploy/Engine.ts b/packages/alchemy/src/Vercel/Deploy/Engine.ts new file mode 100644 index 0000000000..e4fe97e5b6 --- /dev/null +++ b/packages/alchemy/src/Vercel/Deploy/Engine.ts @@ -0,0 +1,1149 @@ +/** + * The Vercel deploy engine (DESIGN §6) — a library, not a service. + * + * Plain Effect functions shared by the `Project` and `Function` providers: + * observe/ensure the project, sync settings and env by observed-vs-desired + * delta, and drive `DeploymentArtifact → upload → createDeployment → READY`. + * + * NOT exported from `Vercel/index.ts`. + */ +import * as aliases from "@distilled.cloud/vercel/aliases"; +import * as deployments from "@distilled.cloud/vercel/deployments"; +import * as projects from "@distilled.cloud/vercel/projects"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import * as Schedule from "effect/Schedule"; +import { Stack } from "../../Stack.ts"; +import { Stage } from "../../Stage.ts"; +import { sha256, sha256Object } from "../../Util/sha256.ts"; +import { VercelEnvironment } from "../VercelEnvironment.ts"; +import { readArtifactFile, type DeploymentArtifact } from "./Artifact.ts"; + +// ───────────────────────────────────────────────────────────────────────────── +// Ownership (DESIGN §5.4) — Vercel has no tags; ownership is an env-var stamp +// on the project plus `meta.alchemy*` stamps on every deployment. +// ───────────────────────────────────────────────────────────────────────────── + +export const ALCHEMY_META_KEY = "ALCHEMY_META"; + +export interface OwnershipStamp { + readonly stack: string; + readonly stage: string; + readonly logicalId: string; +} + +export const makeOwnershipStamp = ( + logicalId: string, +): Effect.Effect => + Effect.gen(function* () { + const stack = yield* Stack; + const stage = yield* Stage; + return { stack: stack.name, stage, logicalId }; + }); + +/** Deployment `meta` stamps (DESIGN §5.4 / §6.6). */ +export const makeDeploymentMeta = ( + stamp: OwnershipStamp, + contentHash: string, +): Record => ({ + alchemyContentHash: contentHash, + alchemyStack: stamp.stack, + alchemyStage: stamp.stage, + alchemyLogicalId: stamp.logicalId, +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Typed engine errors +// ───────────────────────────────────────────────────────────────────────────── + +export class DeploymentFailed extends Data.TaggedError( + "Vercel.DeploymentFailed", +)<{ + readonly message: string; + readonly deploymentId: string; + readonly readyState: string; + readonly errorCode?: string; + /** Tail of the build log events. */ + readonly logs: ReadonlyArray; +}> {} + +export class DeploymentTimeout extends Data.TaggedError( + "Vercel.DeploymentTimeout", +)<{ + readonly message: string; + readonly deploymentId: string; + readonly readyState: string; +}> {} + +export class AliasAssignmentFailed extends Data.TaggedError( + "Vercel.AliasAssignmentFailed", +)<{ + readonly message: string; + readonly deploymentId: string; + readonly code?: string; +}> {} + +// ───────────────────────────────────────────────────────────────────────────── +// Project observe / ensure / settings sync +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Observe a project by id or name. A missing project resolves `undefined` + * (typed `NotFound`, distilled patch 004). + */ +export const observeProject = (idOrName: string) => + Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + return yield* projects + .getProject({ idOrName, teamId }) + .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))); + }); + +/** + * Desired project settings — the mutable scalar surface v1 manages. + */ +export interface ProjectSettingsDesired { + readonly nodeVersion?: string; + readonly resourceConfig?: { + readonly fluid?: boolean; + readonly functionDefaultMemoryType?: string; + readonly functionDefaultTimeout?: number; + readonly functionDefaultRegions?: ReadonlyArray; + }; +} + +/** + * Ensure the project exists (observe → create on missing, catching the + * typed `Conflict` race → re-get) and return the observed project body. + */ +export const ensureProject = (input: { + readonly name: string; + readonly desired?: ProjectSettingsDesired; +}) => + Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + const observed = yield* observeProject(input.name); + if (observed !== undefined) { + return observed; + } + yield* projects + .createProject({ + teamId, + name: input.name, + ...(input.desired?.resourceConfig !== undefined + ? { + resourceConfig: { + fluid: input.desired.resourceConfig.fluid, + functionDefaultMemoryType: + input.desired.resourceConfig.functionDefaultMemoryType, + functionDefaultTimeout: + input.desired.resourceConfig.functionDefaultTimeout, + functionDefaultRegions: + input.desired.resourceConfig.functionDefaultRegions !== + undefined + ? [...input.desired.resourceConfig.functionDefaultRegions] + : undefined, + }, + } + : {}), + }) + // Two reconcilers racing on the same deterministic name: the loser's + // Conflict is a success signal — the project exists. + .pipe( + Effect.catchTag("Conflict", () => Effect.void), + Effect.asVoid, + ); + const project = yield* observeProject(input.name); + if (project === undefined) { + // Created (or conflicted with an existing create) but not yet + // observable — treat as an engine defect rather than inventing state. + return yield* Effect.die( + `Vercel project '${input.name}' not observable immediately after create`, + ); + } + return project; + }); + +/** + * Sync mutable project settings by observed-vs-desired delta — PATCH only + * when something actually differs. + */ +export const syncProjectSettings = (input: { + readonly project: projects.GetProjectResponse; + readonly desired: ProjectSettingsDesired; +}) => + Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + const { project, desired } = input; + + interface ResourceConfigPatch { + fluid?: boolean; + functionDefaultMemoryType?: string; + functionDefaultTimeout?: number; + functionDefaultRegions?: string[]; + } + const patch: { + nodeVersion?: string; + resourceConfig?: ResourceConfigPatch; + } = {}; + + if ( + desired.nodeVersion !== undefined && + desired.nodeVersion !== project.nodeVersion + ) { + patch.nodeVersion = desired.nodeVersion; + } + + if (desired.resourceConfig !== undefined) { + const observed = project.resourceConfig as { + fluid?: boolean; + functionDefaultMemoryType?: string; + functionDefaultTimeout?: number; + functionDefaultRegions?: ReadonlyArray; + }; + const rc: ResourceConfigPatch = {}; + if ( + desired.resourceConfig.fluid !== undefined && + desired.resourceConfig.fluid !== observed?.fluid + ) { + rc.fluid = desired.resourceConfig.fluid; + } + if ( + desired.resourceConfig.functionDefaultMemoryType !== undefined && + desired.resourceConfig.functionDefaultMemoryType !== + observed?.functionDefaultMemoryType + ) { + rc.functionDefaultMemoryType = + desired.resourceConfig.functionDefaultMemoryType; + } + if ( + desired.resourceConfig.functionDefaultTimeout !== undefined && + desired.resourceConfig.functionDefaultTimeout !== + observed?.functionDefaultTimeout + ) { + rc.functionDefaultTimeout = + desired.resourceConfig.functionDefaultTimeout; + } + if ( + desired.resourceConfig.functionDefaultRegions !== undefined && + JSON.stringify(desired.resourceConfig.functionDefaultRegions) !== + JSON.stringify(observed?.functionDefaultRegions) + ) { + rc.functionDefaultRegions = [ + ...desired.resourceConfig.functionDefaultRegions, + ]; + } + if (Object.keys(rc).length > 0) { + patch.resourceConfig = rc; + } + } + + if (Object.keys(patch).length === 0) { + return { changed: false as const }; + } + yield* projects.updateProject({ + idOrName: project.id, + teamId, + ...(patch.nodeVersion !== undefined + ? { nodeVersion: patch.nodeVersion } + : {}), + ...(patch.resourceConfig !== undefined + ? { resourceConfig: patch.resourceConfig } + : {}), + }); + return { changed: true as const }; + }); + +// ───────────────────────────────────────────────────────────────────────────── +// Env sync +// ───────────────────────────────────────────────────────────────────────────── + +export type EnvTarget = "production" | "preview" | "development"; + +export const ALL_ENV_TARGETS: ReadonlyArray = [ + "production", + "preview", + "development", +]; + +/** + * Sensitive env vars cannot target `development` — Vercel rejects the row + * with `BAD_REQUEST "You cannot set a Sensitive Environment Variable's + * target to development"` (live-verified) — so their default excludes it. + */ +export const SENSITIVE_ENV_TARGETS: ReadonlyArray = [ + "production", + "preview", +]; + +export interface DesiredEnv { + readonly key: string; + /** Raw value — `Redacted` values become `sensitive` project env vars. */ + readonly value: string | Redacted.Redacted; + readonly type?: "plain" | "encrypted" | "sensitive"; + readonly target?: ReadonlyArray; +} + +/** Observed env row (normalized across the response union's cases). */ +interface EnvRow { + readonly id?: string; + readonly key: string; + readonly value?: string; + readonly type: string; + readonly comment?: string; + readonly target?: unknown; + /** + * `false` when `value` is an opaque ciphertext envelope even with + * decrypt=true (live-verified: teams on v2 env encryption never return + * plaintext for `encrypted` rows) — the value is unobservable then. + */ + readonly decrypted?: boolean; +} + +const targetsOf = (t: unknown): string[] => + Array.isArray(t) + ? [...t].map(String).sort() + : typeof t === "string" + ? [t] + : []; + +const toEnvRows = (body: projects.FilterProjectEnvsResponse): EnvRow[] => + typeof body === "object" && body !== null && "envs" in body + ? ([...body.envs] as EnvRow[]) + : typeof body === "object" && body !== null && "key" in body + ? [body as EnvRow] + : []; + +/** List a project's env vars, decrypting readable values for diffing. */ +export const listProjectEnvs = (idOrName: string) => + Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + const body = yield* projects.filterProjectEnvs({ + idOrName, + teamId, + decrypt: "true", + }); + return toEnvRows(body); + }); + +/** `alchemy:sha256:` fingerprint comment for sensitive env values. */ +export const sensitiveFingerprint = (value: string) => + Effect.map(sha256(value), (hash) => `alchemy:sha256:${hash}`); + +/** + * Persisted baseline row for an alchemy-managed project env var. + * + * The baseline is the removal gate for every managed key, and the drift + * baseline whenever the cloud value is unobservable (live-verified): + * sensitive rows list with an EMPTY `value`, and on teams with v2 env + * encryption even `encrypted` rows come back as ciphertext with + * `decrypted: false` despite decrypt=true. + */ +export interface ManagedEnvEntry { + readonly key: string; + readonly type: string; + /** + * `alchemy:sha256:` of the resolved value at the last successful + * write. Absent after a failed write so the next reconcile retries. + */ + readonly fingerprint?: string; + /** + * Sensitive rows only: env row id captured from the `createProjectEnv` + * response — a removal fallback if the row ever drops out of the listing. + */ + readonly id?: string; + /** Sensitive rows only: sorted targets at the last write. */ + readonly target?: ReadonlyArray; +} + +const desiredRow = (env: DesiredEnv) => + Effect.gen(function* () { + const isSensitive = + env.type === "sensitive" || Redacted.isRedacted(env.value); + const raw = Redacted.isRedacted(env.value) + ? Redacted.value(env.value) + : env.value; + // Every row gets a value fingerprint — the drift baseline whenever the + // cloud value is unobservable (sensitive rows always; encrypted rows on + // teams whose listing returns `decrypted: false` ciphertext). + const fingerprint = yield* sensitiveFingerprint(raw); + const defaultTargets = isSensitive + ? SENSITIVE_ENV_TARGETS + : ALL_ENV_TARGETS; + // Sensitive rows may never target development, even when asked to. + const targets = (env.target ?? defaultTargets).filter( + (t) => !isSensitive || t !== "development", + ); + return { + key: env.key, + value: raw, + type: isSensitive ? ("sensitive" as const) : (env.type ?? "encrypted"), + target: [...targets], + fingerprint, + // The fingerprint ships as the row's comment for humans in the + // dashboard (sensitive rows only) — nothing depends on reading it back. + ...(isSensitive ? { comment: fingerprint } : {}), + }; + }); + +/** + * Converge project env vars on the desired set (DESIGN §6.3 step 4). + * + * - Plain/encrypted rows diff against the OBSERVED cloud rows from + * `filterProjectEnvs` (adoption-safe) when the value is readable. + * - Unobservable values diff the desired `alchemy:sha256:` + * fingerprint against the PERSISTED baseline and are upserted only on + * mismatch/absence. This covers sensitive rows (always write-only — the + * listing returns them with an EMPTY `value`, live-verified) and + * encrypted rows whose observed row has `decrypted: false` (v2 env + * encryption ciphertext). The fingerprint also ships as a sensitive + * row's `comment` for humans in the dashboard, but nothing DEPENDS on + * reading it back. + * - `managedEnv` is the removal baseline: only keys alchemy previously + * managed are ever removed, so foreign env vars survive adoption. + * A sensitive row that drops out of the listing is removed via the + * persisted row id (captured from the `createProjectEnv` response). + * + * Returns whether anything changed (env changes force a redeploy) and the + * new managed-env baseline for the caller to persist. + */ +export const syncProjectEnv = (input: { + readonly idOrName: string; + readonly desired: ReadonlyArray; + readonly managedEnv: ReadonlyArray; +}) => + Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + const observed = yield* listProjectEnvs(input.idOrName); + const observedByKey = new Map(observed.map((row) => [row.key, row])); + const baselineByKey = new Map(input.managedEnv.map((e) => [e.key, e])); + + const rows = yield* Effect.forEach(input.desired, desiredRow); + + const upserts = rows.filter((row) => { + if (row.type === "sensitive") { + // Value unobservable (listed empty) — diff the persisted baseline. + const baseline = baselineByKey.get(row.key); + if (baseline === undefined) return true; + if (baseline.type !== "sensitive") return true; + if (baseline.fingerprint !== row.fingerprint) return true; + const baselineTargets = [...(baseline.target ?? [])].sort(); + return baselineTargets.join(",") !== [...row.target].sort().join(","); + } + const current = observedByKey.get(row.key); + if (current === undefined) return true; + if (current.type !== row.type) return true; + const observedTargets = targetsOf(current.target); + const desiredTargets = [...row.target].sort(); + if (observedTargets.join(",") !== desiredTargets.join(",")) return true; + if (current.decrypted === false) { + // Undecryptable ciphertext (v2 env encryption) — the observed value + // can't be compared; diff the persisted fingerprint instead. + return baselineByKey.get(row.key)?.fingerprint !== row.fingerprint; + } + return current.value !== row.value; + }); + + // Capture created row ids (sensitive rows persist theirs as the future + // removal handle) and per-key failures (a failed sensitive write must + // not bank its fingerprint). + const createdIdByKey = new Map(); + const failedKeys = new Set(); + if (upserts.length > 0) { + const response = yield* projects.createProjectEnv({ + idOrName: input.idOrName, + teamId, + upsert: "true", + // `fingerprint` is engine metadata, not part of the wire row. + body: upserts.map(({ fingerprint: _fingerprint, ...wire }) => wire), + }); + const createdRows = Array.isArray(response.created) + ? response.created + : [response.created]; + for (const created of createdRows) { + if (created.id !== undefined) { + createdIdByKey.set(created.key, created.id); + } + } + for (const failure of response.failed) { + const key = failure.error.envVarKey ?? failure.error.key; + if (key !== undefined) failedKeys.add(key); + yield* Effect.logWarning( + `Vercel env upsert failed for '${key ?? ""}' on ${input.idOrName}: ${failure.error.code} ${failure.error.message}`, + ); + } + } + + const desiredKeys = new Set(rows.map((row) => row.key)); + const managedKeys = new Set(input.managedEnv.map((e) => e.key)); + // Plain/encrypted removals: resolve the row id from the listing. + const observedRemovals = observed.filter( + (row) => + row.id !== undefined && + managedKeys.has(row.key) && + !desiredKeys.has(row.key), + ); + let removed = 0; + for (const row of observedRemovals) { + yield* projects + .removeProjectEnv({ + idOrName: input.idOrName, + id: row.id!, + teamId, + }) + .pipe(Effect.catchTag("NotFound", () => Effect.void)); + removed++; + } + // Sensitive removals whose row dropped out of the listing: fall back + // to the persisted id; skip with a note when none was captured. + const sensitiveRemovals = input.managedEnv.filter( + (entry) => + entry.type === "sensitive" && + !desiredKeys.has(entry.key) && + !observedByKey.has(entry.key), + ); + for (const entry of sensitiveRemovals) { + if (entry.id === undefined) { + yield* Effect.logWarning( + `Vercel sensitive env '${entry.key}' on ${input.idOrName} has no persisted row id — skipping removal (delete it in the dashboard if it still exists)`, + ); + continue; + } + yield* projects + .removeProjectEnv({ + idOrName: input.idOrName, + id: entry.id, + teamId, + }) + .pipe(Effect.catchTag("NotFound", () => Effect.void)); + removed++; + } + + const managedEnv = rows.map((row): ManagedEnvEntry => { + const previous = baselineByKey.get(row.key); + // A failed write keeps the previous fingerprint (last successful + // write) so the next reconcile retries the upsert. + const fingerprint = failedKeys.has(row.key) + ? previous?.fingerprint + : row.fingerprint; + if (row.type !== "sensitive") { + return { + key: row.key, + type: row.type, + ...(fingerprint !== undefined ? { fingerprint } : {}), + }; + } + const id = + createdIdByKey.get(row.key) ?? + (previous?.type === "sensitive" ? previous.id : undefined); + return { + key: row.key, + type: "sensitive", + ...(fingerprint !== undefined ? { fingerprint } : {}), + ...(id !== undefined ? { id } : {}), + target: [...row.target].sort(), + }; + }); + + return { + changed: upserts.length > 0 || removed > 0, + managedEnv, + }; + }); + +/** Stable hash over the fully-resolved desired env rows. */ +export const hashDesiredEnv = (desired: ReadonlyArray) => + Effect.flatMap(Effect.forEach(desired, desiredRow), (rows) => + sha256Object( + [...rows].sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)), + ), + ); + +// ───────────────────────────────────────────────────────────────────────────── +// Protection bypass (DESIGN §6.7) +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Find an existing automation-bypass secret on an observed project body. + * The secret IS the key of the `protectionBypass` map (live-verified); + * entries with other scopes (integration bypasses) are ignored. + */ +export const findAutomationBypassSecret = ( + project: projects.GetProjectResponse, +): string | undefined => { + const map = project.protectionBypass as + | Record + | undefined; + if (map === undefined) return undefined; + for (const [secret, entry] of Object.entries(map)) { + if (entry?.scope === "automation-bypass") { + return secret; + } + } + return undefined; +}; + +/** + * Ensure the project carries an automation bypass secret, minting one via + * `PATCH /v1/projects/{id}/protection-bypass {generate:{note}}` when absent. + * + * **Observe-and-keep**: an existing secret is NEVER regenerated — bypass + * secrets only open deployments created AFTER they were minted + * (live-verified), so regenerating would orphan every older deployment. + * For the same reason callers mint BEFORE the first deploy, never lazily. + */ +export const ensureProtectionBypass = (project: projects.GetProjectResponse) => + Effect.gen(function* () { + const existing = findAutomationBypassSecret(project); + if (existing !== undefined) { + return Redacted.make(existing); + } + const { teamId } = yield* VercelEnvironment.current; + const response = yield* projects.updateProjectProtectionBypass({ + idOrName: project.id, + teamId, + generate: { note: "alchemy automation bypass" }, + }); + const minted = Object.entries(response.protectionBypass ?? {}).find( + ([, entry]) => + (entry as { scope?: string } | undefined)?.scope === + "automation-bypass", + )?.[0]; + if (minted === undefined) { + return yield* Effect.die( + `Vercel project ${project.id}: protection-bypass generate returned no automation-bypass secret`, + ); + } + return Redacted.make(minted); + }); + +/** + * Read the project's ownership stamp from its env vars. `undefined` when no + * stamp is present (foreign project → `Unowned` gating in `read`). + */ +export const readOwnershipStamp = (idOrName: string) => + Effect.gen(function* () { + const rows = yield* listProjectEnvs(idOrName); + const stamp = rows.find((row) => row.key === ALCHEMY_META_KEY); + if (stamp?.value === undefined) return undefined; + try { + return JSON.parse(stamp.value) as OwnershipStamp; + } catch { + return undefined; + } + }); + +// ───────────────────────────────────────────────────────────────────────────── +// Deployments +// ───────────────────────────────────────────────────────────────────────────── + +type DeploymentSnapshot = + | deployments.CreateDeploymentResponse + | deployments.GetDeploymentResponse; + +const readyStateOf = (d: DeploymentSnapshot): string => d.readyState; + +const idOf = (d: DeploymentSnapshot): string => d.id; + +const urlOf = (d: DeploymentSnapshot): string | undefined => + "url" in d ? d.url : undefined; + +const aliasesOf = (d: DeploymentSnapshot): ReadonlyArray => + "alias" in d && Array.isArray(d.alias) ? d.alias : []; + +const aliasErrorOf = ( + d: DeploymentSnapshot, +): { code?: string; message?: string } | undefined => + "aliasError" in d && d.aliasError !== null && d.aliasError !== undefined + ? (d.aliasError as { code?: string; message?: string }) + : undefined; + +const isTerminal = (readyState: string): boolean => + readyState === "READY" || readyState === "ERROR" || readyState === "CANCELED"; + +/** Fetch the tail of a deployment's build log as plain text lines. */ +const buildLogTail = (deploymentId: string) => + Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + const events = yield* deployments.getDeploymentEvents({ + idOrUrl: deploymentId, + teamId, + builds: 1, + limit: 100, + }); + return events.flatMap((event) => { + if (typeof event !== "object" || event === null) return []; + if ("payload" in event) { + const text = (event.payload as { text?: string }).text; + return typeof text === "string" && text.length > 0 ? [text] : []; + } + if ("text" in event && typeof event.text === "string") { + return [event.text]; + } + return []; + }); + }).pipe( + // Best-effort: a failed log fetch must not mask the DeploymentFailed. + Effect.orElseSucceed(() => [] as string[]), + ); + +export interface DeployResult { + readonly deploymentId: string; + /** Deployment-specific hostname (e.g. `proj-abc123.vercel.app`). */ + readonly deploymentUrl: string | undefined; + /** Aliases assigned at creation (production domain first when present). */ + readonly aliases: ReadonlyArray; + readonly aliasAssigned: boolean; +} + +/** + * Upload-all-first + `POST /v13/deployments` + poll-to-READY + * (DESIGN §6.2, upload facts per PROBES.md). + */ +export const deployArtifact = (input: { + readonly projectName: string; + readonly artifact: DeploymentArtifact; + readonly target: "production" | "preview"; + readonly meta: Record; + readonly session?: { note: (note: string) => Effect.Effect }; +}) => + Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + + // 1. Upload every file (SHA-dedup server-side makes re-sends near-free; + // response is `{urls:[...]}` — never asserted empty). Bounded retry + // on transport blips only; API-level retry is distilled's policy. + yield* Effect.forEach( + input.artifact.files, + (file) => + Effect.gen(function* () { + const bytes = yield* readArtifactFile(file); + yield* deployments + .uploadFile({ + teamId, + xVercelDigest: file.sha1, + contentLength: file.size, + body: bytes, + }) + .pipe( + Effect.retry({ + while: (e) => e._tag === "HttpClientError", + schedule: Schedule.exponential("250 millis"), + times: 4, + }), + ); + }), + { concurrency: 8 }, + ); + + // 2. Create the deployment referencing the uploaded blobs. + const created = yield* deployments.createDeployment({ + teamId, + forceNew: "1", + skipAutoDetectionConfirmation: "1", + name: input.projectName, + project: input.projectName, + // `target` accepts only `staging` / `production` / a custom-env id; + // preview deployments are expressed by OMITTING it. + ...(input.target === "production" ? { target: "production" } : {}), + meta: input.meta, + files: input.artifact.files.map((file) => ({ + file: file.path, + sha: file.sha1, + size: file.size, + })), + ...(input.artifact.projectSettings !== undefined + ? { projectSettings: input.artifact.projectSettings } + : {}), + }); + const deploymentId = idOf(created); + + // 3. Poll until terminal — Schedule.spaced 2s, ≤45 polls (~90s budget, + // speed doctrine; prebuilt deploys are typically ready in seconds). + let dep: DeploymentSnapshot = created; + if (!isTerminal(readyStateOf(dep))) { + dep = yield* deployments + .getDeployment({ idOrUrl: deploymentId, teamId }) + .pipe( + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (d) => isTerminal(readyStateOf(d)), + times: 45, + }), + ); + } + + const readyState = readyStateOf(dep); + if (readyState === "ERROR" || readyState === "CANCELED") { + const logs = yield* buildLogTail(deploymentId); + const errorCode = + "errorCode" in dep && typeof dep.errorCode === "string" + ? dep.errorCode + : undefined; + return yield* new DeploymentFailed({ + message: `Vercel deployment ${deploymentId} ended ${readyState}${ + errorCode !== undefined ? ` (${errorCode})` : "" + }${logs.length > 0 ? `:\n${logs.slice(-25).join("\n")}` : ""}`, + deploymentId, + readyState, + ...(errorCode !== undefined ? { errorCode } : {}), + logs, + }); + } + if (readyState !== "READY") { + return yield* new DeploymentTimeout({ + message: `Vercel deployment ${deploymentId} still ${readyState} after poll budget`, + deploymentId, + readyState, + }); + } + + // 4. Production deploys must land their aliases — bounded re-poll, then + // a typed failure carrying Vercel's aliasError. + let aliasAssigned = Boolean( + "aliasAssigned" in dep ? dep.aliasAssigned : false, + ); + if (input.target === "production" && !aliasAssigned) { + dep = yield* deployments + .getDeployment({ idOrUrl: deploymentId, teamId }) + .pipe( + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (d) => + Boolean("aliasAssigned" in d ? d.aliasAssigned : false) || + aliasErrorOf(d) !== undefined, + times: 10, + }), + ); + aliasAssigned = Boolean( + "aliasAssigned" in dep ? dep.aliasAssigned : false, + ); + const aliasError = aliasErrorOf(dep); + if (aliasError !== undefined) { + return yield* new AliasAssignmentFailed({ + message: `Vercel deployment ${deploymentId} alias assignment failed: ${aliasError.message ?? aliasError.code ?? "unknown"}`, + deploymentId, + ...(aliasError.code !== undefined ? { code: aliasError.code } : {}), + }); + } + if (!aliasAssigned) { + return yield* new AliasAssignmentFailed({ + message: `Vercel deployment ${deploymentId} aliases not assigned after poll budget`, + deploymentId, + }); + } + } + + return { + deploymentId, + deploymentUrl: urlOf(dep), + aliases: aliasesOf(dep), + aliasAssigned, + } satisfies DeployResult; + }); + +/** + * Crash recovery (DESIGN §6.6): find an existing READY deployment for this + * content hash — a reconcile that died after `createDeployment` but before + * state persisted is reused instead of duplicated. + */ +export const findDeploymentByContentHash = (input: { + readonly projectId: string; + readonly contentHash: string; +}) => + Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + const page = yield* deployments.getDeployments({ + teamId, + projectId: input.projectId, + limit: 20, + }); + return page.deployments.find( + (d) => + d.meta?.alchemyContentHash === input.contentHash && + d.readyState === "READY", + ); + }); + +/** + * List this stack/stage/logicalId's own `meta`-stamped deployments in a + * project (tenant-mode delete, leak census). + */ +export const listOwnDeployments = (input: { + readonly projectId: string; + readonly stamp: OwnershipStamp; +}) => + Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + const page = yield* deployments.getDeployments({ + teamId, + projectId: input.projectId, + limit: 100, + }); + return page.deployments.filter( + (d) => + d.meta?.alchemyStack === input.stamp.stack && + d.meta?.alchemyStage === input.stamp.stage && + d.meta?.alchemyLogicalId === input.stamp.logicalId, + ); + }); + +/** Idempotent single-deployment delete. */ +export const deleteDeploymentById = (id: string) => + Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + yield* deployments + .deleteDeployment({ id, teamId }) + .pipe(Effect.catchTag("NotFound", () => Effect.void)); + }); + +/** Idempotent project delete. */ +export const deleteProjectByIdOrName = (idOrName: string) => + Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + yield* projects + .deleteProject({ idOrName, teamId }) + .pipe(Effect.catchTag("NotFound", () => Effect.void)); + }); + +/** + * Read back the project's assigned production domain (DESIGN §6.5 — the + * computed `https://{name}.vercel.app` is never load-bearing). `undefined` + * when no production alias exists yet. + */ +export const readProductionUrl = ( + project: projects.GetProjectResponse, +): string | undefined => { + const aliases = project.alias ?? []; + const production = aliases.filter( + (a) => String(a.target).toLowerCase() === "production", + ); + const candidates = production.length > 0 ? production : aliases; + // The alias rows carry BOTH the public assigned domain + // (`{name}.vercel.app`) and the SSO-gated team-suffixed variant + // (`{name}-{team}.vercel.app`) in nondeterministic order (PROBES.md + // probe 0) — prefer the shortest hostname, which is the public one. + const domain = [...candidates].sort( + (x, y) => (x.domain?.length ?? 0) - (y.domain?.length ?? 0), + )[0]?.domain; + return domain !== undefined ? `https://${domain}` : undefined; +}; + +/** + * Read back the project's assigned production domain from the project + * domains listing — the pre-deploy complement to {@link readProductionUrl} + * (a freshly created project's body carries no `alias` rows until its + * first deployment, but the assigned `{name}.vercel.app` project domain + * exists from creation). Still a READ-BACK, never computed: `.vercel.app` + * is a global namespace, so a taken name yields a suffixed domain. + */ +export const readAssignedProductionUrl = (idOrName: string) => + Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + const body = yield* projects.getProjectDomains({ idOrName, teamId }); + const domains = + typeof body === "object" && body !== null && "domains" in body + ? (body.domains as ReadonlyArray<{ + name: string; + verified: boolean; + redirect?: string | null; + }>) + : []; + const candidates = domains.filter( + (d) => d.verified && (d.redirect === undefined || d.redirect === null), + ); + const assigned = + candidates.find((d) => d.name.endsWith(".vercel.app")) ?? candidates[0]; + return assigned !== undefined ? `https://${assigned.name}` : undefined; + }); + +// ───────────────────────────────────────────────────────────────────────────── +// Stage aliases (DESIGN §5.1) — a preview tenant's stable per-stage hostname +// ───────────────────────────────────────────────────────────────────────────── + +/** Collapse a name into a valid lowercase DNS label (≤63 chars). */ +const aliasLabel = (name: string): string => + name + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 63) + .replace(/-$/, ""); + +/** + * The deterministic per-stage alias hostname a preview tenant claims: + * `{projectName}-{stage}.vercel.app` (DESIGN §5.1). + */ +export const stageAliasName = (projectName: string, stage: string): string => + `${aliasLabel(`${projectName}-${stage}`)}.vercel.app`; + +/** + * Deterministic fallback hostname when the base per-stage alias is taken — + * `.vercel.app` is a global namespace, so `{project}-{stage}` can be owned + * by another team. The suffix hashes the stack name so it is stable across + * reconciles. + */ +export const stageAliasFallbackName = (input: { + readonly projectName: string; + readonly stage: string; + readonly stack: string; +}) => + Effect.map( + sha256(`${input.stack}:${input.stage}`), + (hash) => + `${aliasLabel(`${input.projectName}-${input.stage}-${hash.slice(0, 6)}`)}.vercel.app`, + ); + +export interface StageAliasResult { + /** The unique identifier of the alias record. */ + readonly uid: string; + /** The alias hostname actually claimed (base or suffix fallback). */ + readonly alias: string; +} + +/** Observe an alias record; `undefined` when missing or not ours. */ +const observeAliasRecord = (aliasName: string) => + Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + return yield* aliases.getAlias({ idOrAlias: aliasName, teamId }).pipe( + Effect.catchTag("NotFound", () => Effect.succeed(undefined)), + // A hostname owned by another team is unreadable — treated as absent + // here; the subsequent assign surfaces the taken-domain failure. + Effect.catchTag("Forbidden", () => Effect.succeed(undefined)), + ); + }); + +/** + * Converge a preview tenant's stable per-stage alias onto a deployment + * (DESIGN §5.1): observe first (skip the write when the alias already + * points at the deployment), then `assignAlias` — an upsert (live-verified): + * it creates the alias or atomically re-points an existing one. + * + * `.vercel.app` is a global namespace, so the base `{project}-{stage}` name + * can be taken by another team (`Forbidden`/`Conflict` on assign). That + * falls back — loudly, via a logged warning, never silently — to the + * deterministic {@link stageAliasFallbackName}; if that is taken too, a + * typed {@link AliasAssignmentFailed} surfaces. + * + * `previous` (the alias persisted in state) takes precedence over the + * computed base name so an earlier suffix-fallback stays stable across + * reconciles. + * + * NOTE (live-verified, PROBES.md probe 5): assign-created `.vercel.app` + * aliases are SSO-gated on team accounts even for production deployments — + * drive them with the project's automation bypass secret + * (`x-vercel-protection-bypass`), which only opens deployments created + * AFTER the secret was minted. + */ +export const ensureStageAlias = (input: { + readonly projectName: string; + readonly stage: string; + readonly stack: string; + readonly deploymentId: string; + readonly previous?: StageAliasResult | undefined; +}) => + Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + + const assign = (aliasName: string) => + Effect.gen(function* () { + const observed = yield* observeAliasRecord(aliasName); + if ( + observed !== undefined && + observed.deploymentId === input.deploymentId + ) { + return { + uid: observed.uid, + alias: observed.alias, + } satisfies StageAliasResult; + } + const assigned = yield* aliases.assignAlias({ + id: input.deploymentId, + alias: aliasName, + teamId, + }); + return { + uid: assigned.uid, + alias: assigned.alias, + } satisfies StageAliasResult; + }); + + const base = + input.previous?.alias ?? stageAliasName(input.projectName, input.stage); + return yield* assign(base).pipe( + Effect.catchTag(["Forbidden", "Conflict"], (error) => + Effect.gen(function* () { + const fallback = yield* stageAliasFallbackName(input); + if (fallback === base) { + return yield* new AliasAssignmentFailed({ + message: `Vercel stage alias ${base} could not be assigned to deployment ${input.deploymentId}: ${error._tag}`, + deploymentId: input.deploymentId, + code: error._tag, + }); + } + yield* Effect.logWarning( + `Vercel stage alias ${base} is taken (${error._tag}) — falling back to ${fallback}`, + ); + return yield* assign(fallback).pipe( + Effect.catchTag( + ["Forbidden", "Conflict"], + (fallbackError) => + new AliasAssignmentFailed({ + message: `Vercel stage alias unavailable: ${base} and fallback ${fallback} are both taken (${fallbackError._tag})`, + deploymentId: input.deploymentId, + code: fallbackError._tag, + }), + ), + ); + }), + ), + ); + }); + +/** Idempotent alias delete (by uid or hostname). */ +export const deleteAliasByIdOrName = (aliasId: string) => + Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + yield* aliases + .deleteAlias({ aliasId, teamId }) + .pipe(Effect.catchTag("NotFound", () => Effect.void)); + }); + +/** Normalize the `getProjects` response union into a plain project list. */ +export const listAllProjects = () => + Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + const collected: Array<{ id: string; name: string }> = []; + let until: number | undefined; + // Bounded pagination: `pagination.next` is a millisecond timestamp fed + // back as `until`; hard cap 20 pages. + for (let page = 0; page < 20; page++) { + const body = yield* projects.getProjects({ + teamId, + limit: "100", + ...(until !== undefined ? { until: String(until) } : {}), + }); + const items: ReadonlyArray<{ id: string; name: string }> = Array.isArray( + body, + ) + ? body + : typeof body === "object" && body !== null && "projects" in body + ? body.projects + : []; + collected.push(...items.map((p) => ({ id: p.id, name: p.name }))); + const next = + !Array.isArray(body) && + typeof body === "object" && + body !== null && + "pagination" in body + ? ((body.pagination as { next?: number | null }).next ?? undefined) + : undefined; + if (items.length === 0 || next === undefined || next === until) { + break; + } + until = next; + } + return collected; + }); diff --git a/packages/alchemy/src/Vercel/Domains/Cert.ts b/packages/alchemy/src/Vercel/Domains/Cert.ts new file mode 100644 index 0000000000..d481858050 --- /dev/null +++ b/packages/alchemy/src/Vercel/Domains/Cert.ts @@ -0,0 +1,210 @@ +import * as certs from "@distilled.cloud/vercel/certs"; +import * as Effect from "effect/Effect"; +import { isResolved } from "../../Diff.ts"; +import * as Provider from "../../Provider.ts"; +import { Resource } from "../../Resource.ts"; +import { VercelEnvironment } from "../VercelEnvironment.ts"; +import type { Providers } from "../Providers.ts"; + +export interface CertUpload { + /** The certificate authority chain (PEM). */ + ca: string; + /** The certificate private key (PEM). */ + key: string; + /** The certificate (PEM). */ + cert: string; + /** + * Skip server-side validation of the certificate. + * @default false + */ + skipValidation?: boolean; +} + +export interface CertProps { + /** + * The common names to issue the certificate for. The domains must resolve + * to Vercel at issuance time — issuing for a domain that does not yet + * resolve fails with the typed `DomainPretestFailed` error (HTTP 449). + * Changing the common names replaces the certificate (certs are + * immutable). + * + * Exactly one of `cns` or `upload` must be provided. + */ + cns?: string[]; + /** + * Upload a custom certificate instead of issuing one. Requires an + * Enterprise plan (typed `PaymentRequired` otherwise). Changing the + * upload replaces the certificate. + */ + upload?: CertUpload; +} + +export type Cert = Resource< + "Vercel.Cert", + CertProps, + { + /** The unique id of the certificate. */ + certId: string; + /** The common names the certificate covers. */ + cns: string[]; + /** Timestamp (ms) when the certificate was created. */ + createdAt: number; + /** Timestamp (ms) when the certificate expires. */ + expiresAt: number; + /** Whether Vercel auto-renews the certificate. */ + autoRenew: boolean; + }, + never, + Providers +>; + +type CertAttributes = Cert["Attributes"]; + +/** + * A TLS certificate on the Vercel team — either issued by Vercel for + * domains that resolve to it, or a custom certificate upload (Enterprise). + * + * Vercel issues and renews certificates automatically for project domains, + * so an explicit `Cert` is only needed for advance issuance or custom + * certificates. Certificates are immutable: any change to the requested + * common names or the uploaded material replaces the certificate. + * + * @resource + * @section Issuing a certificate + * @example Issue for domains resolving to Vercel + * ```typescript + * const cert = yield* Vercel.Cert("Cert", { + * cns: ["acme.com", "www.acme.com"], + * }); + * ``` + * + * @section Custom certificates (Enterprise) + * @example Upload a custom certificate + * ```typescript + * const cert = yield* Vercel.Cert("CustomCert", { + * upload: { ca: CA_PEM, key: KEY_PEM, cert: CERT_PEM }, + * }); + * ``` + * + * @see https://vercel.com/docs/domains/custom-SSL-certificate + */ +export const Cert = Resource("Vercel.Cert"); + +const sortedCns = (cns: ReadonlyArray | undefined): string[] => + [...(cns ?? [])].sort(); + +const cnsEqual = ( + a: ReadonlyArray | undefined, + b: ReadonlyArray | undefined, +): boolean => { + const sa = sortedCns(a); + const sb = sortedCns(b); + return sa.length === sb.length && sa.every((v, i) => v === sb[i]); +}; + +const observeCert = (certId: string) => + Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + return yield* certs.getCertById({ id: certId, teamId }).pipe( + Effect.map( + (cert): CertAttributes => ({ + certId: cert.id, + cns: [...cert.cns], + createdAt: cert.createdAt, + expiresAt: cert.expiresAt, + autoRenew: cert.autoRenew, + }), + ), + Effect.catchTag("NotFound", () => Effect.succeed(undefined)), + ); + }); + +export const CertProvider = () => + Provider.succeed(Cert, { + stables: ["certId", "createdAt"], + diff: Effect.fn(function* ({ olds, news, output }) { + if (!isResolved(news)) return undefined; + if (!output) return undefined; + // Certificates are immutable — any material change replaces. + if (!cnsEqual(news.cns, olds.cns)) { + return { action: "replace" } as const; + } + if ( + JSON.stringify(news.upload ?? null) !== + JSON.stringify(olds.upload ?? null) + ) { + return { action: "replace" } as const; + } + return undefined; + }), + read: Effect.fn(function* ({ output }) { + if (!output?.certId) return undefined; + return yield* observeCert(output.certId); + }), + list: Effect.fn(function* () { + const { teamId } = yield* VercelEnvironment.current; + const rows: CertAttributes[] = []; + // getCerts carries no pagination inputs in the spec — a single page is + // the full enumeration surface. + const page = yield* certs.getCerts({ teamId }); + for (const cert of page.certs) { + rows.push({ + certId: cert.id, + cns: [...cert.cns], + createdAt: cert.createdAt, + expiresAt: cert.expiresAt, + autoRenew: cert.autoRenew, + }); + } + return rows; + }), + reconcile: Effect.fn(function* ({ news, output }) { + const { teamId } = yield* VercelEnvironment.current; + // Observe — the cert may already exist from a prior (unrecorded) run. + const observed = output?.certId + ? yield* observeCert(output.certId) + : undefined; + if (observed !== undefined) { + // Certificates are immutable; material changes arrive as + // replacements, so an existing cert is already converged. + return observed; + } + if (news.upload !== undefined) { + const uploaded = yield* certs.uploadCert({ + teamId, + ca: news.upload.ca, + key: news.upload.key, + cert: news.upload.cert, + ...(news.upload.skipValidation !== undefined + ? { skipValidation: news.upload.skipValidation } + : {}), + }); + return { + certId: uploaded.id, + cns: [...uploaded.cns], + createdAt: uploaded.createdAt, + expiresAt: uploaded.expiresAt, + autoRenew: uploaded.autoRenew, + } satisfies CertAttributes; + } + if (news.cns === undefined || news.cns.length === 0) { + return yield* Effect.die( + "Vercel.Cert requires either `cns` (issue) or `upload` (custom certificate)", + ); + } + const issued = yield* certs.issueCert({ teamId, cns: [...news.cns] }); + return { + certId: issued.id, + cns: [...issued.cns], + createdAt: issued.createdAt, + expiresAt: issued.expiresAt, + autoRenew: issued.autoRenew, + } satisfies CertAttributes; + }), + delete: Effect.fn(function* ({ output }) { + const { teamId } = yield* VercelEnvironment.current; + yield* certs + .removeCert({ id: output.certId, teamId }) + .pipe(Effect.catchTag("NotFound", () => Effect.void)); + }), + }); diff --git a/packages/alchemy/src/Vercel/Domains/DnsRecord.ts b/packages/alchemy/src/Vercel/Domains/DnsRecord.ts new file mode 100644 index 0000000000..966bcb1ea1 --- /dev/null +++ b/packages/alchemy/src/Vercel/Domains/DnsRecord.ts @@ -0,0 +1,423 @@ +import * as dns from "@distilled.cloud/vercel/dns"; +import * as domains from "@distilled.cloud/vercel/domains"; +import * as Effect from "effect/Effect"; +import { isResolved } from "../../Diff.ts"; +import * as Provider from "../../Provider.ts"; +import { Resource } from "../../Resource.ts"; +import { VercelEnvironment } from "../VercelEnvironment.ts"; +import type { Domain } from "./Domain.ts"; +import type { Providers } from "../Providers.ts"; + +/** The DNS zone the record lives in — a `Vercel.Domain` or a plain name. */ +export type DnsRecordDomainSource = Domain | { name: string } | string; + +export type DnsRecordType = + | "A" + | "AAAA" + | "ALIAS" + | "CAA" + | "CNAME" + | "HTTPS" + | "MX" + | "SRV" + | "TXT" + | "NS"; + +export interface DnsRecordSrv { + /** The SRV target hostname. */ + target: string; + /** The SRV weight (0-65535). */ + weight: number; + /** The SRV port (0-65535). */ + port: number; + /** The SRV priority (0-65535). */ + priority: number; +} + +export interface DnsRecordHttps { + /** The HTTPS priority (0-65535). */ + priority: number; + /** The HTTPS target hostname. */ + target: string; + /** The HTTPS parameter string, e.g. `alpn=h2,h3`. */ + params?: string; +} + +export interface DnsRecordProps { + /** + * The domain (DNS zone) to create the record in. Accepts a + * `Vercel.Domain` resource or a plain domain name. The domain must have a + * Vercel DNS zone (`zone: true` on the `Domain`). Changing the domain + * replaces the record. + */ + domain: DnsRecordDomainSource; + /** + * The DNS record type. + */ + type: DnsRecordType; + /** + * A subdomain name, or an empty string for the root domain. Required for + * every record type except SRV and HTTPS (whose name derives from + * `srv`/`https`). + */ + name?: string; + /** + * The record value (address, hostname, or text depending on the type). + * Not used for SRV and HTTPS records. + */ + value?: string; + /** + * Time-to-live in seconds (60 – 2147483647). + * @default 60 + */ + ttl?: number; + /** + * The MX priority (0-65535). Required for MX records. + */ + mxPriority?: number; + /** + * The SRV payload. Required for SRV records. + */ + srv?: DnsRecordSrv; + /** + * The HTTPS payload. Required for HTTPS records. + */ + https?: DnsRecordHttps; + /** + * A comment to add context on what this DNS record is for (max 500 + * characters). + */ + comment?: string; +} + +export type DnsRecord = Resource< + "Vercel.DnsRecord", + DnsRecordProps, + { + /** + * The record's unique id. NOTE: Vercel mints a NEW id on every update — + * the id is not stable across updates. + */ + recordId: string; + /** The domain (zone) the record lives in. */ + domain: string; + /** The DNS record type. */ + type: string; + /** The record name (subdomain, or empty string for the root). */ + name: string; + /** + * The record value as stored by Vercel (derived server-side for + * MX/SRV/HTTPS records). + */ + value: string; + /** Time-to-live in seconds. */ + ttl: number | undefined; + /** The MX priority, when set. */ + mxPriority: number | undefined; + /** The SRV payload, when set. */ + srv: DnsRecordSrv | undefined; + /** The HTTPS payload, when set. */ + https: DnsRecordHttps | undefined; + /** The record comment, when set. */ + comment: string | undefined; + }, + never, + Providers +>; + +type DnsRecordAttributes = DnsRecord["Attributes"]; + +/** + * A DNS record in a Vercel-hosted DNS zone. + * + * The parent domain must be a Vercel DNS zone — add it with + * `Vercel.Domain` (which enables `zone` by default). Records in a zone that + * is not Vercel-hosted are rejected by the API with `invalid_zone`. + * + * @resource + * @section Creating records + * @example TXT record + * ```typescript + * const zone = yield* Vercel.Domain("Zone", { name: "acme.com" }); + * yield* Vercel.DnsRecord("Verification", { + * domain: zone, + * type: "TXT", + * name: "_verify", + * value: "token-123", + * }); + * ``` + * + * @example CNAME record + * ```typescript + * yield* Vercel.DnsRecord("Www", { + * domain: zone, + * type: "CNAME", + * name: "www", + * value: "cname.vercel-dns.com", + * }); + * ``` + * + * @example MX record + * ```typescript + * yield* Vercel.DnsRecord("Mail", { + * domain: zone, + * type: "MX", + * name: "", + * value: "mail.acme.com", + * mxPriority: 10, + * }); + * ``` + * + * @see https://vercel.com/docs/domains/managing-dns-records + */ +export const DnsRecord = Resource("Vercel.DnsRecord"); + +const resolveDomainName = ( + source: DnsRecordDomainSource | undefined, +): string | undefined => { + if (source === undefined) return undefined; + if (typeof source === "string") return source; + if ("name" in source && typeof source.name === "string") return source.name; + return undefined; +}; + +/** Read a record by id, returning `undefined` when it no longer exists. */ +const observeRecord = (recordId: string) => + dns + .getDomainsRecordsByRecordId({ recordId }) + .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))); + +const toAttributes = ( + observed: dns.GetDomainsRecordsByRecordIdResponse, + props: Partial>, +): DnsRecordAttributes => ({ + recordId: observed.id, + domain: observed.domain, + type: observed.recordType, + name: observed.name, + value: observed.value, + ttl: observed.ttl, + mxPriority: props.mxPriority, + srv: props.srv, + https: props.https, + comment: observed.comment, +}); + +export const DnsRecordProvider = () => + Provider.succeed(DnsRecord, { + diff: Effect.fn(function* ({ olds, news, output }) { + // Detect an upstream domain change before short-circuiting on + // unresolved news — a replaced parent domain must replace the record. + const oldDomain = + output?.domain ?? + resolveDomainName(olds.domain as DnsRecordDomainSource); + const newDomain = + "domain" in news + ? resolveDomainName(news.domain as DnsRecordDomainSource) + : undefined; + // Only a RESOLVED differing name is a move. An unresolved domain + // input (the parent Domain has its own pending change, e.g. a + // delete-first flag replacement under the SAME name) must NOT plan a + // replacement: record ids are content-addressed, so a create-first + // replacement of an unchanged record re-mints the SAME id and the + // old-generation cleanup would delete the successor. Reconcile + // observes the actual zone and heals (recreate or move) instead. + if ( + oldDomain !== undefined && + newDomain !== undefined && + oldDomain !== newDomain + ) { + return { action: "replace" } as const; + } + if (!isResolved(news)) return undefined; + return undefined; + }), + read: Effect.fn(function* ({ olds, output }) { + if (!output?.recordId) return undefined; + const observed = yield* observeRecord(output.recordId); + if (observed === undefined) return undefined; + return toAttributes(observed, olds ?? {}); + }), + list: Effect.fn(function* () { + const { teamId } = yield* VercelEnvironment.current; + // Records are scoped to a domain with no account-wide enumeration — + // fan out over every domain on the team. + const domainNames: string[] = []; + let domainsUntil: number | undefined; + do { + const page = yield* domains.getDomains({ + teamId, + limit: 100, + until: domainsUntil, + }); + domainNames.push(...page.domains.map((d) => d.name)); + domainsUntil = page.pagination.next ?? undefined; + } while (domainsUntil !== undefined); + + const perDomain = yield* Effect.forEach( + domainNames, + (domain) => + Effect.gen(function* () { + const rows: DnsRecordAttributes[] = []; + let until: number | undefined; + do { + const body = yield* dns.getRecords({ + domain, + teamId, + limit: "100", + ...(until !== undefined ? { until: String(until) } : {}), + }); + if (typeof body === "string") break; + for (const record of body.records) { + // Zone-management records Vercel creates itself (CAA etc.) + // belong to the zone, not to any alchemy resource. + if (record.creator === "system") continue; + rows.push({ + recordId: record.id, + domain, + type: record.type, + name: record.name, + value: record.value, + ttl: record.ttl, + mxPriority: record.mxPriority, + srv: undefined, + https: undefined, + comment: record.comment, + }); + } + until = + "pagination" in body + ? (body.pagination.next ?? undefined) + : undefined; + } while (until !== undefined); + return rows; + }).pipe( + // The domain may be deleted between enumeration and listing. + Effect.catchTag("NotFound", () => Effect.succeed([])), + ), + { concurrency: 8 }, + ); + return perDomain.flat(); + }), + reconcile: Effect.fn(function* ({ news, output }) { + const { teamId } = yield* VercelEnvironment.current; + const domain = resolveDomainName(news.domain); + if (domain === undefined) { + return yield* Effect.die( + "Invalid Vercel DNS record domain source: must be a Domain, { name } or a plain domain name", + ); + } + + // Observe — the record id from prior output is a cache, not a + // guarantee: the record may have been deleted out-of-band, and Vercel + // mints a new id on every update. + let observed = output?.recordId + ? yield* observeRecord(output.recordId) + : undefined; + + // A record cannot be moved across zones via update — when the + // desired zone differs from the observed one (a move that diff could + // not classify because the parent Domain's output was unresolved at + // plan time), remove the old record and fall through to create. + if (observed !== undefined && observed.domain !== domain) { + yield* dns + .removeRecord({ + domain: observed.domain, + recordId: observed.id, + teamId, + }) + .pipe(Effect.catchTag("NotFound", () => Effect.void)); + observed = undefined; + } + + if (observed === undefined) { + // Ensure — create. Vercel upserts an identical (name, type, value) + // record, returning the existing id, so a crash-retry converges. + const created = yield* dns.createRecord({ + domain, + teamId, + type: news.type, + ...(news.name !== undefined ? { name: news.name } : {}), + ...(news.value !== undefined ? { value: news.value } : {}), + ...(news.ttl !== undefined ? { ttl: news.ttl } : {}), + ...(news.mxPriority !== undefined + ? { mxPriority: news.mxPriority } + : {}), + ...(news.srv !== undefined ? { srv: news.srv } : {}), + ...(news.https !== undefined ? { https: news.https } : {}), + ...(news.comment !== undefined ? { comment: news.comment } : {}), + }); + const fresh = yield* observeRecord(created.uid); + if (fresh === undefined) { + return yield* Effect.die( + `Vercel DNS record ${created.uid} not observable after create`, + ); + } + return toAttributes(fresh, news); + } + + // Sync — diff OBSERVED cloud state against desired; PATCH only the + // delta. The read API does not return mxPriority/srv/https, so those + // are compared against the prior output snapshot (the only baseline + // that exists for them). + const scalarDelta = + observed.recordType !== news.type || + (news.name !== undefined && observed.name !== news.name) || + (news.value !== undefined && observed.value !== news.value) || + (observed.ttl ?? 60) !== (news.ttl ?? 60) || + (news.comment !== undefined && observed.comment !== news.comment); + const blindDelta = + JSON.stringify({ + mxPriority: news.mxPriority, + srv: news.srv, + https: news.https, + }) !== + JSON.stringify({ + mxPriority: output?.mxPriority, + srv: output?.srv, + https: output?.https, + }); + + if (!scalarDelta && !blindDelta) { + return toAttributes(observed, news); + } + + // NOTE: updateRecord mints a NEW record id — persist the returned id. + const updated = yield* dns.updateRecord({ + recordId: observed.id, + teamId, + type: news.type, + ...(news.name !== undefined ? { name: news.name } : {}), + ...(news.value !== undefined ? { value: news.value } : {}), + ...(news.ttl !== undefined ? { ttl: news.ttl } : {}), + ...(news.mxPriority !== undefined + ? { mxPriority: news.mxPriority } + : {}), + ...(news.srv !== undefined ? { srv: news.srv } : {}), + ...(news.https !== undefined ? { https: news.https } : {}), + ...(news.comment !== undefined ? { comment: news.comment } : {}), + }); + return { + recordId: updated.id, + domain: updated.domain, + type: updated.recordType, + name: updated.name, + value: updated.value, + ttl: updated.ttl, + mxPriority: news.mxPriority, + srv: news.srv, + https: news.https, + comment: updated.comment, + }; + }), + delete: Effect.fn(function* ({ output }) { + const { teamId } = yield* VercelEnvironment.current; + yield* dns + .removeRecord({ + domain: output.domain, + recordId: output.recordId, + teamId, + }) + .pipe(Effect.catchTag("NotFound", () => Effect.void)); + }), + }); diff --git a/packages/alchemy/src/Vercel/Domains/Domain.ts b/packages/alchemy/src/Vercel/Domains/Domain.ts new file mode 100644 index 0000000000..a9dff4be91 --- /dev/null +++ b/packages/alchemy/src/Vercel/Domains/Domain.ts @@ -0,0 +1,209 @@ +import * as domains from "@distilled.cloud/vercel/domains"; +import * as Effect from "effect/Effect"; +import { Unowned } from "../../AdoptPolicy.ts"; +import { isResolved } from "../../Diff.ts"; +import * as Provider from "../../Provider.ts"; +import { Resource } from "../../Resource.ts"; +import { VercelEnvironment } from "../VercelEnvironment.ts"; +import type { Providers } from "../Providers.ts"; + +export interface DomainProps { + /** + * The apex domain name to add to the Vercel team, e.g. `example.com`. + * Changing the name replaces the domain. + */ + name: string; + /** + * Whether to create a DNS zone on Vercel for the domain. Required before + * any `Vercel.DnsRecord` can be created for it. The flag is immutable on + * the platform (re-adding an existing domain does not update it), so + * changing it replaces the domain (delete-first — the physical name is + * identical either side of the replacement). + * + * @default true + */ + zone?: boolean; + /** + * Whether the domain has the Vercel Edge Network enabled. Immutable on the + * platform — changing it replaces the domain (delete-first). + * + * @default true (Vercel's default) + */ + cdnEnabled?: boolean; +} + +export type Domain = Resource< + "Vercel.Domain", + DomainProps, + { + /** The unique identifier of the domain (content-addressed by name). */ + domainId: string; + /** The domain name. */ + name: string; + /** Whether Vercel has verified ownership of the domain. */ + verified: boolean; + /** Current nameservers of the domain. */ + nameservers: string[]; + /** Nameservers to point the domain at Vercel DNS. */ + intendedNameservers: string[]; + /** + * `external` if DNS is externally handled, `zeit.world` if handled by + * Vercel, `na` if unavailable. + */ + serviceType: string; + /** Timestamp (ms) when the domain was added. */ + createdAt: number; + }, + never, + Providers +>; + +type DomainAttributes = Domain["Attributes"]; + +/** + * An apex domain registered as a domain-of-record on the Vercel team. + * + * Adding a `Domain` makes the name available for `Vercel.DnsRecord`s (when + * `zone` is enabled, the default) and for attaching to projects via + * `Vercel.ProjectDomain`. Vercel has no tags and domains carry no metadata + * channel, so ownership is tracked purely through the domain's + * user-specified name plus alchemy state — `read` without prior state + * reports an existing domain as unowned, gating takeover behind `--adopt`. + * + * @resource + * @section Adding a Domain + * @example Domain with a Vercel DNS zone (default) + * ```typescript + * const zone = yield* Vercel.Domain("Zone", { name: "acme.com" }); + * ``` + * + * @example Externally-managed domain without a Vercel DNS zone + * ```typescript + * const domain = yield* Vercel.Domain("Apex", { + * name: "acme.com", + * zone: false, + * }); + * ``` + * + * @section DNS records on the domain + * @example CNAME for a subdomain + * ```typescript + * const zone = yield* Vercel.Domain("Zone", { name: "acme.com" }); + * yield* Vercel.DnsRecord("Www", { + * domain: zone, + * type: "CNAME", + * name: "www", + * value: "cname.vercel-dns.com", + * }); + * ``` + * + * @see https://vercel.com/docs/domains + */ +export const Domain = Resource("Vercel.Domain"); + +const toAttributes = ( + domain: + | domains.GetDomainResponseDomain + | domains.GetDomainsResponseDomainsItem, +): DomainAttributes => ({ + domainId: domain.id, + name: domain.name, + verified: domain.verified, + nameservers: [...domain.nameservers], + intendedNameservers: [...domain.intendedNameservers], + serviceType: domain.serviceType, + createdAt: domain.createdAt, +}); + +const observeDomain = (name: string) => + Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + return yield* domains.getDomain({ domain: name, teamId }).pipe( + Effect.map((res) => toAttributes(res.domain)), + Effect.catchTag("NotFound", () => Effect.succeed(undefined)), + ); + }); + +export const DomainProvider = () => + Provider.succeed(Domain, { + stables: ["domainId", "name"], + diff: Effect.fn(function* ({ olds, news, output }) { + if (!isResolved(news)) return undefined; + if (!output) return undefined; + if (news.name !== olds.name) { + return { action: "replace" } as const; + } + // `zone`/`cdnEnabled` are immutable platform flags (re-adding an + // existing domain does not update them) and the physical name is + // identical across the replacement, so the old domain must be deleted + // before the new one is created. + if ( + (news.zone ?? true) !== (olds.zone ?? true) || + news.cdnEnabled !== olds.cdnEnabled + ) { + return { action: "replace", deleteFirst: true } as const; + } + return undefined; + }), + read: Effect.fn(function* ({ olds, output }) { + const name = output?.name ?? olds?.name; + if (name === undefined) return undefined; + const attrs = yield* observeDomain(name); + if (attrs === undefined) return undefined; + // Vercel domains have no ownership channel (no tags, no env). With + // prior state the engine already knows the domain is ours; without it + // an existing domain could equally be a foreign domain-of-record, so + // gate takeover behind `--adopt`. + return output !== undefined ? attrs : Unowned(attrs); + }), + list: Effect.fn(function* () { + const { teamId } = yield* VercelEnvironment.current; + const rows: DomainAttributes[] = []; + let until: number | undefined; + do { + const page = yield* domains.getDomains({ teamId, limit: 100, until }); + rows.push(...page.domains.map(toAttributes)); + until = page.pagination.next ?? undefined; + } while (until !== undefined); + return rows; + }), + reconcile: Effect.fn(function* ({ news }) { + const { teamId } = yield* VercelEnvironment.current; + // Observe — the domain may already exist (crash recovery, adoption). + const observed = yield* observeDomain(news.name); + if (observed === undefined) { + // Ensure — `createOrTransferDomain` with a bare body adds the + // domain. NOTE: `method: "add"` must be omitted (the server-side + // oneOf rejects `{name, method: "add"}` — see the distilled patch). + yield* domains + .createOrTransferDomain({ + teamId, + name: news.name, + ...(news.zone !== false ? { zone: true } : {}), + ...(news.cdnEnabled !== undefined + ? { cdnEnabled: news.cdnEnabled } + : {}), + }) + .pipe( + // A concurrent add of the same domain is a race, not a failure. + Effect.catchTag("Conflict", () => Effect.void), + ); + } + // `zone`/`cdnEnabled` drift on an existing domain is handled by diff + // (delete-first replacement) — re-adding does not update the flags. + // Return — re-read the final state. + const fresh = yield* observeDomain(news.name); + if (fresh === undefined) { + return yield* Effect.die( + `Vercel domain ${news.name} not observable after add`, + ); + } + return fresh; + }), + delete: Effect.fn(function* ({ output }) { + const { teamId } = yield* VercelEnvironment.current; + yield* domains + .deleteDomain({ domain: output.name, teamId }) + .pipe(Effect.catchTag("NotFound", () => Effect.void)); + }), + }); diff --git a/packages/alchemy/src/Vercel/Domains/ProjectDomain.ts b/packages/alchemy/src/Vercel/Domains/ProjectDomain.ts new file mode 100644 index 0000000000..dd7ba1749e --- /dev/null +++ b/packages/alchemy/src/Vercel/Domains/ProjectDomain.ts @@ -0,0 +1,353 @@ +import * as projects from "@distilled.cloud/vercel/projects"; +import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; +import { isResolved } from "../../Diff.ts"; +import * as Provider from "../../Provider.ts"; +import { Resource } from "../../Resource.ts"; +import { VercelEnvironment } from "../VercelEnvironment.ts"; +import type { Providers } from "../Providers.ts"; + +/** The Vercel project to attach the domain to — a resource carrying a + * `projectId` (e.g. `Vercel.Project`) or a plain project id/name. */ +export type ProjectDomainProjectSource = { projectId: string } | string; + +export type ProjectDomainRedirectStatusCode = 301 | 302 | 307 | 308; + +/** A single verification challenge for a pending project domain. */ +export interface ProjectDomainVerification { + type: string; + domain: string; + value: string; + reason: string; +} + +export interface ProjectDomainProps { + /** + * The project to attach the domain to. Accepts a `Vercel.Project` + * resource (or anything carrying a `projectId`) or a plain project + * id/name. Changing the project replaces the attachment. + */ + project: ProjectDomainProjectSource; + /** + * The domain name to attach, e.g. `app.acme.com`. Changing the name + * replaces the attachment. + * + * Attaching a domain whose apex is not yet on the team automatically adds + * the apex domain to the team's domain list. + */ + name: string; + /** + * Git branch to link the project domain to (deploys of this branch get + * the domain). `undefined` links to the production branch. + */ + gitBranch?: string; + /** + * Target destination domain when this domain should redirect. + */ + redirect?: string; + /** + * Status code for the domain redirect. + */ + redirectStatusCode?: ProjectDomainRedirectStatusCode; +} + +export type ProjectDomain = Resource< + "Vercel.ProjectDomain", + ProjectDomainProps, + { + /** The id of the project the domain is attached to. */ + projectId: string; + /** The attached domain name. */ + name: string; + /** The apex domain of the attached name. */ + apexName: string; + /** + * `true` once the domain is verified for use with the project. While + * `false` the domain serves nothing and `verification` carries the + * outstanding challenge(s). + */ + verified: boolean; + /** Linked git branch, when set. */ + gitBranch: string | undefined; + /** Redirect target, when set. */ + redirect: string | undefined; + /** Redirect status code, when set. */ + redirectStatusCode: number | undefined; + /** Outstanding verification challenges while `verified` is false. */ + verification: ProjectDomainVerification[] | undefined; + }, + never, + Providers +>; + +type ProjectDomainAttributes = ProjectDomain["Attributes"]; + +/** + * Attaches a domain name to a Vercel project so deployments serve on it. + * + * If the domain's apex is owned by (or gets auto-added to) the same team, + * the attachment verifies immediately. A domain owned by another Vercel + * team stays `verified: false` with a TXT `verification` challenge until + * the challenge record is created — the resource converges to the pending + * state and reports the challenge in its attributes rather than failing. + * + * @resource + * @section Attaching a domain + * @example Attach a subdomain to a project + * ```typescript + * const project = yield* Vercel.Project("Site"); + * const zone = yield* Vercel.Domain("Zone", { name: "acme.com" }); + * yield* Vercel.ProjectDomain("AppDomain", { + * project, + * name: "app.acme.com", + * }); + * ``` + * + * @section Redirects + * @example Redirect the apex to www + * ```typescript + * yield* Vercel.ProjectDomain("ApexRedirect", { + * project, + * name: "acme.com", + * redirect: "www.acme.com", + * redirectStatusCode: 308, + * }); + * ``` + * + * @section Branch domains + * @example Domain for a preview branch + * ```typescript + * yield* Vercel.ProjectDomain("StagingDomain", { + * project, + * name: "staging.acme.com", + * gitBranch: "staging", + * }); + * ``` + * + * @see https://vercel.com/docs/domains/add-a-domain + */ +export const ProjectDomain = Resource("Vercel.ProjectDomain"); + +const resolveProjectId = ( + source: ProjectDomainProjectSource | undefined, +): string | undefined => { + if (source === undefined) return undefined; + if (typeof source === "string") return source; + if ("projectId" in source && typeof source.projectId === "string") { + return source.projectId; + } + return undefined; +}; + +interface ObservedProjectDomain { + name: string; + apexName: string; + projectId: string; + verified: boolean; + gitBranch?: string | null; + redirect?: string | null; + redirectStatusCode?: number | null; + verification?: ReadonlyArray; +} + +const toAttributes = ( + observed: ObservedProjectDomain, +): ProjectDomainAttributes => ({ + projectId: observed.projectId, + name: observed.name, + apexName: observed.apexName, + verified: observed.verified, + gitBranch: observed.gitBranch ?? undefined, + redirect: observed.redirect ?? undefined, + redirectStatusCode: observed.redirectStatusCode ?? undefined, + verification: observed.verification?.map((v) => ({ + type: v.type, + domain: v.domain, + value: v.value, + reason: v.reason, + })), +}); + +/** + * Find the project domain by name via the list endpoint. `getProjectDomain` + * 404s for a missing domain but distilled's typed union for it carries no + * `NotFound` (the OpenAPI omits the 404 response), and the `projects` + * service is owned by the core factory agent — the list endpoint reaches + * the same row without an out-of-union error path. + */ +const observeProjectDomain = (idOrName: string, name: string) => + Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + let until: number | undefined; + do { + const body = yield* projects.getProjectDomains({ + idOrName, + teamId, + limit: 100, + until, + }); + const match = body.domains.find((d) => d.name === name); + if (match !== undefined) return match as ObservedProjectDomain; + until = body.pagination.next ?? undefined; + } while (until !== undefined); + return undefined; + }); + +export const ProjectDomainProvider = () => + Provider.succeed(ProjectDomain, { + stables: ["projectId", "name", "apexName"], + diff: Effect.fn(function* ({ olds, news, output }) { + const oldProjectId = + output?.projectId ?? + resolveProjectId(olds.project as ProjectDomainProjectSource); + const newProjectId = + "project" in news + ? resolveProjectId(news.project as ProjectDomainProjectSource) + : undefined; + if (oldProjectId !== undefined && oldProjectId !== newProjectId) { + // A domain name can only be attached to ONE project on the team at + // a time — a create-first replacement that re-points the SAME name + // at a new project Conflicts while the old attachment still exists, + // so it must detach first. When the name changes too (or is not yet + // resolved), the successor's add cannot collide with the old row + // only if the resolved name is known to differ. + const oldName = output?.name ?? olds.name; + const newName = + "name" in news && typeof news.name === "string" + ? news.name + : undefined; + return oldName !== undefined && + (newName === undefined || newName === oldName) + ? ({ action: "replace", deleteFirst: true } as const) + : ({ action: "replace" } as const); + } + if (!isResolved(news)) return undefined; + if (!output) return undefined; + if (news.name !== output.name) { + return { action: "replace" } as const; + } + return undefined; + }), + read: Effect.fn(function* ({ olds, output }) { + const projectId = + output?.projectId ?? + resolveProjectId(olds?.project as ProjectDomainProjectSource); + const name = output?.name ?? olds?.name; + if (projectId === undefined || name === undefined) return undefined; + const observed = yield* observeProjectDomain(projectId, name).pipe( + // The whole project may already be gone. + Effect.catchTag("NotFound", () => Effect.succeed(undefined)), + ); + if (observed === undefined) return undefined; + return toAttributes(observed); + }), + reconcile: Effect.fn(function* ({ news, output }) { + const { teamId } = yield* VercelEnvironment.current; + const projectId = output?.projectId ?? resolveProjectId(news.project); + if (projectId === undefined) { + return yield* Effect.die( + "Invalid Vercel project source: must be a Project, { projectId } or a plain project id/name", + ); + } + + // Observe — the attachment may already exist (crash recovery, race). + let observed = yield* observeProjectDomain(projectId, news.name); + + if (observed === undefined) { + // Ensure — attach. A concurrent attach races as Conflict + // (`domain_already_in_use` on this project); re-observe. A domain + // that was detached or deleted moments earlier (multi-cycle + // redeploys, replacement successors) can transiently Conflict or + // lag out of the list — retry the add+observe loop boundedly until + // the attachment is visible. + observed = yield* Effect.gen(function* () { + yield* projects + .addProjectDomain({ + idOrName: projectId, + teamId, + name: news.name, + ...(news.gitBranch !== undefined + ? { gitBranch: news.gitBranch } + : {}), + ...(news.redirect !== undefined + ? { redirect: news.redirect } + : {}), + ...(news.redirectStatusCode !== undefined + ? { redirectStatusCode: news.redirectStatusCode } + : {}), + }) + .pipe(Effect.catchTag("Conflict", () => Effect.void)); + return yield* observeProjectDomain(projectId, news.name); + }).pipe( + Effect.repeat({ + schedule: Schedule.exponential("1 second"), + until: (o) => o !== undefined, + times: 5, + }), + ); + if (observed === undefined) { + return yield* Effect.die( + `Vercel project domain ${news.name} not observable after add on project ${projectId}`, + ); + } + } + + // Sync — diff observed configuration against desired; PATCH only on + // delta. + const delta = + (observed.gitBranch ?? undefined) !== news.gitBranch || + (observed.redirect ?? undefined) !== news.redirect || + (observed.redirectStatusCode ?? undefined) !== news.redirectStatusCode; + if (delta) { + yield* projects.updateProjectDomain({ + idOrName: projectId, + domain: news.name, + teamId, + gitBranch: news.gitBranch ?? null, + redirect: news.redirect ?? null, + redirectStatusCode: news.redirectStatusCode ?? null, + }); + } + + // Verify — attempt the verification challenge when pending. A domain + // whose challenge is not yet satisfied answers 400; converge to the + // pending state instead of failing (the challenge is surfaced in the + // attributes). + if (!observed.verified) { + yield* projects + .verifyProjectDomain({ + idOrName: projectId, + domain: news.name, + teamId, + }) + .pipe(Effect.catchTag("BadRequest", () => Effect.void)); + } + + // Return — re-read the final state. + const fresh = yield* observeProjectDomain(projectId, news.name); + return toAttributes(fresh ?? observed); + }), + delete: Effect.fn(function* ({ output }) { + const { teamId } = yield* VercelEnvironment.current; + // NOTE: detaching does NOT remove an auto-added apex domain from the + // team's domain list — that is a separate `Vercel.Domain`. + yield* projects + .removeProjectDomain({ + idOrName: output.projectId, + domain: output.name, + teamId, + }) + .pipe( + // A domain that other project domains redirect to cannot be + // removed until those redirects are gone (typed Conflict). When + // the redirecting attachments are being deleted in the same + // destroy the race resolves itself — retry boundedly. + Effect.retry({ + while: (e) => e._tag === "Conflict", + schedule: Schedule.exponential("1 second"), + times: 6, + }), + Effect.catchTag("NotFound", () => Effect.void), + ); + }), + }); diff --git a/packages/alchemy/src/Vercel/Domains/index.ts b/packages/alchemy/src/Vercel/Domains/index.ts new file mode 100644 index 0000000000..f213ecc7c5 --- /dev/null +++ b/packages/alchemy/src/Vercel/Domains/index.ts @@ -0,0 +1,4 @@ +export * from "./Cert.ts"; +export * from "./DnsRecord.ts"; +export * from "./Domain.ts"; +export * from "./ProjectDomain.ts"; diff --git a/packages/alchemy/src/Vercel/Drains/Drain.ts b/packages/alchemy/src/Vercel/Drains/Drain.ts new file mode 100644 index 0000000000..d0255fa374 --- /dev/null +++ b/packages/alchemy/src/Vercel/Drains/Drain.ts @@ -0,0 +1,565 @@ +/** + * Vercel Drain resource — the unified observability drains API (`/v1/drains`). + * + * NOTE: the legacy Log Drains API (distilled service `log_drains`, + * `/v1/integrations/log-drains` + `/v2/deployments/.../log-drains`) is + * superseded by this unified drains API and is deliberately NOT implemented + * as an alchemy resource — it is integration-scoped, redundant with `Drain`, + * and Vercel steers all self-served use to `/v1/drains`. + */ +import * as drains from "@distilled.cloud/vercel/drains"; +import * as Effect from "effect/Effect"; +import { createPhysicalName } from "../../PhysicalName.ts"; +import * as Provider from "../../Provider.ts"; +import { Resource } from "../../Resource.ts"; +import type { Providers } from "../Providers.ts"; +import { VercelEnvironment } from "../VercelEnvironment.ts"; + +/** + * Observability datasets a drain can subscribe to. Each key maps to a schema + * version (currently `"v1"` for all datasets). + */ +export type DrainSchemaKey = + | "log" + | "trace" + | "analytics" + | "speed_insights" + | "ai_gateway" + | "audit_log" + | "connect"; + +/** + * The datasets the drain receives, keyed by dataset name with the schema + * version to deliver, e.g. `{ log: { version: "v1" } }`. + */ +export type DrainSchemas = { + [K in DrainSchemaKey]?: { version: string }; +}; + +export type DrainLogSource = + | "build" + | "edge" + | "lambda" + | "static" + | "external" + | "firewall" + | "redirect"; + +export type DrainEnvironment = "production" | "preview"; + +/** + * Structured filter: restrict by project ids, log sources, and/or deployment + * environments. + */ +export interface DrainBasicFilter { + type: "basic"; + /** Restrict to specific project ids. */ + project?: { ids?: string[] }; + /** Restrict log events to specific sources. */ + log?: { sources?: DrainLogSource[] }; + /** Restrict to deployments in specific environments. */ + deployment?: { environments?: DrainEnvironment[] }; +} + +/** Free-form OData filter expression. */ +export interface DrainODataFilter { + type: "odata"; + text: string; +} + +export type DrainFilter = DrainBasicFilter | DrainODataFilter; + +/** + * HTTP delivery — Vercel POSTs event batches to `endpoint`. + * + * Note: Vercel validates the endpoint URL at create time (unresolvable + * hosts are rejected with a typed `BadRequest` "Invalid delivery endpoint"), + * but does NOT verify delivery — the endpoint doesn't need to accept the + * payloads for the drain to be created. + */ +export interface DrainHttpDelivery { + type: "http"; + /** URL that receives the drained events. */ + endpoint: string; + /** Payload encoding. */ + encoding: "json" | "ndjson"; + /** + * Payload compression. + * @default "none" + */ + compression?: "gzip" | "none"; + /** Extra headers sent with every delivery request. */ + headers?: Record; + /** + * Secret used to sign deliveries (`x-vercel-signature`). Write-only on + * the Vercel side. + */ + secret?: string; +} + +/** OTLP/HTTP delivery for traces. */ +export interface DrainOtlpDelivery { + type: "otlphttp"; + /** Per-signal OTLP endpoints. */ + endpoint: { traces: string }; + /** OTLP encoding. */ + encoding: "proto" | "json"; + /** Extra headers sent with every delivery request. */ + headers?: Record; + /** Secret used to sign deliveries. Write-only on the Vercel side. */ + secret?: string; +} + +/** S3-compatible bucket delivery (AWS role-based). */ +export interface DrainS3Delivery { + type: "s3"; + /** Bucket URL, e.g. `s3://my-bucket/prefix`. */ + endpoint: string; + encoding: "json" | "ndjson"; + compression: "none"; + fileStructure: "hive"; + /** IAM role Vercel assumes to write objects. */ + roleArn: string; + /** Bucket region. */ + region: string; + serverSideEncryption?: "AES256" | "aws:kms" | "aws:kms:dsse"; + objectAcl?: "private" | "bucket-owner-read" | "bucket-owner-full-control"; +} + +export type DrainDelivery = + | DrainHttpDelivery + | DrainOtlpDelivery + | DrainS3Delivery; + +/** Head-sampling rule applied before delivery. */ +export interface DrainSamplingRule { + /** Sampling rate from 0 to 1 (e.g. 0.1 for 10%). */ + rate: number; + /** Environment to apply sampling to. */ + env?: DrainEnvironment; + /** Request path prefix to apply the sampling rule to. */ + requestPath?: string; +} + +export type DrainStatus = "enabled" | "disabled" | "errored"; + +export interface DrainProps { + /** + * Name of the drain. If omitted, a unique name is generated from + * `${app}-${id}-${stage}`. + */ + name?: string; + /** + * Whether the drain receives events from all projects or only the + * projects listed in `projectIds`. + * + * @default "all" + */ + projects?: "all" | "some"; + /** + * Project ids to drain when `projects` is `"some"`. + */ + projectIds?: string[]; + /** + * Filter applied to events before delivery. Omit for no filtering + * (equivalent to an empty `basic` filter). + */ + filter?: DrainFilter; + /** + * The datasets to drain, keyed by dataset with schema version, e.g. + * `{ log: { version: "v1" } }`. + */ + schemas: DrainSchemas; + /** + * Where and how events are delivered. + */ + delivery: DrainDelivery; + /** + * Head-sampling rules. Omit to deliver everything. + */ + sampling?: DrainSamplingRule[]; + /** + * Transforms applied before delivery, referenced by id. Not readable back + * from the API, so drift in this field is detected against the last + * deployed props rather than observed cloud state. + */ + transforms?: { id: string }[]; + /** + * Desired status. Use `"disabled"` to pause delivery without deleting + * the drain. + * + * @default "enabled" + */ + status?: "enabled" | "disabled"; +} + +export type Drain = Resource< + "Vercel.Drain", + DrainProps, + { + /** Drain id, e.g. `drn_xxxxxxxx`. */ + drainId: string; + /** Drain name. */ + drainName: string; + /** Current status. `errored` is set by Vercel after delivery failures. */ + status: DrainStatus; + /** Creation timestamp (epoch ms). */ + createdAt: number; + /** Last-update timestamp (epoch ms). */ + updatedAt: number; + /** Owning user or team id. */ + ownerId: string; + /** Team id, when team-scoped. */ + teamId: string | undefined; + /** Project ids the drain is restricted to (absent = all projects). */ + projectIds: string[] | undefined; + }, + never, + Providers +>; + +type DrainAttributes = Drain["Attributes"]; + +/** + * A Vercel observability Drain — streams logs, traces, analytics, and other + * datasets to an external endpoint over the unified `/v1/drains` API. + * + * @resource + * @section Creating a Drain + * @example Drain all project logs to an HTTP endpoint + * ```typescript + * const drain = yield* Vercel.Drain("Logs", { + * schemas: { log: { version: "v1" } }, + * delivery: { + * type: "http", + * endpoint: "https://logs.example.com/ingest", + * encoding: "ndjson", + * compression: "gzip", + * headers: { authorization: "Bearer abc" }, + * }, + * }); + * ``` + * + * @example Drain traces over OTLP + * ```typescript + * const drain = yield* Vercel.Drain("Traces", { + * schemas: { trace: { version: "v1" } }, + * delivery: { + * type: "otlphttp", + * endpoint: { traces: "https://otel.example.com/v1/traces" }, + * encoding: "proto", + * }, + * }); + * ``` + * + * @section Filtering and sampling + * @example Only lambda logs from production deployments + * ```typescript + * const drain = yield* Vercel.Drain("ProdLambdaLogs", { + * schemas: { log: { version: "v1" } }, + * filter: { + * type: "basic", + * log: { sources: ["lambda"] }, + * deployment: { environments: ["production"] }, + * }, + * delivery: { + * type: "http", + * endpoint: "https://logs.example.com/ingest", + * encoding: "json", + * }, + * }); + * ``` + * + * @example Sample 10% of production events + * ```typescript + * const drain = yield* Vercel.Drain("Sampled", { + * schemas: { log: { version: "v1" } }, + * sampling: [{ rate: 0.1, env: "production" }], + * delivery: { + * type: "http", + * endpoint: "https://logs.example.com/ingest", + * encoding: "json", + * }, + * }); + * ``` + * + * @section Scoping to specific projects + * @example Drain only two projects + * ```typescript + * const drain = yield* Vercel.Drain("AppLogs", { + * projects: "some", + * projectIds: [projectA.projectId, projectB.projectId], + * schemas: { log: { version: "v1" } }, + * delivery: { + * type: "http", + * endpoint: "https://logs.example.com/ingest", + * encoding: "json", + * }, + * }); + * ``` + * + * @see https://vercel.com/docs/drains + */ +export const Drain = Resource("Vercel.Drain"); + +export const DrainProvider = () => + Provider.succeed(Drain, { + stables: ["drainId", "ownerId", "createdAt"], + // NOTE on the `testDrain` op (`POST /v1/drains/test`): it delivers sample + // events to the configured endpoint — a side effect — so it is NOT used + // as plan-time validation in `diff`. Endpoint validation happens at + // create/update time via Vercel's own typed `BadRequest` rejection. + list: Effect.fn(function* () { + const { teamId } = yield* VercelEnvironment.current; + const response = yield* drains.getDrains({ teamId }); + const rows: DrainAttributes[] = []; + for (const d of response.drains) { + // Integration-sourced drains are owned by marketplace integrations + // and can never have been created by this provider — skip them so + // account-wide teardown never tears down an integration's drain. + if (d.source.kind !== "self-served") continue; + rows.push(toAttributes(d)); + } + return rows; + }), + read: Effect.fn(function* ({ id, olds, output }) { + const { teamId } = yield* VercelEnvironment.current; + if (output?.drainId) { + return yield* drains.getDrain({ id: output.drainId, teamId }).pipe( + Effect.map(toAttributes), + Effect.catchTag("NotFound", () => Effect.succeed(undefined)), + ); + } + // Persistence-failure path: drains have no env/metadata surface to + // stamp ownership on (§5.4), so ownership = deterministic naming. + // Auto-generated names embed app/stage/id + instance suffix and can't + // collide with foreign drains; an explicit user name is only matched + // when it was recorded in `olds`. + const name = olds?.name ?? (yield* createPhysicalName({ id })); + const response = yield* drains.getDrains({ teamId }); + for (const d of response.drains) { + if (d.source.kind === "self-served" && d.name === name) { + return toAttributes(d); + } + } + return undefined; + }), + reconcile: Effect.fn(function* ({ id, news, olds, output }) { + const { teamId } = yield* VercelEnvironment.current; + + // Observe — output.drainId is a cache, not a guarantee: fall through + // to create when the drain no longer exists. + const observed = output?.drainId + ? yield* drains + .getDrain({ id: output.drainId, teamId }) + .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))) + : undefined; + + const desiredName = + news.name ?? output?.drainName ?? (yield* createPhysicalName({ id })); + const desiredProjects = news.projects ?? "all"; + const desiredProjectIds = + desiredProjects === "some" ? (news.projectIds ?? []) : undefined; + const desiredFilter: DrainFilter = news.filter ?? { type: "basic" }; + const desiredDelivery = toWireDelivery(news.delivery); + const desiredSampling = news.sampling?.map((rule) => ({ + type: "head_sampling", + rate: rule.rate, + env: rule.env, + requestPath: rule.requestPath, + })); + const desiredStatus = news.status ?? "enabled"; + + // Ensure — greenfield (or vanished) drain. + if (observed === undefined) { + const created = yield* drains.createDrain({ + teamId, + name: desiredName, + projects: desiredProjects, + projectIds: desiredProjectIds, + filter: news.filter + ? { version: "v2", filter: news.filter } + : undefined, + schemas: news.schemas, + delivery: desiredDelivery, + sampling: desiredSampling, + transforms: news.transforms, + }); + return toAttributes(created); + } + + // Sync — diff each observed aspect against desired; PATCH only deltas. + const patch: { + name?: string; + projects?: "all" | "some"; + projectIds?: string[] | null; + filter?: { version: string; filter: DrainFilter }; + schemas?: DrainSchemas; + delivery?: drains.CreateDrainRequestDelivery; + sampling?: typeof desiredSampling | null; + transforms?: { id: string }[] | null; + status?: "enabled" | "disabled"; + } = {}; + + if (observed.name !== desiredName) { + patch.name = desiredName; + } + + const observedProjectIds = [...(observed.projectIds ?? [])].sort(); + if (desiredProjects === "all") { + if (observedProjectIds.length > 0) { + patch.projects = "all"; + patch.projectIds = null; + } + } else if ( + canonical([...(desiredProjectIds ?? [])].sort()) !== + canonical(observedProjectIds) + ) { + patch.projects = "some"; + patch.projectIds = desiredProjectIds ?? []; + } + + if (canonical(news.schemas) !== canonical(observed.schemas)) { + patch.schemas = news.schemas; + } + + // Delivery: compare without `secret` (write-only on Vercel's side) and + // fall back to the previous props as a hint for secret rotation. + if ( + canonical(omitKeys(desiredDelivery, ["secret"])) !== + canonical(omitKeys(observed.delivery, ["secret"])) || + deliverySecret(news.delivery) !== deliverySecret(olds?.delivery) + ) { + patch.delivery = desiredDelivery; + } + + // Filter: "no filter" is canonically an empty basic filter so that a + // removed `filter:` prop converges instead of re-patching forever. + // Vercel decorates observed basic filters with a legacy field — strip + // it before comparing. + const observedFilter = omitKeys(observed.filterV2?.filter, [ + "legacy_excludeCachedStaticAssetLogs", + ]) ?? { type: "basic" }; + if (canonical(desiredFilter) !== canonical(observedFilter)) { + patch.filter = { version: "v2", filter: desiredFilter }; + } + + if (desiredSampling === undefined) { + if ((observed.sampling?.length ?? 0) > 0) { + patch.sampling = null; + } + } else if (canonical(desiredSampling) !== canonical(observed.sampling)) { + patch.sampling = desiredSampling; + } + + // Transforms are not readable back from the API — diff against the + // last deployed props as a hint (the doctrine's one sanctioned use). + if (canonical(news.transforms) !== canonical(olds?.transforms)) { + patch.transforms = news.transforms ?? null; + } + + if ((observed.status ?? "enabled") !== desiredStatus) { + patch.status = desiredStatus; + } + + if (Object.keys(patch).length === 0) { + return toAttributes(observed); + } + + const updated = yield* drains.updateDrain({ + id: observed.id, + teamId, + ...patch, + }); + return toAttributes(updated); + }), + delete: Effect.fn(function* ({ output }) { + const { teamId } = yield* VercelEnvironment.current; + yield* drains + .deleteDrain({ id: output.drainId, teamId }) + .pipe(Effect.catchTag("NotFound", () => Effect.void)); + }), + }); + +/** + * Minimal structural shape shared by every drains API response variant + * (create/get/update/list x self-served/integration). + */ +interface DrainApiShape { + readonly id: string; + readonly name: string; + readonly createdAt: number; + readonly updatedAt: number; + readonly ownerId: string; + readonly teamId?: string | null; + readonly status?: DrainStatus; + readonly projectIds?: ReadonlyArray; +} + +const toAttributes = (d: DrainApiShape): DrainAttributes => ({ + drainId: d.id, + drainName: d.name, + status: d.status ?? "enabled", + createdAt: d.createdAt, + updatedAt: d.updatedAt, + ownerId: d.ownerId, + teamId: d.teamId ?? undefined, + projectIds: d.projectIds ? [...d.projectIds] : undefined, +}); + +/** Normalize the user-facing delivery into the wire shape (headers required). */ +const toWireDelivery = ( + delivery: DrainDelivery, +): drains.CreateDrainRequestDelivery => { + switch (delivery.type) { + case "http": + return { ...delivery, headers: delivery.headers ?? {} }; + case "otlphttp": + return { ...delivery, headers: delivery.headers ?? {} }; + case "s3": + return delivery; + } +}; + +const deliverySecret = ( + delivery: DrainDelivery | undefined, +): string | undefined => + delivery !== undefined && delivery.type !== "s3" + ? delivery.secret + : undefined; + +/** + * Canonical JSON for config comparison: recursively sorts object keys and + * drops `undefined` members so `{a: undefined}` ≡ `{}` and key order never + * produces a phantom diff. + */ +const canonical = (value: unknown): string => + JSON.stringify(sortValue(value)) ?? "undefined"; + +const sortValue = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(sortValue); + if (value !== null && typeof value === "object") { + const record = value as Record; + const out: Record = {}; + for (const key of Object.keys(record).sort()) { + if (record[key] !== undefined) out[key] = sortValue(record[key]); + } + return out; + } + return value; +}; + +/** Shallow-copy an object dropping the given keys (undefined stays undefined). */ +const omitKeys = ( + value: unknown, + keys: ReadonlyArray, +): Record | undefined => { + if (value === null || value === undefined || typeof value !== "object") { + return undefined; + } + const out: Record = { + ...(value as Record), + }; + for (const key of keys) delete out[key]; + return out; +}; diff --git a/packages/alchemy/src/Vercel/EdgeCache/Purge.ts b/packages/alchemy/src/Vercel/EdgeCache/Purge.ts new file mode 100644 index 0000000000..eb193396b2 --- /dev/null +++ b/packages/alchemy/src/Vercel/EdgeCache/Purge.ts @@ -0,0 +1,139 @@ +/** + * Edge-cache purge runtime actions — plain Effects, not resources. + * + * Vercel's edge cache is purged per project, either by cache tag or by + * source image. Purging is a traffic operation over already-deployed + * content, so it is modeled like promote/rollback: an action you run, not + * state the engine converges. + * + * Two flavors exist per key: + * - **invalidate** — marks entries stale; they are revalidated in the + * background on the next request (stale content may be served once while + * revalidating). Safe default. + * - **dangerouslyDelete** — drops entries outright; the next request blocks + * on the origin. One tag or source image can map to many paths, so a + * delete can stampede the origin. Prefer invalidate. + */ +import * as edgeCache from "@distilled.cloud/vercel/edge_cache"; +import * as Effect from "effect/Effect"; +import { VercelEnvironment } from "../VercelEnvironment.ts"; + +/** + * The project a purge targets — a resource carrying `projectId` (e.g. a + * `Vercel.Project`'s or `Vercel.Function`'s attributes) or a plain project + * id/name. + */ +export type PurgeTarget = { projectId: string } | string; + +const resolveProjectId = (target: PurgeTarget) => + typeof target === "string" ? target : target.projectId; + +export interface InvalidateEdgeCacheOptions { + /** + * The deployment target whose cache to purge. + * + * @default "production" + */ + target?: string; +} + +/** + * Invalidate edge-cache entries by cache tag: entries are marked stale and + * revalidated in the background on the next request. + * + * ```typescript + * yield* Vercel.invalidateEdgeCacheByTags(fn, ["products", "pricing"]); + * ``` + */ +export const invalidateEdgeCacheByTags = ( + target: PurgeTarget, + tags: string | ReadonlyArray, + options?: InvalidateEdgeCacheOptions, +) => + Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + yield* edgeCache.invalidateByTags({ + projectIdOrName: resolveProjectId(target), + tags: typeof tags === "string" ? tags : [...tags], + ...(options?.target !== undefined ? { target: options.target } : {}), + teamId, + }); + }); + +/** + * Invalidate edge-cache entries by source image (ISR/image-optimization + * cache): entries are marked stale and revalidated in the background. + * + * ```typescript + * yield* Vercel.invalidateEdgeCacheBySrcImages(fn, [ + * "https://acme.com/hero.png", + * ]); + * ``` + */ +export const invalidateEdgeCacheBySrcImages = ( + target: PurgeTarget, + srcImages: ReadonlyArray, +) => + Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + yield* edgeCache.invalidateBySrcImages({ + projectIdOrName: resolveProjectId(target), + srcImages: [...srcImages], + teamId, + }); + }); + +export interface DeleteEdgeCacheOptions extends InvalidateEdgeCacheOptions { + /** + * Spread foreground revalidation over this many seconds to soften the + * origin stampede a hard delete can cause. + */ + revalidationDeadlineSeconds?: number; +} + +/** + * Hard-delete edge-cache entries by cache tag. The next request for each + * affected path blocks on the origin — one tag can map to many paths, so + * this can stampede the origin. Prefer {@link invalidateEdgeCacheByTags}. + * + * ```typescript + * yield* Vercel.dangerouslyDeleteEdgeCacheByTags(fn, "catalog", { + * revalidationDeadlineSeconds: 60, + * }); + * ``` + */ +export const dangerouslyDeleteEdgeCacheByTags = ( + target: PurgeTarget, + tags: string | ReadonlyArray, + options?: DeleteEdgeCacheOptions, +) => + Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + yield* edgeCache.dangerouslyDeleteByTags({ + projectIdOrName: resolveProjectId(target), + tags: typeof tags === "string" ? tags : [...tags], + ...(options?.target !== undefined ? { target: options.target } : {}), + ...(options?.revalidationDeadlineSeconds !== undefined + ? { revalidationDeadlineSeconds: options.revalidationDeadlineSeconds } + : {}), + teamId, + }); + }); + +/** + * Hard-delete edge-cache entries by source image. The affected cache + * entries are revalidated in the foreground on the next request. Prefer + * {@link invalidateEdgeCacheBySrcImages}. + */ +export const dangerouslyDeleteEdgeCacheBySrcImages = ( + target: PurgeTarget, + srcImages: ReadonlyArray, +) => + Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + yield* edgeCache.dangerouslyDeleteBySrcImages({ + projectIdOrName: resolveProjectId(target), + srcImages: [...srcImages], + teamId, + }); + }); diff --git a/packages/alchemy/src/Vercel/EdgeConfig/EdgeConfig.ts b/packages/alchemy/src/Vercel/EdgeConfig/EdgeConfig.ts new file mode 100644 index 0000000000..53454585f7 --- /dev/null +++ b/packages/alchemy/src/Vercel/EdgeConfig/EdgeConfig.ts @@ -0,0 +1,369 @@ +import * as globalConfig from "@distilled.cloud/vercel/global_config"; +import * as Effect from "effect/Effect"; +import { Unowned } from "../../AdoptPolicy.ts"; +import type { ScopedPlanStatusSession } from "../../Cli/Cli.ts"; +import { createPhysicalName } from "../../PhysicalName.ts"; +import * as Provider from "../../Provider.ts"; +import { Resource } from "../../Resource.ts"; +import { stableStringify } from "../../Util/stable.ts"; +import type { Providers } from "../Providers.ts"; +import { VercelEnvironment } from "../VercelEnvironment.ts"; + +export interface EdgeConfigProps { + /** + * Slug (name) of the Edge Config. If omitted, a unique slug is generated + * from the app, stage, and logical ID. Must start with an alphabetic + * character and contain only alphanumeric characters, underscores, and + * hyphens (max 64 characters). Renaming is applied in place (no + * replacement) — Edge Config slugs are not unique identifiers. + */ + slug?: string; + /** + * Declarative item set for the Edge Config. When set, the provider + * converges the config's items to exactly this map — adding, updating, + * and removing items as needed (drift written out-of-band is reverted on + * the next deploy). When omitted, items are left unmanaged. + * + * Values may be any JSON value (string, number, boolean, null, object, + * array). + */ + items?: Record; + /** + * Optional JSON Schema enforced by Vercel on item writes. When set, the + * provider keeps the config's schema in sync with this definition. When + * omitted after previously being set, the schema is deleted. + */ + schema?: Record; +} + +export interface EdgeConfig extends Resource< + "Vercel.EdgeConfig", + EdgeConfigProps, + { + /** + * Unique ID of the Edge Config (e.g. `ecfg_…`). Used in the data-plane + * connection string `https://edge-config.vercel.com/{edgeConfigId}`. + */ + edgeConfigId: string; + /** + * Slug (name) of the Edge Config. + */ + slug: string; + /** + * Content digest — changes whenever the items change. + */ + digest: string; + /** + * Creation timestamp (ms since epoch). + */ + createdAt: number; + /** + * ID of the owning team or user. + */ + ownerId: string; + /** + * Number of items currently stored. + */ + itemCount: number; + /** + * Total size of the stored items in bytes. + */ + sizeInBytes: number; + }, + never, + Providers +> {} + +type EdgeConfigAttributes = EdgeConfig["Attributes"]; + +/** + * A Vercel Edge Config (Global Config) — an ultra-low-latency key-value + * store replicated to Vercel's edge network, ideal for feature flags, + * A/B testing configuration, and dynamic redirects. + * + * Items declared on the resource are managed declaratively: the provider + * diffs the observed items against the declared map on every deploy and + * applies only the delta, so out-of-band drift converges back to the + * declared state. Reads at runtime go through the data plane + * (`https://edge-config.vercel.com/{edgeConfigId}`) with a read token + * minted via the Vercel API. + * @resource + * @section Creating an Edge Config + * @example Basic Edge Config + * ```typescript + * import * as Vercel from "alchemy/Vercel"; + * + * const flags = yield* Vercel.EdgeConfig("flags", {}); + * ``` + * + * @example Edge Config with declarative items + * ```typescript + * const flags = yield* Vercel.EdgeConfig("flags", { + * items: { + * enableCheckout: true, + * bannerText: "Hello!", + * limits: { maxItems: 10 }, + * }, + * }); + * ``` + * + * @example Edge Config with an explicit slug + * ```typescript + * const flags = yield* Vercel.EdgeConfig("flags", { + * slug: "my_feature_flags", + * }); + * ``` + * + * @section Enforcing a schema + * @example Validate items with JSON Schema + * ```typescript + * const flags = yield* Vercel.EdgeConfig("flags", { + * schema: { + * type: "object", + * properties: { + * enableCheckout: { type: "boolean" }, + * }, + * }, + * items: { enableCheckout: false }, + * }); + * ``` + * + * @see https://vercel.com/docs/edge-config + */ +export const EdgeConfig = Resource("Vercel.EdgeConfig"); + +export const EdgeConfigProvider = () => + Provider.succeed(EdgeConfig, { + stables: ["edgeConfigId", "createdAt", "ownerId"], + list: Effect.fn(function* () { + const { teamId } = yield* VercelEnvironment.current; + const configs = yield* globalConfig.getEdgeConfigs({ teamId }); + return configs.map(toEdgeConfigAttributes); + }), + read: Effect.fn(function* ({ id, output, olds }) { + const { teamId } = yield* VercelEnvironment.current; + + // Owned path: refresh by the persisted Edge Config id. + if (output?.edgeConfigId) { + return yield* globalConfig + .getEdgeConfig({ edgeConfigId: output.edgeConfigId, teamId }) + .pipe( + Effect.map(toEdgeConfigAttributes), + Effect.catchTag("NotFound", () => Effect.succeed(undefined)), + ); + } + + // Cold read: no persisted id (state-persistence failure). Match on + // slug via the list API. Edge Configs carry no ownership markers + // (no tags, no env), so we cannot prove we created a match — brand + // it `Unowned` so the engine gates takeover behind adoption. + const slug = yield* createSlug(id, olds?.slug); + const configs = yield* globalConfig.getEdgeConfigs({ teamId }); + const match = configs.find((c) => c.slug === slug); + if (match) return Unowned(toEdgeConfigAttributes(match)); + return undefined; + }), + reconcile: Effect.fn(function* ({ id, news = {}, olds, output, session }) { + const { teamId } = yield* VercelEnvironment.current; + + // Auto-generated slugs are engine-owned: prefer the deployed slug so + // generator drift never renames an existing config. Only an explicit + // `news.slug` forces a rename. + const desiredSlug = + news.slug ?? output?.slug ?? (yield* createSlug(id, undefined)); + + // 1. Observe — the cached id is a hint, not a guarantee. + let observed = output?.edgeConfigId + ? yield* globalConfig + .getEdgeConfig({ edgeConfigId: output.edgeConfigId, teamId }) + .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))) + : undefined; + + // 2. Ensure — create when missing. Slugs are not unique on Vercel, so + // there is no AlreadyExists race to tolerate; the API happily + // creates and returns the new config. + if (observed === undefined) { + const created = yield* globalConfig.createEdgeConfig({ + slug: desiredSlug, + teamId, + }); + yield* session.note(`Created Edge Config: ${created.slug}`); + observed = yield* globalConfig.getEdgeConfig({ + edgeConfigId: created.id, + teamId, + }); + } + const edgeConfigId = observed.id; + + // 3. Sync slug — rename in place when the observed slug differs. + if (observed.slug !== desiredSlug) { + yield* globalConfig.updateEdgeConfig({ + edgeConfigId, + slug: desiredSlug, + teamId, + }); + yield* session.note( + `Renamed Edge Config: ${observed.slug} -> ${desiredSlug}`, + ); + } + + // 4. Sync schema before items so item writes validate against the + // declared schema. Observed baseline comes from the schema + // endpoint (adoption-safe); `olds` is only a hint that we + // previously managed a schema and should delete it when + // undeclared. + yield* syncSchema({ + edgeConfigId, + teamId, + desired: news.schema, + oldsHadSchema: olds?.schema !== undefined, + session, + }); + + // 5. Sync items — diff observed (management API, strongly + // consistent) against the declared map and apply only the delta. + yield* syncItems({ + edgeConfigId, + teamId, + desired: news.items, + session, + }); + + // 6. Return fresh attributes (digest/counters reflect the syncs). + const final = yield* globalConfig.getEdgeConfig({ edgeConfigId, teamId }); + return toEdgeConfigAttributes(final); + }), + delete: Effect.fn(function* ({ output, session }) { + const { teamId } = yield* VercelEnvironment.current; + yield* globalConfig + .deleteEdgeConfig({ edgeConfigId: output.edgeConfigId, teamId }) + .pipe(Effect.catchTag("NotFound", () => Effect.void)); + yield* session.note(`Deleted Edge Config: ${output.slug}`); + }), + }); + +const createSlug = (id: string, slug: string | undefined) => + Effect.gen(function* () { + // Edge Config slugs allow [A-Za-z0-9_-] and max 64 chars; the physical + // name generator's alphanumeric+hyphen output fits. + return slug ?? (yield* createPhysicalName({ id, maxLength: 64 })); + }); + +const toEdgeConfigAttributes = (config: { + id: string; + slug: string; + digest: string; + createdAt: number; + ownerId: string; + itemCount: number; + sizeInBytes: number; +}): EdgeConfigAttributes => ({ + edgeConfigId: config.id, + slug: config.slug, + digest: config.digest, + createdAt: config.createdAt, + ownerId: config.ownerId, + itemCount: config.itemCount, + sizeInBytes: config.sizeInBytes, +}); + +/** + * Read the currently-stored schema definition. The endpoint answers + * `{ definition: … }` when a schema is set and an empty 204 (decoded as + * `{}`) when none is — both collapse into `definition | undefined`. + */ +const observeSchema = (edgeConfigId: string, teamId: string | undefined) => + globalConfig.getEdgeConfigSchema({ edgeConfigId, teamId }).pipe( + Effect.map((response) => + typeof response === "object" && + response !== null && + "definition" in response + ? (response as { definition: unknown }).definition + : undefined, + ), + Effect.catchTag("NotFound", () => Effect.succeed(undefined)), + ); + +const syncSchema = Effect.fn(function* ({ + edgeConfigId, + teamId, + desired, + oldsHadSchema, + session, +}: { + edgeConfigId: string; + teamId: string | undefined; + desired: Record | undefined; + oldsHadSchema: boolean; + session: ScopedPlanStatusSession; +}) { + const observedSchema = yield* observeSchema(edgeConfigId, teamId); + if (desired !== undefined) { + if (stableStringify(observedSchema) === stableStringify(desired)) return; + yield* globalConfig.patchEdgeConfigSchema({ + edgeConfigId, + teamId, + definition: desired, + }); + yield* session.note(`Updated Edge Config schema: ${edgeConfigId}`); + return; + } + // Undeclared: only delete a schema we previously declared ourselves — + // an adopted config's foreign schema is left untouched. + if (oldsHadSchema && observedSchema !== undefined) { + yield* globalConfig + .deleteEdgeConfigSchema({ edgeConfigId, teamId }) + .pipe(Effect.catchTag("NotFound", () => Effect.void)); + yield* session.note(`Removed Edge Config schema: ${edgeConfigId}`); + } +}); + +const syncItems = Effect.fn(function* ({ + edgeConfigId, + teamId, + desired, + session, +}: { + edgeConfigId: string; + teamId: string | undefined; + desired: Record | undefined; + session: ScopedPlanStatusSession; +}) { + // Items undeclared — leave whatever is stored unmanaged. + if (desired === undefined) return; + + // Observe via the management API (strongly consistent, unlike the + // eventually-consistent data plane). + const observed = yield* globalConfig.getEdgeConfigItems({ + edgeConfigId, + teamId, + }); + const observedByKey = new Map(observed.map((item) => [item.key, item.value])); + + const ops: globalConfig.PatchEdgeConfigItemsRequestItemsItem[] = []; + for (const [key, value] of Object.entries(desired)) { + if (value === undefined) continue; + const current = observedByKey.get(key); + if ( + !observedByKey.has(key) || + stableStringify(current) !== stableStringify(value) + ) { + ops.push({ operation: "upsert", key, value }); + } + } + for (const key of observedByKey.keys()) { + if (!(key in desired) || desired[key] === undefined) { + ops.push({ operation: "delete", key }); + } + } + + if (ops.length === 0) return; + yield* globalConfig.patchEdgeConfigItems({ + edgeConfigId, + teamId, + items: ops, + }); + yield* session.note( + `Synced ${ops.length} Edge Config item(s): ${edgeConfigId}`, + ); +}); diff --git a/packages/alchemy/src/Vercel/EdgeConfig/EdgeConfigRead.ts b/packages/alchemy/src/Vercel/EdgeConfig/EdgeConfigRead.ts new file mode 100644 index 0000000000..69f17f2406 --- /dev/null +++ b/packages/alchemy/src/Vercel/EdgeConfig/EdgeConfigRead.ts @@ -0,0 +1,478 @@ +import * as EdgeConfigData from "@distilled.cloud/vercel/edge_config_data"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import * as Result from "effect/Result"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import * as Binding from "../../Binding.ts"; +import { unpackEnvValue, type RuntimeContext } from "../../RuntimeContext.ts"; +import type { EdgeConfig } from "./EdgeConfig.ts"; + +/** + * A data-plane read against the Edge Config failed. Reads go to + * `https://edge-config.vercel.com/{edgeConfigId}` with bearer auth via the + * distilled `edge_config_data` service; the protocol contract + * (probe-verified against `@vercel/edge-config@1.5.1`): a missing ITEM is a + * 404 *with* an `x-edge-config-digest` header (the typed + * `EdgeConfigItemNotFound`, which `get`/`has` absorb), while a bare 404 + * means the config itself (or the token) is invalid. Distilled's typed + * errors are re-wrapped into this public error at the client boundary; the + * original typed error rides `cause`. + */ +export class EdgeConfigReadError extends Data.TaggedError( + "Vercel.EdgeConfigReadError", +)<{ + readonly message: string; + /** The operation that failed (`get` | `getAll` | `has` | `digest`). */ + readonly operation: string; + /** HTTP status of the failing response, when one was received. */ + readonly status?: number | undefined; + readonly cause?: unknown; +}> {} + +/** + * Typed read client for an {@link EdgeConfig} — the same surface as + * `@vercel/edge-config` (`get`/`getAll`/`has`/`digest`), returned by the + * {@link EdgeConfigRead} binding. Every method requires + * {@link RuntimeContext}: reads resolve the connection string minted for + * the deployed Function. + */ +export interface ReadEdgeConfigClient { + /** Read a single item; `undefined` when the key does not exist. */ + get( + key: string, + ): Effect.Effect; + /** Read all items (or the given subset of keys) as a record. */ + getAll = Record>( + keys?: readonly string[], + ): Effect.Effect; + /** `true` when the key exists. */ + has(key: string): Effect.Effect; + /** The config's content digest — changes whenever any item changes. */ + digest(): Effect.Effect; +} + +/** + * Bind an {@link EdgeConfig} to a Function with read access and obtain the + * typed data-plane client (`get`, `getAll`, `has`, `digest`). + * + * The implementation layer ({@link ReadEdgeConfigHttp}) mints a scoped + * {@link EdgeConfigToken} per Function (observe-and-keep — never rotated + * while it exists) and injects the connection string as a `sensitive` + * project env var; the runtime client follows that connection string + * against `https://edge-config.vercel.com`. + * + * @binding + * @section Reading an Edge Config + * @example Feature flags inside an Effect-native Function + * ```typescript + * import * as Vercel from "alchemy/Vercel"; + * import * as Effect from "effect/Effect"; + * import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; + * + * export default class Api extends Vercel.Function()( + * "Api", + * { main: import.meta.url }, + * Effect.gen(function* () { + * const flags = yield* Vercel.EdgeConfig("Flags", { + * items: { enableCheckout: true }, + * }); + * const config = yield* Vercel.ReadEdgeConfig(flags); + * return { + * fetch: Effect.gen(function* () { + * const enabled = yield* config + * .get("enableCheckout") + * .pipe(Effect.orDie); + * return yield* HttpServerResponse.json({ enabled }); + * }), + * }; + * }).pipe(Effect.provide(Vercel.ReadEdgeConfigHttp)), + * ) {} + * ``` + * + * @example Reading multiple items and the digest + * ```typescript + * const all = yield* config.getAll().pipe(Effect.orDie); + * const some = yield* config.getAll(["a", "b"]).pipe(Effect.orDie); + * const exists = yield* config.has("a").pipe(Effect.orDie); + * const digest = yield* config.digest().pipe(Effect.orDie); + * ``` + */ +export interface EdgeConfigRead extends Binding.Service< + EdgeConfigRead, + "Vercel.EdgeConfigRead", + (config: EdgeConfig) => Effect.Effect +> {} + +export const EdgeConfigRead = Binding.Service( + "Vercel.EdgeConfigRead", +); + +/** + * Ergonomic alias for {@link EdgeConfigRead} — + * `yield* Vercel.ReadEdgeConfig(flags)`. + */ +export const ReadEdgeConfig = EdgeConfigRead; + +// ───────────────────────────────────────────────────────────────────────────── +// Connection strings +// ───────────────────────────────────────────────────────────────────────────── + +export interface EdgeConfigConnection { + /** Data-plane base URL (`https://edge-config.vercel.com/{edgeConfigId}`). */ + readonly baseUrl: string; + /** The read token (the `token` query parameter). */ + readonly token: string; +} + +/** + * Parse an Edge Config connection string + * (`https://edge-config.vercel.com/{edgeConfigId}?token={token}`) into its + * base URL + token. Throws a plain `Error` on malformed input (callers in + * Effect code wrap it in `Effect.try`). + */ +export const parseEdgeConfigConnectionString = ( + connectionString: string, +): EdgeConfigConnection => { + let url: URL; + try { + url = new URL(connectionString); + } catch { + throw new Error( + "Invalid Edge Config connection string: expected https://edge-config.vercel.com/{edgeConfigId}?token={token}", + ); + } + const token = url.searchParams.get("token"); + if (token === null || token === "" || url.pathname.length <= 1) { + throw new Error( + "Invalid Edge Config connection string: expected https://edge-config.vercel.com/{edgeConfigId}?token={token}", + ); + } + return { baseUrl: `${url.origin}${url.pathname}`, token }; +}; + +/** + * The per-call inputs the distilled `edge_config_data` operations take: + * the connection string's origin IS the endpoint (local emulation hands out + * `http://localhost:` origins), the pathname is the config id. + */ +interface EdgeConfigCallTarget { + readonly origin: string; + readonly edgeConfigId: string; + readonly token: string; +} + +const toCallTarget = ( + connection: EdgeConfigConnection, +): EdgeConfigCallTarget => { + const url = new URL(connection.baseUrl); + return { + origin: url.origin, + edgeConfigId: url.pathname.slice(1), + token: connection.token, + }; +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Effect client +// ───────────────────────────────────────────────────────────────────────────── + +/** Every error a distilled Edge Config read operation can produce. */ +type EdgeConfigDataError = + | EdgeConfigData.GetEdgeConfigItemError + | EdgeConfigData.GetEdgeConfigItemsError + | EdgeConfigData.HasEdgeConfigItemError + | EdgeConfigData.GetEdgeConfigDigestError; + +/** + * HTTP statuses of the typed distilled error tags — the public + * {@link EdgeConfigReadError} keeps its `status` field even though the + * generated errors carry the status in their tag instead. + */ +const READ_ERROR_STATUS: { readonly [tag: string]: number | undefined } = { + EdgeConfigItemNotFound: 404, + EdgeConfigNotFound: 404, + EdgeConfigUnauthorized: 401, + BadRequest: 400, + Unauthorized: 401, + PaymentRequired: 402, + Forbidden: 403, + NotFound: 404, + Conflict: 409, + Gone: 410, + UnprocessableEntity: 422, + Locked: 423, + TooManyRequests: 429, + InternalServerError: 500, + BadGateway: 502, + ServiceUnavailable: 503, + GatewayTimeout: 504, +}; + +const statusOf = (cause: EdgeConfigDataError): number | undefined => + cause._tag === "HttpClientError" + ? cause.response?.status + : READ_ERROR_STATUS[cause._tag]; + +/** + * Re-wrap a typed distilled error into the public + * {@link EdgeConfigReadError} (boundary contract: the client's error + * surface is unchanged; the typed error rides `cause`). + */ +const wrapReadError = + (operation: string) => + (cause: EdgeConfigDataError): EdgeConfigReadError => + new EdgeConfigReadError({ + message: `Edge Config ${operation} failed: ${cause.message}`, + operation, + status: statusOf(cause), + cause, + }); + +const readFailure = ( + operation: string, + status: number | undefined, + message: string, + cause?: unknown, +): EdgeConfigReadError => + new EdgeConfigReadError({ message, operation, status, cause }); + +/** + * Build a {@link ReadEdgeConfigClient} from a connection string (a plain + * value or an accessor Effect — the {@link ReadEdgeConfigHttp} binding + * passes the env-injected accessor). The `HttpClient` is captured once at + * construction; every read goes through the distilled `edge_config_data` + * operations (typed errors, per-call origin + bearer token). + */ +export const makeReadEdgeConfigClient = ( + connection: + | string + | Redacted.Redacted + | Effect.Effect | undefined>, +): Effect.Effect => + Effect.gen(function* () { + const context = yield* Effect.context(); + + const resolveConnection: Effect.Effect< + EdgeConfigCallTarget, + EdgeConfigReadError + > = Effect.gen(function* () { + const raw = Effect.isEffect(connection) ? yield* connection : connection; + const value = Redacted.isRedacted(raw) ? Redacted.value(raw) : raw; + if (value === undefined || value === "") { + return yield* readFailure( + "connect", + undefined, + "Edge Config connection string is not available — was the EdgeConfigRead binding deployed with this Function?", + ); + } + return yield* Effect.try({ + try: () => toCallTarget(parseEdgeConfigConnectionString(value)), + catch: (cause) => + readFailure( + "connect", + undefined, + cause instanceof Error ? cause.message : String(cause), + cause, + ), + }); + }); + + const get = ( + key: string, + ): Effect.Effect => + resolveConnection.pipe( + Effect.flatMap(({ origin, edgeConfigId, token }) => + EdgeConfigData.getEdgeConfigItem({ + origin, + token, + edgeConfigId, + key, + version: "1", + }).pipe( + Effect.map((value) => value as T | undefined), + Effect.catchTag("EdgeConfigItemNotFound", () => + Effect.succeed(undefined), + ), + Effect.mapError(wrapReadError("get")), + ), + ), + Effect.provideContext(context), + ); + + const getAll = < + T extends Record = Record, + >( + keys?: readonly string[], + ): Effect.Effect => + resolveConnection.pipe( + Effect.flatMap(({ origin, edgeConfigId, token }) => + EdgeConfigData.getEdgeConfigItems({ + origin, + token, + edgeConfigId, + version: "1", + ...(keys === undefined ? {} : { keys: [...keys] }), + }).pipe( + Effect.map((items) => items as T), + Effect.mapError(wrapReadError("getAll")), + ), + ), + Effect.provideContext(context), + ); + + const has = (key: string): Effect.Effect => + resolveConnection.pipe( + Effect.flatMap(({ origin, edgeConfigId, token }) => + EdgeConfigData.hasEdgeConfigItem({ + origin, + token, + edgeConfigId, + key, + version: "1", + }).pipe( + Effect.as(true), + Effect.catchTag("EdgeConfigItemNotFound", () => + Effect.succeed(false), + ), + Effect.mapError(wrapReadError("has")), + ), + ), + Effect.provideContext(context), + ); + + const digest = (): Effect.Effect => + resolveConnection.pipe( + Effect.flatMap(({ origin, edgeConfigId, token }) => + EdgeConfigData.getEdgeConfigDigest({ + origin, + token, + edgeConfigId, + version: "1", + }).pipe(Effect.mapError(wrapReadError("digest"))), + ), + Effect.provideContext(context), + ); + + return { get, getAll, has, digest } satisfies ReadEdgeConfigClient; + }); + +// ───────────────────────────────────────────────────────────────────────────── +// Async mode (no Effect runtime) +// ───────────────────────────────────────────────────────────────────────────── + +/** Promise-based Edge Config read client for plain async Functions. */ +export interface AsyncReadEdgeConfigClient { + get(key: string): Promise; + getAll = Record>( + keys?: readonly string[], + ): Promise; + has(key: string): Promise; + digest(): Promise; +} + +/** + * `fromEnv`-style constructor for **plain async Functions**: resolves the + * connection string from the given env var at call time (handling both raw + * connection strings and alchemy's marker-packed sensitive values) and + * reads via the distilled `edge_config_data` operations, surfacing failures + * as plain `Error`s (the typed distilled error rides `cause`). For Effect + * code use {@link ReadEdgeConfig} (bound) or + * {@link makeReadEdgeConfigClient}. + * + * @example Async handler + * ```typescript + * import { readEdgeConfigFromEnv } from "alchemy/Vercel"; + * const flags = readEdgeConfigFromEnv("FLAGS"); + * + * export default { + * async fetch(): Promise { + * return Response.json({ + * checkout: await flags.get("enableCheckout"), + * }); + * }, + * }; + * ``` + */ +export const readEdgeConfigFromEnv = ( + envKey = "EDGE_CONFIG", +): AsyncReadEdgeConfigClient => { + const connect = (): EdgeConfigCallTarget => { + const unpacked = unpackEnvValue(process.env[envKey]); + const value = Redacted.isRedacted(unpacked) + ? (Redacted.value(unpacked) as string) + : unpacked; + if (typeof value !== "string" || value === "") { + throw new Error( + `readEdgeConfigFromEnv(${envKey}): env var is not set — bind an EdgeConfigToken connection string to it`, + ); + } + return toCallTarget(parseEdgeConfigConnectionString(value)); + }; + const run = async ( + operation: string, + effect: Effect.Effect, + ): Promise => { + const result = await Effect.runPromise( + Effect.result(effect).pipe(Effect.provide(FetchHttpClient.layer)), + ); + if (Result.isFailure(result)) { + const cause = result.failure; + const status = statusOf(cause); + throw new Error( + `readEdgeConfigFromEnv(${envKey}).${operation}: ${ + status !== undefined ? `HTTP ${status}: ` : "" + }${cause.message}`, + { cause }, + ); + } + return result.success; + }; + // Methods are `async` so a synchronous `connect()` failure (missing env + // var, malformed connection string) surfaces as a rejection, not a throw. + return { + get: async (key) => + run( + "get", + EdgeConfigData.getEdgeConfigItem({ + ...connect(), + key, + version: "1", + }).pipe( + Effect.map((value) => value as never), + Effect.catchTag("EdgeConfigItemNotFound", () => + Effect.succeed(undefined as never), + ), + ), + ), + getAll: async (keys) => + run( + "getAll", + EdgeConfigData.getEdgeConfigItems({ + ...connect(), + version: "1", + ...(keys === undefined ? {} : { keys: [...keys] }), + }).pipe(Effect.map((items) => items as never)), + ), + has: async (key) => + run( + "has", + EdgeConfigData.hasEdgeConfigItem({ + ...connect(), + key, + version: "1", + }).pipe( + Effect.as(true), + Effect.catchTag("EdgeConfigItemNotFound", () => + Effect.succeed(false), + ), + ), + ), + digest: async () => + run( + "digest", + EdgeConfigData.getEdgeConfigDigest({ ...connect(), version: "1" }), + ), + }; +}; diff --git a/packages/alchemy/src/Vercel/EdgeConfig/EdgeConfigToken.ts b/packages/alchemy/src/Vercel/EdgeConfig/EdgeConfigToken.ts new file mode 100644 index 0000000000..5be1581418 --- /dev/null +++ b/packages/alchemy/src/Vercel/EdgeConfig/EdgeConfigToken.ts @@ -0,0 +1,330 @@ +import * as globalConfig from "@distilled.cloud/vercel/global_config"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import type { ScopedPlanStatusSession } from "../../Cli/Cli.ts"; +import { isResolved } from "../../Diff.ts"; +import type { Input } from "../../Input.ts"; +import { createPhysicalName } from "../../PhysicalName.ts"; +import * as Provider from "../../Provider.ts"; +import { Resource } from "../../Resource.ts"; +import type { Providers } from "../Providers.ts"; +import { VercelEnvironment } from "../VercelEnvironment.ts"; + +export interface EdgeConfigTokenProps { + /** + * ID of the Edge Config the token grants read access to (e.g. `ecfg_…`). + * Changing it replaces the token. + */ + edgeConfigId: string; + /** + * Human-readable label for the token (shown in the Vercel dashboard). + * If omitted, a unique label is generated from the app, stage, and + * logical ID. Tokens are immutable on Vercel, so changing the label + * mints a fresh token (and deletes the old one). + */ + label?: string; +} + +export interface EdgeConfigToken extends Resource< + "Vercel.EdgeConfigToken", + EdgeConfigTokenProps, + { + /** + * ID of the token — used to reference (e.g. delete) the token; NOT the + * secret itself. + */ + tokenId: string; + /** + * ID of the Edge Config the token reads. + */ + edgeConfigId: string; + /** + * The token's label. + */ + label: string; + /** + * Creation timestamp (ms since epoch). + */ + createdAt: number; + /** + * The plaintext token value. Vercel returns this only once, on + * creation, so it is persisted here (observe-and-keep — the token is + * never re-minted while it still exists on Vercel). + */ + token: Redacted.Redacted; + /** + * The Edge Config connection string + * (`https://edge-config.vercel.com/{edgeConfigId}?token={token}`) — + * the exact shape `@vercel/edge-config` and + * {@link readEdgeConfigFromEnv} accept. + */ + connectionString: Redacted.Redacted; + }, + never, + Providers +> {} + +type EdgeConfigTokenAttributes = EdgeConfigToken["Attributes"]; + +/** + * A read token for a Vercel Edge Config — grants data-plane read access + * (`https://edge-config.vercel.com/{edgeConfigId}`) scoped to a single + * Edge Config. + * + * Vercel discloses the plaintext token exactly once, at creation, so the + * provider persists it in state and *observes-and-keeps*: as long as the + * token still exists on Vercel, subsequent deploys reuse the same value + * (no churn in dependent env vars). The {@link ReadEdgeConfigHttp} binding + * mints one of these per Function automatically — declare it explicitly + * only for async-mode Functions or external consumers. + * @resource + * @section Creating a Read Token + * @example Token for an Edge Config + * ```typescript + * import * as Vercel from "alchemy/Vercel"; + * + * const flags = yield* Vercel.EdgeConfig("flags", { + * items: { enableCheckout: true }, + * }); + * const token = yield* Vercel.EdgeConfigToken("flags-token", { + * edgeConfigId: flags.edgeConfigId, + * }); + * ``` + * + * @section Async Functions + * @example Bind the connection string into an async Function's env + * ```typescript + * const fn = yield* Vercel.Function("Api", { + * main: "./src/api.ts", + * env: { + * // Redacted ⇒ synced as a `sensitive` project env var + * FLAGS: token.connectionString, + * }, + * }); + * + * // src/api.ts + * import { readEdgeConfigFromEnv } from "alchemy/Vercel"; + * const flags = readEdgeConfigFromEnv("FLAGS"); + * const enabled = await flags.get("enableCheckout"); + * ``` + * + * @see https://vercel.com/docs/edge-config/vercel-api#tokens + */ +export const EdgeConfigToken = Resource( + "Vercel.EdgeConfigToken", +); + +/** The data-plane connection string for an Edge Config + read token. */ +export const edgeConfigConnectionString = ( + edgeConfigId: string, + token: string, +): string => `https://edge-config.vercel.com/${edgeConfigId}?token=${token}`; + +const createLabel = (id: string, label: string | undefined) => + Effect.gen(function* () { + // Token labels are display-only; keep them comfortably short. + return label ?? (yield* createPhysicalName({ id, maxLength: 52 })); + }); + +const toTokenAttributes = ( + meta: { + id: string; + edgeConfigId: string; + label: string; + createdAt: number; + }, + token: Redacted.Redacted, +): EdgeConfigTokenAttributes => ({ + tokenId: meta.id, + edgeConfigId: meta.edgeConfigId, + label: meta.label, + createdAt: meta.createdAt, + token, + connectionString: Redacted.make( + edgeConfigConnectionString(meta.edgeConfigId, Redacted.value(token)), + ), +}); + +/** + * Observe the token by its persisted plaintext (the detail endpoint + * addresses tokens by value — the list endpoint never returns plaintext, so + * the persisted secret is the only usable lookup key). + */ +const observeToken = ( + edgeConfigId: string, + token: Redacted.Redacted, + teamId: string | undefined, +) => + globalConfig + .getEdgeConfigToken({ + edgeConfigId, + token: Redacted.value(token), + teamId, + }) + .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))); + +const mintToken = Effect.fn(function* ({ + edgeConfigId, + label, + teamId, + session, +}: { + edgeConfigId: string; + label: string; + teamId: string | undefined; + session: ScopedPlanStatusSession; +}) { + const created = yield* globalConfig.createEdgeConfigToken({ + edgeConfigId, + label, + teamId, + }); + yield* session.note(`Created Edge Config token: ${label}`); + // The create response carries only { id, token } — read the full + // metadata (createdAt) back through the detail endpoint. + const meta = yield* globalConfig.getEdgeConfigToken({ + edgeConfigId, + token: created.token, + teamId, + }); + return toTokenAttributes(meta, Redacted.make(created.token)); +}); + +/** + * The live lifecycle implementation of {@link EdgeConfigToken} — extracted + * so the dev-mode provider (`LocalEdgeConfigProvider.ts`) can delegate to + * it when a token targets a REAL Edge Config (an `Alchemy.remote()` config + * bound during `alchemy dev`: the mixed local-Function/live-EdgeConfig + * stack still needs a real read token). + * + * @internal + */ +export const makeEdgeConfigTokenService = () => ({ + stables: ["edgeConfigId" as const], + diff: Effect.fn(function* ({ + olds, + news, + output, + }: { + olds: EdgeConfigTokenProps; + news: Input; + output: EdgeConfigTokenAttributes | undefined; + }) { + if (!isResolved(news)) return undefined; + // Moving the token to another Edge Config is a replacement (the new + // parent must exist before the old token dies); label changes are + // in-place updates (reconcile mints a successor + deletes the old). + const oldEdgeConfigId = output?.edgeConfigId ?? olds?.edgeConfigId; + if ( + oldEdgeConfigId !== undefined && + news.edgeConfigId !== oldEdgeConfigId + ) { + return { action: "replace" } as const; + } + }), + read: Effect.fn(function* ({ + output, + }: { + output: EdgeConfigTokenAttributes | undefined; + }) { + // Cold read is impossible: the plaintext (the only lookup key that + // returns full metadata) lives solely in our state. + if (output?.token === undefined) return undefined; + const { teamId } = yield* VercelEnvironment.current; + const observed = yield* observeToken( + output.edgeConfigId, + output.token, + teamId, + ); + if (observed === undefined) return undefined; + return toTokenAttributes(observed, output.token); + }), + reconcile: Effect.fn(function* ({ + id, + news = {}, + output, + session, + }: { + id: string; + news?: Partial | undefined; + output: EdgeConfigTokenAttributes | undefined; + session: ScopedPlanStatusSession; + }) { + const { teamId } = yield* VercelEnvironment.current; + const edgeConfigId = news.edgeConfigId ?? output?.edgeConfigId; + if (edgeConfigId === undefined) { + return yield* Effect.die( + `Vercel.EdgeConfigToken(${id}): \`edgeConfigId\` is required`, + ); + } + if (edgeConfigId.startsWith("dev:")) { + // A locally emulated (`dev:`) Edge Config reached the LIVE token + // lifecycle — e.g. an `Alchemy.remote()` Function binding a local + // config. Real Vercel cannot reach the local data plane. + return yield* Effect.die( + `Vercel.EdgeConfigToken(${id}): "${edgeConfigId}" is a local (dev) ` + + `Edge Config id — a live token cannot be minted for it. Pipe the ` + + `Edge Config through Alchemy.remote() too, or run the consuming ` + + `Function locally.`, + ); + } + const label = yield* createLabel(id, news.label); + + // 1. Observe — the persisted plaintext is a hint, not a guarantee. + const observed = + output?.token !== undefined && output.edgeConfigId === edgeConfigId + ? yield* observeToken(edgeConfigId, output.token, teamId) + : undefined; + + // 2. Converged: same token, same label — keep it (never re-mint; a + // rotation would invalidate the value baked into every dependent + // env row and older deployment). + if (observed !== undefined && observed.label === label) { + return toTokenAttributes(observed, output!.token); + } + + // 3. Label drift: tokens are immutable — mint the successor first, + // then retire the old token. + const attributes = yield* mintToken({ + edgeConfigId, + label, + teamId, + session, + }); + if (observed !== undefined) { + yield* globalConfig + .deleteEdgeConfigTokens({ + edgeConfigId, + ids: [observed.id], + teamId, + }) + .pipe(Effect.catchTag("NotFound", () => Effect.void)); + yield* session.note( + `Replaced Edge Config token: ${observed.label} -> ${label}`, + ); + } + return attributes; + }), + delete: Effect.fn(function* ({ + output, + session, + }: { + output: EdgeConfigTokenAttributes; + session: ScopedPlanStatusSession; + }) { + const { teamId } = yield* VercelEnvironment.current; + // Idempotent: a token already gone — or a parent Edge Config already + // deleted (which cascades its tokens) — reports NotFound. + yield* globalConfig + .deleteEdgeConfigTokens({ + edgeConfigId: output.edgeConfigId, + ids: [output.tokenId], + teamId, + }) + .pipe(Effect.catchTag("NotFound", () => Effect.void)); + yield* session.note(`Deleted Edge Config token: ${output.label}`); + }), +}); + +export const EdgeConfigTokenProvider = () => + Provider.succeed(EdgeConfigToken, makeEdgeConfigTokenService()); diff --git a/packages/alchemy/src/Vercel/EdgeConfig/EdgeConfigWrite.ts b/packages/alchemy/src/Vercel/EdgeConfig/EdgeConfigWrite.ts new file mode 100644 index 0000000000..1b3968fe7d --- /dev/null +++ b/packages/alchemy/src/Vercel/EdgeConfig/EdgeConfigWrite.ts @@ -0,0 +1,269 @@ +import { + DEFAULT_API_BASE_URL, + Credentials, +} from "@distilled.cloud/vercel/Credentials"; +import * as globalConfig from "@distilled.cloud/vercel/global_config"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import type * as Redacted from "effect/Redacted"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import * as Binding from "../../Binding.ts"; +import type { RuntimeContext } from "../../RuntimeContext.ts"; +import type { EdgeConfig } from "./EdgeConfig.ts"; + +/** + * A management-plane write against the Edge Config failed. Writes go to + * `PATCH /v1/global-config/{edgeConfigId}/items` on `https://api.vercel.com` + * via the distilled `global_config` service; distilled's typed errors are + * re-wrapped into this public error at the client boundary and ride `cause`. + */ +export class EdgeConfigWriteError extends Data.TaggedError( + "Vercel.EdgeConfigWriteError", +)<{ + readonly message: string; + /** The operation that failed (`set` | `delete` | `patch` | `connect`). */ + readonly operation: string; + /** HTTP status of the failing response, when one was received. */ + readonly status?: number | undefined; + readonly cause?: unknown; +}> {} + +/** One batched item mutation (mirrors the management API's PATCH body). */ +export interface EdgeConfigWriteOperation { + readonly operation: "upsert" | "delete"; + readonly key: string; + /** Required for `upsert`; ignored for `delete`. Any JSON value. */ + readonly value?: unknown; +} + +/** + * Typed write client for an {@link EdgeConfig}, returned by the + * {@link EdgeConfigWrite} binding. Every method requires + * {@link RuntimeContext}: writes resolve the management token bound into + * the deployed Function's environment. + * + * Writes ride the Vercel **management API** (there is no write data plane), + * so they are strongly consistent at the API but propagate to the read data + * plane (`edge-config.vercel.com`) with the usual sub-second replication + * delay. + */ +export interface WriteEdgeConfigClient { + /** Upsert every key in the record (one batched PATCH). */ + set( + items: Record, + ): Effect.Effect; + /** Delete the given keys (one batched PATCH; missing keys are an API error). */ + delete( + keys: readonly string[], + ): Effect.Effect; + /** Raw batched mutation — mix upserts and deletes in one PATCH. */ + patch( + operations: readonly EdgeConfigWriteOperation[], + ): Effect.Effect; +} + +/** + * Bind an {@link EdgeConfig} to a Function with write access and obtain the + * typed management-plane client (`set`, `delete`, `patch`). + * + * ## Runtime authorization (explicit opt-in — read before using) + * + * Edge Config **writes have no scoped credential**. This is a live-verified + * platform limitation (probe 2026-08): `POST /v3/user/tokens` can mint + * `scope: "project-only"` tokens (with expiry), but Edge Configs are owned + * by the **team**, not a project — a project-scoped token's + * `getEdgeConfig`/`patchEdgeConfigItems` answer `NotFound` and + * `createEdgeConfigToken` answers `Forbidden`. The only token that can + * write Edge Config items is a **team-wide (or user-wide) API token** — + * full management authority over every project and resource in the team. + * + * Alchemy therefore **never auto-binds its own management token** into a + * Function. The {@link WriteEdgeConfigHttp} implementation layer requires + * YOU to hand it a token explicitly (`Config.redacted(...)` or a + * `Redacted` value), and by accepting that you accept the tradeoff: any + * code (or dependency) running in that Function can use the bound token + * for ANY team-scoped management call, not just Edge Config writes. Prefer + * a dedicated machine-user token with an `expiresAt`, rotate it, and reach + * for deploy-time declarative `items` on the {@link EdgeConfig} resource + * whenever runtime writes are not strictly required. + * + * @binding + * @section Writing an Edge Config at runtime + * @example Explicit opt-in with a user-supplied token + * ```typescript + * import * as Vercel from "alchemy/Vercel"; + * import * as Config from "effect/Config"; + * import * as Effect from "effect/Effect"; + * import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; + * + * export default class Admin extends Vercel.Function()( + * "Admin", + * { main: import.meta.url }, + * Effect.gen(function* () { + * const flags = yield* Vercel.EdgeConfig("Flags", {}); + * const writer = yield* Vercel.WriteEdgeConfig(flags); + * return { + * fetch: Effect.gen(function* () { + * yield* writer.set({ maintenance: true }).pipe(Effect.orDie); + * return yield* HttpServerResponse.json({ ok: true }); + * }), + * }; + * }).pipe( + * // The token is YOUR team-wide management token — resolved from the + * // deploy environment and synced as a sensitive project env var. + * Effect.provide( + * Vercel.WriteEdgeConfigHttp({ + * token: Config.redacted("EDGE_CONFIG_WRITE_TOKEN"), + * }), + * ), + * ), + * ) {} + * ``` + * + * @example Batched upserts and deletes + * ```typescript + * yield* writer.patch([ + * { operation: "upsert", key: "banner", value: "hello" }, + * { operation: "delete", key: "legacyFlag" }, + * ]); + * ``` + */ +export interface EdgeConfigWrite extends Binding.Service< + EdgeConfigWrite, + "Vercel.EdgeConfigWrite", + (config: EdgeConfig) => Effect.Effect +> {} + +export const EdgeConfigWrite = Binding.Service( + "Vercel.EdgeConfigWrite", +); + +/** + * Ergonomic alias for {@link EdgeConfigWrite} — + * `yield* Vercel.WriteEdgeConfig(flags)`. + */ +export const WriteEdgeConfig = EdgeConfigWrite; + +// ───────────────────────────────────────────────────────────────────────────── +// Effect client +// ───────────────────────────────────────────────────────────────────────────── + +/** What a write needs: the config's identity plus the management token. */ +export interface EdgeConfigWriteTarget { + readonly edgeConfigId: string; + /** Team the config belongs to; omit for personal-scope configs. */ + readonly teamId?: string | undefined; + /** Team-wide management token (see the security note on {@link EdgeConfigWrite}). */ + readonly token: Redacted.Redacted; + /** + * Management API base URL. Defaults to the `VERCEL_API_URL` env override + * when set, else `https://api.vercel.com`. + */ + readonly apiBaseUrl?: string | undefined; +} + +type PatchError = globalConfig.PatchEdgeConfigItemsError; + +/** HTTP statuses of the typed distilled error tags (parallel to reads). */ +const WRITE_ERROR_STATUS: { readonly [tag: string]: number | undefined } = { + BadRequest: 400, + Unauthorized: 401, + PaymentRequired: 402, + Forbidden: 403, + NotFound: 404, + Conflict: 409, +}; + +const statusOf = (cause: PatchError): number | undefined => + cause._tag === "HttpClientError" + ? cause.response?.status + : WRITE_ERROR_STATUS[cause._tag]; + +const wrapWriteError = + (operation: string) => + (cause: PatchError): EdgeConfigWriteError => + new EdgeConfigWriteError({ + message: `Edge Config ${operation} failed: ${cause.message}`, + operation, + status: statusOf(cause), + cause, + }); + +/** + * Build a {@link WriteEdgeConfigClient} from a resolved target (or an + * accessor Effect — the {@link WriteEdgeConfigHttp} binding passes the + * env-reading accessor). The `HttpClient` is captured once at construction; + * every write goes through the distilled `global_config.patchEdgeConfigItems` + * operation with per-target credentials. + */ +export const makeWriteEdgeConfigClient = ( + target: + | EdgeConfigWriteTarget + | Effect.Effect, +): Effect.Effect => + Effect.gen(function* () { + const context = yield* Effect.context(); + + const resolveTarget: Effect.Effect< + EdgeConfigWriteTarget, + EdgeConfigWriteError + > = Effect.isEffect(target) ? target : Effect.succeed(target); + + const applyPatch = ( + operation: string, + operations: readonly EdgeConfigWriteOperation[], + ): Effect.Effect => + operations.length === 0 + ? Effect.void + : resolveTarget.pipe( + Effect.flatMap((resolved) => + globalConfig + .patchEdgeConfigItems({ + edgeConfigId: resolved.edgeConfigId, + teamId: resolved.teamId, + items: operations.map((op) => + op.operation === "upsert" + ? { + operation: "upsert" as const, + key: op.key, + value: op.value, + } + : { operation: "delete" as const, key: op.key }, + ), + }) + .pipe( + Effect.asVoid, + Effect.mapError(wrapWriteError(operation)), + Effect.provideService( + Credentials, + Effect.succeed({ + token: resolved.token, + apiBaseUrl: + resolved.apiBaseUrl ?? + process.env.VERCEL_API_URL ?? + DEFAULT_API_BASE_URL, + }), + ), + ), + ), + Effect.provideContext(context), + ); + + return { + set: (items) => + applyPatch( + "set", + Object.entries(items).map(([key, value]) => ({ + operation: "upsert" as const, + key, + value, + })), + ), + delete: (keys) => + applyPatch( + "delete", + keys.map((key) => ({ operation: "delete" as const, key })), + ), + patch: (operations) => applyPatch("patch", operations), + } satisfies WriteEdgeConfigClient; + }); diff --git a/packages/alchemy/src/Vercel/EdgeConfig/LocalEdgeConfigProvider.ts b/packages/alchemy/src/Vercel/EdgeConfig/LocalEdgeConfigProvider.ts new file mode 100644 index 0000000000..6326c250a9 --- /dev/null +++ b/packages/alchemy/src/Vercel/EdgeConfig/LocalEdgeConfigProvider.ts @@ -0,0 +1,317 @@ +/** + * Dev-mode (`alchemy dev`) providers for `Vercel.EdgeConfig` and + * `Vercel.EdgeConfigToken` — registry-style local emulation per the + * ProviderMode doctrine (in-memory rows, NO `LocalProvider.make`). + * + * - **EdgeConfig** rows live in {@link LocalEdgeConfigState}: the declared + * `items` map is seeded into the registry with a content digest, and the + * registry is served over a local HTTP endpoint speaking the exact + * data-plane read protocol (see `LocalEdgeConfigState.ts`). Ids carry the + * `dev:ecfg_` marker — proof no cloud call ran. The optional `schema` is + * NOT enforced locally (validation is a management-API concern; local + * items only change via deploy). + * - **EdgeConfigToken** mints `dev:ectok_…` read tokens against the local + * endpoint and yields a `http://localhost:/{edgeConfigId}?token=…` + * connection string — the exact shape the alchemy read clients and + * `@vercel/edge-config` accept. When the token targets a REAL Edge Config + * (an `Alchemy.remote()` config bound during dev — the mixed + * local-Function/live-EdgeConfig stack), it delegates to the live + * lifecycle ({@link makeEdgeConfigTokenService}) so the locally running + * Function still receives a real `https://edge-config.vercel.com/…` + * connection string. + * + * Because item changes converge in the registry (served live by the local + * endpoint), an items-only redeploy updates reads WITHOUT restarting the + * consuming Function — the connection string, and therefore the Function's + * env, is unchanged. + */ +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as MutableHashMap from "effect/MutableHashMap"; +import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; +import type { ScopedPlanStatusSession } from "../../Cli/Cli.ts"; +import { isResolved } from "../../Diff.ts"; +import * as RpcProvider from "../../Local/RpcProvider.ts"; +import { createPhysicalName } from "../../PhysicalName.ts"; +import { sha256 } from "../../Util/sha256.ts"; +import { stableStringify } from "../../Util/stable.ts"; +import { LOCAL_ENTRY_URL } from "../LocalRuntime.ts"; +import { EdgeConfig } from "./EdgeConfig.ts"; +import { + EdgeConfigToken, + makeEdgeConfigTokenService, +} from "./EdgeConfigToken.ts"; +import { + computeLocalDigest, + isLocalEdgeConfigId, + LOCAL_EDGE_CONFIG_ID_PREFIX, + LOCAL_EDGE_CONFIG_TOKEN_PREFIX, + LocalEdgeConfigState, + type LocalEdgeConfigEntry, +} from "./LocalEdgeConfigState.ts"; + +/** + * A provider running behind the RPC sidecar receives `session` as plain + * data — capnweb strips its method stubs — so `note` may arrive `null`. + * Fall back to a log line (still surfaced in the sidecar output) and hand + * the delegated live lifecycle a callable session. + */ +const rpcSafeSession = ( + session: ScopedPlanStatusSession, +): ScopedPlanStatusSession => + typeof session?.note === "function" + ? session + : { ...session, note: (note: string) => Effect.log(note) }; + +/** Live-parity item normalization: `undefined`-valued keys are dropped. */ +const pruneItems = (items: Record): Record => + Object.fromEntries( + Object.entries(items).filter(([, value]) => value !== undefined), + ); + +const toEdgeConfigAttributes = ( + entry: LocalEdgeConfigEntry, +): EdgeConfig["Attributes"] => ({ + edgeConfigId: entry.edgeConfigId, + slug: entry.slug, + digest: entry.digest, + createdAt: entry.createdAt, + ownerId: "dev", + itemCount: entry.itemCount, + sizeInBytes: entry.sizeInBytes, +}); + +export const LocalEdgeConfigProvider = () => + RpcProvider.effect( + EdgeConfig, + LOCAL_ENTRY_URL, + Effect.gen(function* () { + const state = yield* LocalEdgeConfigState; + return { + stables: [ + "edgeConfigId" as const, + "createdAt" as const, + "ownerId" as const, + ], + diff: Effect.fn(function* ({ news = {}, output }) { + // Greenfield — or a row without a local identity (cannot occur in + // practice: Vercel shipped provider modes from day one) — mints a + // fresh `dev:` id in reconcile. + if ( + output?.edgeConfigId === undefined || + !isLocalEdgeConfigId(output.edgeConfigId) + ) { + return { action: "update" } as const; + } + if (!isResolved(news)) return undefined; + const entry = Option.getOrUndefined( + MutableHashMap.get(state.configs, output.edgeConfigId), + ); + // Fresh sidecar session: the registry is empty even though state + // says `created` — reconcile re-registers (and re-serves) it. + if (entry === undefined) return { action: "update" } as const; + if ((news.slug ?? output.slug) !== entry.slug) { + return { action: "update" } as const; + } + if ( + news.items !== undefined && + stableStringify(pruneItems(news.items)) !== + stableStringify(entry.items) + ) { + return { action: "update" } as const; + } + return { action: "noop" } as const; + }), + read: Effect.fn(function* ({ output }) { + if (output?.edgeConfigId === undefined) return undefined; + const entry = Option.getOrUndefined( + MutableHashMap.get(state.configs, output.edgeConfigId), + ); + return entry === undefined + ? undefined + : toEdgeConfigAttributes(entry); + }), + reconcile: Effect.fn(function* ({ id, fqn, news = {}, output }) { + // Auto-generated slugs are engine-owned: prefer the deployed slug + // (matching the live provider); only an explicit `news.slug` + // renames. + const slug = + news.slug ?? + output?.slug ?? + (yield* createPhysicalName({ id, maxLength: 64 })); + // The id survives renames (live parity: slugs are labels, ids are + // identity). Derived from the FQN so distinct stacks sharing one + // sidecar can never collide; never carry a live `ecfg_…` id onto + // a local row. + const edgeConfigId = + output?.edgeConfigId !== undefined && + isLocalEdgeConfigId(output.edgeConfigId) + ? output.edgeConfigId + : `${LOCAL_EDGE_CONFIG_ID_PREFIX}${(yield* sha256(fqn)).slice(0, 24)}`; + const existing = Option.getOrUndefined( + MutableHashMap.get(state.configs, edgeConfigId), + ); + // Declared items converge exactly; undeclared items are left + // unmanaged (live parity) — a fresh registry entry starts empty. + const items = pruneItems(news.items ?? existing?.items ?? {}); + const digest = yield* computeLocalDigest(items); + const sizeInBytes = yield* Effect.sync( + () => new TextEncoder().encode(stableStringify(items)).length, + ); + const entry: LocalEdgeConfigEntry = { + edgeConfigId, + slug, + items, + digest, + createdAt: + existing?.createdAt ?? + output?.createdAt ?? + (yield* Clock.currentTimeMillis), + itemCount: Object.keys(items).length, + sizeInBytes, + }; + MutableHashMap.set(state.configs, edgeConfigId, entry); + return toEdgeConfigAttributes(entry); + }), + delete: Effect.fn(function* ({ output }) { + MutableHashMap.remove(state.configs, output.edgeConfigId); + // Read tokens cascade with their config (live parity). + for (const [token, target] of state.tokens) { + if (target === output.edgeConfigId) { + MutableHashMap.remove(state.tokens, token); + } + } + }), + }; + }), + ); + +export const LocalEdgeConfigTokenProvider = () => + RpcProvider.effect( + EdgeConfigToken, + LOCAL_ENTRY_URL, + Effect.gen(function* () { + const state = yield* LocalEdgeConfigState; + const live = makeEdgeConfigTokenService(); + return { + stables: ["edgeConfigId" as const], + diff: Effect.fn(function* ({ olds, news, output }) { + if (!isResolved(news)) return undefined; + const oldEdgeConfigId = output?.edgeConfigId ?? olds?.edgeConfigId; + // Moving the token to another Edge Config is a replacement + // (matching the live diff) — including local↔live moves. + if ( + news.edgeConfigId !== undefined && + oldEdgeConfigId !== undefined && + news.edgeConfigId !== oldEdgeConfigId + ) { + return { action: "replace" } as const; + } + const target = news.edgeConfigId ?? oldEdgeConfigId; + if (!isLocalEdgeConfigId(target)) { + // Live target (an `Alchemy.remote()` Edge Config): the default + // props-diff semantics — identical to the live provider's + // remaining behaviour. + return undefined; + } + if (output === undefined) return { action: "update" } as const; + // The minted connection string must point at THIS session's local + // data plane: a fresh sidecar serves on a new port (and starts + // with an empty token registry), so re-mint when stale. + const serverUrl = yield* state.serverUrl; + const registered = + Option.getOrUndefined( + MutableHashMap.get(state.tokens, Redacted.value(output.token)), + ) === target; + const connection = Redacted.value(output.connectionString); + if (!registered || !connection.startsWith(`${serverUrl}/`)) { + return { action: "update" } as const; + } + if (news.label !== undefined && news.label !== output.label) { + return { action: "update" } as const; + } + return { action: "noop" } as const; + }), + read: Effect.fn(function* ({ output }) { + if (output === undefined) return undefined; + if (!isLocalEdgeConfigId(output.edgeConfigId)) { + return yield* live.read({ output }); + } + const registered = + Option.getOrUndefined( + MutableHashMap.get(state.tokens, Redacted.value(output.token)), + ) === output.edgeConfigId; + return registered ? output : undefined; + }), + reconcile: Effect.fn(function* ({ id, news = {}, output, session }) { + const safeSession = rpcSafeSession(session); + const edgeConfigId = news.edgeConfigId ?? output?.edgeConfigId; + if ( + edgeConfigId === undefined || + !isLocalEdgeConfigId(edgeConfigId) + ) { + // A real Edge Config bound during dev (`Alchemy.remote()`) — the + // token must be real too; delegate to the live lifecycle (which + // also owns the `edgeConfigId === undefined` die). + return yield* live.reconcile({ + id, + news, + output, + session: safeSession, + }); + } + const serverUrl = yield* state.serverUrl; + const label = + news.label ?? + output?.label ?? + (yield* createPhysicalName({ id, maxLength: 52 })); + // Observe-and-keep (live parity): reuse the persisted token value + // while it still targets the same config, so dependent env rows — + // and therefore Function restarts — never churn. + const kept = + output !== undefined && + output.edgeConfigId === edgeConfigId && + Redacted.value(output.token).startsWith( + LOCAL_EDGE_CONFIG_TOKEN_PREFIX, + ) + ? output + : undefined; + const token = + kept !== undefined + ? Redacted.value(kept.token) + : yield* Effect.sync( + () => + `${LOCAL_EDGE_CONFIG_TOKEN_PREFIX}${crypto.randomUUID().replaceAll("-", "")}`, + ); + MutableHashMap.set(state.tokens, token, edgeConfigId); + if (kept === undefined) { + yield* safeSession.note( + `Created local Edge Config token: ${label}`, + ); + } + return { + tokenId: + kept?.tokenId ?? + `dev:ectokid_${token.slice(LOCAL_EDGE_CONFIG_TOKEN_PREFIX.length)}`, + edgeConfigId, + label, + createdAt: kept?.createdAt ?? (yield* Clock.currentTimeMillis), + token: Redacted.make(token), + connectionString: Redacted.make( + `${serverUrl}/${edgeConfigId}?token=${token}`, + ), + }; + }), + delete: Effect.fn(function* ({ output, session }) { + if (!isLocalEdgeConfigId(output.edgeConfigId)) { + return yield* live.delete({ + output, + session: rpcSafeSession(session), + }); + } + MutableHashMap.remove(state.tokens, Redacted.value(output.token)); + }), + }; + }), + ); diff --git a/packages/alchemy/src/Vercel/EdgeConfig/LocalEdgeConfigState.ts b/packages/alchemy/src/Vercel/EdgeConfig/LocalEdgeConfigState.ts new file mode 100644 index 0000000000..e73fae0422 --- /dev/null +++ b/packages/alchemy/src/Vercel/EdgeConfig/LocalEdgeConfigState.ts @@ -0,0 +1,201 @@ +/** + * Shared state for the dev-mode (`alchemy dev`) Vercel Edge Config + * emulation: an in-memory registry of local Edge Configs (+ their read + * tokens) and a lazily started local HTTP endpoint speaking the EXACT + * data-plane read protocol probed against `@vercel/edge-config@1.5.1` + * (PROBES.md §10): + * + * - `GET {base}/item/{key}?version=1` → the item's JSON value + * - `GET {base}/items?version=1[&key=…]` → record of (a subset of) items + * - `HEAD {base}/item/{key}?version=1` → 200 / 404 + * - `GET {base}/digest?version=1` → the content digest (a JSON string) + * - bearer auth (`Authorization: Bearer `) + * - a missing ITEM is a 404 **with** an `x-edge-config-digest` header; a + * bare 404 means "config not found" (the SDK throws on it), and an + * invalid token is a 401. + * + * The registry lives in the dev sidecar (see `Vercel/Local.ts`) — or the + * test process under `sidecar: false` — shared by the `EdgeConfig` and + * `EdgeConfigToken` local providers via {@link localVercelServices}. The + * server starts on first demand (an ephemeral port) and is torn down with + * the state layer's scope; connection strings minted by the local token + * provider point at it, so both alchemy's read clients and the real + * `@vercel/edge-config` SDK (which follows arbitrary hosts, probe-verified) + * round-trip against it unchanged. + */ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as MutableHashMap from "effect/MutableHashMap"; +import * as Option from "effect/Option"; +import * as Scope from "effect/Scope"; +import * as HttpServer from "effect/unstable/http/HttpServer"; +import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import { httpServer } from "../../Util/PlatformServices.ts"; +import { sha256 } from "../../Util/sha256.ts"; +import { stableStringify } from "../../Util/stable.ts"; + +/** The `dev:` marker prefix for locally emulated Edge Config ids. */ +export const LOCAL_EDGE_CONFIG_ID_PREFIX = "dev:ecfg_"; + +/** The `dev:` marker prefix for locally minted Edge Config read tokens. */ +export const LOCAL_EDGE_CONFIG_TOKEN_PREFIX = "dev:ectok_"; + +/** `true` for a locally emulated (`dev:`-marked) Edge Config id. */ +export const isLocalEdgeConfigId = (id: string | undefined): id is string => + typeof id === "string" && id.startsWith("dev:"); + +/** A locally emulated Edge Config: the registry row the server reads. */ +export interface LocalEdgeConfigEntry { + readonly edgeConfigId: string; + readonly slug: string; + readonly items: Record; + /** Content digest — sha256 over the canonical item serialization. */ + readonly digest: string; + readonly createdAt: number; + readonly itemCount: number; + readonly sizeInBytes: number; +} + +/** The local content digest — changes exactly when the items change. */ +export const computeLocalDigest = ( + items: Record, +): Effect.Effect => sha256(stableStringify(items)); + +/** + * Registry of locally emulated Edge Configs and read tokens, plus the + * lazily started local data-plane server. Lives in the dev sidecar (or the + * test process when `sidecar: false`), shared by the EdgeConfig and + * EdgeConfigToken local providers built from {@link localVercelServices}. + */ +export class LocalEdgeConfigState extends Context.Service< + LocalEdgeConfigState, + { + /** Local configs, keyed by `dev:ecfg_…` id. */ + readonly configs: MutableHashMap.MutableHashMap< + string, + LocalEdgeConfigEntry + >; + /** Valid read tokens: token value → the `dev:ecfg_…` id it reads. */ + readonly tokens: MutableHashMap.MutableHashMap; + /** + * The local data-plane origin (`http://localhost:`, no trailing + * slash). Starts the server on first demand; memoized for the state's + * lifetime. + */ + readonly serverUrl: Effect.Effect; + } +>()("alchemy/vercel/LocalEdgeConfigState") {} + +/** + * Handle one data-plane read against the registry (see the module doc for + * the protocol contract). + */ +const handleRead = ( + request: HttpServerRequest.HttpServerRequest, + configs: MutableHashMap.MutableHashMap, + tokens: MutableHashMap.MutableHashMap, +): HttpServerResponse.HttpServerResponse => { + const url = new URL(request.url, "http://localhost"); + const segments = url.pathname.split("/").filter((s) => s !== ""); + const edgeConfigId = decodeURIComponent(segments[0] ?? ""); + const entry = Option.getOrUndefined( + MutableHashMap.get(configs, edgeConfigId), + ); + if (entry === undefined) { + // Bare 404 (no digest header) — the SDK's "config not found" signal. + return HttpServerResponse.text("Not Found", { status: 404 }); + } + const authorization = request.headers.authorization; + const bearer = + typeof authorization === "string" && authorization.startsWith("Bearer ") + ? authorization.slice("Bearer ".length) + : undefined; + const token = bearer ?? url.searchParams.get("token") ?? undefined; + const authorized = + token !== undefined && + Option.getOrUndefined(MutableHashMap.get(tokens, token)) === edgeConfigId; + if (!authorized) { + return HttpServerResponse.text("Unauthorized", { status: 401 }); + } + const headers = { "x-edge-config-digest": entry.digest }; + const route = segments[1]; + if (route === "digest" && segments.length === 2) { + // The digest endpoint answers a JSON *string*. + return HttpServerResponse.jsonUnsafe(entry.digest, { headers }); + } + if (route === "items" && segments.length === 2) { + const keys = url.searchParams.getAll("key"); + const items = + keys.length === 0 + ? entry.items + : Object.fromEntries( + keys + .filter((key) => Object.hasOwn(entry.items, key)) + .map((key) => [key, entry.items[key]]), + ); + return HttpServerResponse.jsonUnsafe(items, { headers }); + } + if (route === "item" && segments.length >= 3) { + const key = decodeURIComponent(segments.slice(2).join("/")); + const exists = Object.hasOwn(entry.items, key); + if (request.method === "HEAD") { + return HttpServerResponse.empty({ status: exists ? 200 : 404, headers }); + } + if (!exists) { + // Missing ITEM: 404 *with* the digest header — the SDK (and our + // clients) decode this as `undefined`, never as an error. + return HttpServerResponse.text("Not Found", { status: 404, headers }); + } + return HttpServerResponse.jsonUnsafe(entry.items[key], { headers }); + } + return HttpServerResponse.text("Not Found", { status: 404 }); +}; + +/** Serve the local data-plane endpoint in the ambient `Scope`. */ +const serveLocalEdgeConfig = Effect.fn(function* ( + configs: MutableHashMap.MutableHashMap, + tokens: MutableHashMap.MutableHashMap, +) { + const handler = Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + return handleRead(request, configs, tokens); + }); + const context = yield* Layer.build( + httpServer(0, "127.0.0.1"), + ) as Effect.Effect< + Context.Context, + unknown, + Scope.Scope + >; + const server = Context.get(context, HttpServer.HttpServer); + yield* server.serve(handler); + return HttpServer.formatAddress(server.address) + .replace("127.0.0.1", "localhost") + .replace(/\/$/, ""); +}); + +/** + * The {@link LocalEdgeConfigState} layer — a module-level constant so every + * composition site shares one layer identity (the stack build's `MemoMap` + * then dedupes it to a single registry + server per build; see + * `localVercelServices`). + */ +export const LocalEdgeConfigStateLive = Layer.effect( + LocalEdgeConfigState, + Effect.gen(function* () { + const scope = yield* Effect.scope; + const configs = MutableHashMap.empty(); + const tokens = MutableHashMap.empty(); + const serverUrl = yield* Effect.cached( + // The server lives for the whole state lifetime (torn down with the + // layer scope), not for the lifetime of the demanding call. + serveLocalEdgeConfig(configs, tokens).pipe( + Scope.provide(scope), + Effect.orDie, + ), + ); + return LocalEdgeConfigState.of({ configs, tokens, serverUrl }); + }), +); diff --git a/packages/alchemy/src/Vercel/EdgeConfig/ReadEdgeConfigHttp.ts b/packages/alchemy/src/Vercel/EdgeConfig/ReadEdgeConfigHttp.ts new file mode 100644 index 0000000000..9d591fc6ae --- /dev/null +++ b/packages/alchemy/src/Vercel/EdgeConfig/ReadEdgeConfigHttp.ts @@ -0,0 +1,62 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import { Self } from "../../Self.ts"; +import { EdgeConfigRead, makeReadEdgeConfigClient } from "./EdgeConfigRead.ts"; +import { EdgeConfigToken } from "./EdgeConfigToken.ts"; +import type { EdgeConfig } from "./EdgeConfig.ts"; + +/** + * HTTP (data-plane) implementation of {@link EdgeConfigRead}. + * + * Deploy half: yields a scoped {@link EdgeConfigToken} for the bound Edge + * Config (one per host Function + config, observe-and-keep — the token is + * minted once and reused across deploys, so env fingerprints stay stable) + * and captures its `connectionString` into the Function's environment as a + * `sensitive` project env var. Runtime half: the same capture resolves the + * connection string back out of `process.env` and the client reads via + * `https://edge-config.vercel.com/{edgeConfigId}` with bearer auth. + * + * Provide on the Function's init Effect: + * `Effect.provide(Vercel.ReadEdgeConfigHttp)`. + * + * ## Runtime authorization + * + * Read access is least-privilege by construction: the minted + * {@link EdgeConfigToken} is a **read-only, per-config data-plane token** + * (plaintext disclosed once at mint) — it grants nothing beyond reading + * this one config. No management credential ever reaches the Function. + * + * Writes are NOT auto-bound: the management API only accepts team/user-wide + * tokens (live-verified — project-scoped tokens cannot see team-owned Edge + * Configs), so runtime writes are an explicit opt-in via + * `WriteEdgeConfigHttp({ token })` (see the security note on + * `EdgeConfigWrite`). Prefer deploy-time declarative `items` on the + * {@link EdgeConfig} resource where possible. + */ +export const ReadEdgeConfigHttp = Layer.effect( + EdgeConfigRead, + Effect.gen(function* () { + const Token = yield* EdgeConfigToken; + const self = yield* Self; + const context = yield* Effect.context(); + + return Effect.fn(function* (config: EdgeConfig) { + // Registered in BOTH phases: at plan time this adds the token to the + // stack graph; at runtime it only rebuilds the Output expression the + // env accessor below is keyed by. + const token = yield* Token( + `${self.LogicalId}${config.LogicalId}ReadToken`, + { edgeConfigId: config.edgeConfigId }, + ); + // Env capture: at plan time this registers the connection string as + // an env var on the host Function (Redacted ⇒ synced `sensitive`); + // at runtime it returns an accessor reading it back from + // `process.env` (Redacted-unpacked). + const connection = yield* token.connectionString; + return yield* makeReadEdgeConfigClient(connection).pipe( + Effect.provideContext(context), + ); + }); + }), +); diff --git a/packages/alchemy/src/Vercel/EdgeConfig/WriteEdgeConfigHttp.ts b/packages/alchemy/src/Vercel/EdgeConfig/WriteEdgeConfigHttp.ts new file mode 100644 index 0000000000..ba1cc4869a --- /dev/null +++ b/packages/alchemy/src/Vercel/EdgeConfig/WriteEdgeConfigHttp.ts @@ -0,0 +1,168 @@ +import type * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import * as Binding from "../../Binding.ts"; +import { sanitizeKey, unpackEnvValue } from "../../RuntimeContext.ts"; +import { isFunction } from "../Functions/Function.ts"; +import type { EdgeConfig } from "./EdgeConfig.ts"; +import { + EdgeConfigWrite, + EdgeConfigWriteError, + makeWriteEdgeConfigClient, + type EdgeConfigWriteTarget, +} from "./EdgeConfigWrite.ts"; + +/** Env keys the deploy half binds for a written config's logical id. */ +const writeEnvKey = ( + logicalId: string, + suffix: "ID" | "OWNER" | "TOKEN", +): string => `${sanitizeKey(logicalId)}_EDGE_CONFIG_WRITE_${suffix}`; + +/** Options for {@link WriteEdgeConfigHttp}. */ +export interface WriteEdgeConfigHttpOptions { + /** + * The management token used for runtime writes — a **team-wide Vercel API + * token** supplied BY YOU (there is no scoped write credential; see the + * security note on {@link EdgeConfigWrite}). Pass + * `Config.redacted("SOME_ENV")` to resolve it from the deploy + * environment, or a `Redacted` value directly. It is synced to the + * Function as a `sensitive` project env var. + */ + readonly token: + | Redacted.Redacted + | Config.Config>; +} + +/** + * HTTP (management-plane) implementation of {@link EdgeConfigWrite} — + * an **explicit opt-in** layer factory, deliberately not a ready-made layer. + * + * ## Runtime authorization + * + * Unlike every other Vercel capability, Edge Config writes cannot be + * granted least-privilege (live-verified: project-scoped tokens cannot see + * team-owned Edge Configs, and the write path only accepts team/user-wide + * management tokens — see {@link EdgeConfigWrite}). This factory therefore + * requires the caller to supply the token themselves and makes the + * decision visible at the call site: + * + * - Deploy half (guarded by `__ALCHEMY_RUNTIME__`): resolves `options.token` + * (a `Config.redacted` reads the DEPLOY machine's environment) and binds + * three env vars on the host Function — the config's id, its owner id, + * and the token (Redacted ⇒ synced as a `sensitive` project env var). + * - Runtime half: reads those env vars back and issues + * `PATCH /v1/global-config/{edgeConfigId}/items` via the distilled + * `global_config` service. The `VERCEL_API_URL` env override is honored + * for the management base URL. + * + * Dev-mode note: `alchemy dev` emulates the Edge Config READ data plane + * only; runtime writes always target the management API, so in dev they + * fail unless the resource is deployed live (`Alchemy.remote()`). + * + * Provide on the Function's init Effect: + * `Effect.provide(Vercel.WriteEdgeConfigHttp({ token: Config.redacted("...") }))`. + */ +export const WriteEdgeConfigHttp = ( + options: WriteEdgeConfigHttpOptions, +): Layer.Layer => + Layer.effect( + EdgeConfigWrite, + Effect.gen(function* () { + const context = yield* Effect.context(); + + return Effect.fn(function* (config: EdgeConfig) { + const logicalId = (config as { LogicalId?: string }).LogicalId; + if (logicalId === undefined) { + return yield* Effect.die( + new Error( + "Vercel.WriteEdgeConfig: target has no LogicalId — pass a Vercel.EdgeConfig handle", + ), + ); + } + const idKey = writeEnvKey(logicalId, "ID"); + const ownerKey = writeEnvKey(logicalId, "OWNER"); + const tokenKey = writeEnvKey(logicalId, "TOKEN"); + + if (!globalThis.__ALCHEMY_RUNTIME__) { + // Deploy-time only: resolve the user-supplied token (Config reads + // the deploy environment) and bind it — with the config's + // identity — onto the host's project env. The Redacted wrapper + // makes the token row `sensitive`. + const host = yield* Binding.Host; + if (isFunction(host)) { + const token = Effect.isEffect(options.token) + ? yield* ( + options.token as Effect.Effect< + Redacted.Redacted, + unknown + > + ).pipe( + Effect.catch((cause) => + Effect.die( + new Error( + `Vercel.WriteEdgeConfig(${logicalId}): could not resolve the management token from options.token: ${String( + cause, + )}`, + ), + ), + ), + ) + : options.token; + yield* host.bind`WriteEdgeConfig(${host}, ${logicalId})`({ + env: { + [idKey]: config.edgeConfigId, + [ownerKey]: config.ownerId, + [tokenKey]: token, + }, + }); + } + } + + // Lazy env reads — the bound vars only exist at the exec phase. + const resolveTarget: Effect.Effect< + EdgeConfigWriteTarget, + EdgeConfigWriteError + > = Effect.suspend(() => { + const edgeConfigId = unpackEnvValue(process.env[idKey]); + const owner = unpackEnvValue(process.env[ownerKey]); + const rawToken = unpackEnvValue>( + process.env[tokenKey], + ); + const token = + rawToken === undefined + ? undefined + : Redacted.isRedacted(rawToken) + ? rawToken + : Redacted.make(rawToken); + if ( + edgeConfigId === undefined || + edgeConfigId === "" || + token === undefined + ) { + return Effect.fail( + new EdgeConfigWriteError({ + message: `Vercel.WriteEdgeConfig(${logicalId}): env ${idKey}/${tokenKey} is not set — was the WriteEdgeConfig binding deployed with this Function?`, + operation: "connect", + }), + ); + } + return Effect.succeed({ + edgeConfigId, + // Personal-scope configs carry a user owner id; the management + // API only takes `teamId` for team-owned configs. + teamId: + owner !== undefined && owner.startsWith("team_") + ? owner + : undefined, + token, + }); + }); + + return yield* makeWriteEdgeConfigClient(resolveTarget).pipe( + Effect.provideContext(context), + ); + }); + }), + ); diff --git a/packages/alchemy/src/Vercel/EdgeConfig/index.ts b/packages/alchemy/src/Vercel/EdgeConfig/index.ts new file mode 100644 index 0000000000..423336bf0b --- /dev/null +++ b/packages/alchemy/src/Vercel/EdgeConfig/index.ts @@ -0,0 +1,6 @@ +export * from "./EdgeConfig.ts"; +export * from "./EdgeConfigRead.ts"; +export * from "./EdgeConfigToken.ts"; +export * from "./EdgeConfigWrite.ts"; +export * from "./ReadEdgeConfigHttp.ts"; +export * from "./WriteEdgeConfigHttp.ts"; diff --git a/packages/alchemy/src/Vercel/Environments/SharedEnv.ts b/packages/alchemy/src/Vercel/Environments/SharedEnv.ts new file mode 100644 index 0000000000..09e09c8220 --- /dev/null +++ b/packages/alchemy/src/Vercel/Environments/SharedEnv.ts @@ -0,0 +1,388 @@ +import { + createSharedEnvVariable, + deleteSharedEnvVariable, + getSharedEnvVar, + listSharedEnvVariable, + updateSharedEnvVariable, +} from "@distilled.cloud/vercel/environment"; +import { createHash } from "node:crypto"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Provider from "../../Provider.ts"; +import { Resource } from "../../Resource.ts"; +import type { Providers } from "../Providers.ts"; +import { VercelEnvironment } from "../VercelEnvironment.ts"; + +/** Deployment environments a shared env var can target. */ +export type SharedEnvTarget = "production" | "preview" | "development"; + +/** + * A reference to a project the shared env var is linked to: a project id + * (`prj_…`) or any resource carrying a `projectId` attribute (e.g. + * `Vercel.Project`). + */ +export type SharedEnvProjectSource = string | { projectId: string }; + +/** + * A per-item failure reported inside the 200-enveloped `failed[]` array of + * the shared env var create/update/delete APIs, surfaced as a typed error. + */ +export class SharedEnvVarError extends Data.TaggedError("SharedEnvVarError")<{ + readonly code: string; + readonly message: string; + readonly key: string | undefined; +}> {} + +export interface SharedEnvProps { + /** + * The environment variable name, e.g. `API_URL`. Renaming updates the + * variable in place (the id is stable). + */ + key: string; + /** + * The environment variable value. For `type: "encrypted"` (the default) + * the value can be read back and drift is corrected from observed cloud + * state; for `type: "sensitive"` the value is write-only on Vercel's side + * and drift is detected via a content hash persisted in state. + */ + value: string; + /** + * The variable type. `"encrypted"` values are decryptable by the API; + * `"sensitive"` values can never be read back (and cannot be converted + * back to `"encrypted"` once written). + * + * @default "encrypted" + */ + type?: "encrypted" | "sensitive"; + /** + * Deployment environments the variable applies to. + * + * @default ["production", "preview", "development"] + */ + target?: SharedEnvTarget[]; + /** + * Projects the variable is linked to. Accepts `Vercel.Project` resources + * or project ids. Adding/removing entries links/unlinks the variable + * declaratively. + */ + projects?: SharedEnvProjectSource[]; + /** + * A comment describing what the variable is for. + */ + comment?: string; +} + +export type SharedEnv = Resource< + "Vercel.SharedEnv", + SharedEnvProps, + { + /** The shared env var id (`env_…`). Stable across updates. */ + sharedEnvId: string; + /** The variable name. */ + key: string; + /** The variable type as reported by Vercel. */ + type: "encrypted" | "plain" | "sensitive" | "system"; + /** Deployment environments the variable applies to. */ + target: SharedEnvTarget[]; + /** Ids of the projects the variable is linked to. */ + projectIds: string[]; + /** The comment, if any. */ + comment: string | undefined; + /** + * sha256 of the last value written by Alchemy. Used to detect value + * drift for `sensitive` variables, whose values can never be read back. + */ + valueHash: string; + /** Creation time in epoch milliseconds. */ + createdAt: number | undefined; + /** Last update time in epoch milliseconds. */ + updatedAt: number | undefined; + }, + never, + Providers +>; + +type SharedEnvAttributes = SharedEnv["Attributes"]; + +/** + * A Vercel Shared Environment Variable: a team-level env var defined once + * and linked to any number of projects, applied to the selected deployment + * targets of each linked project. + * + * @resource + * @section Creating a shared env var + * @example Link a variable to a project + * ```typescript + * const project = yield* Vercel.Project("my-app", {}); + * const flag = yield* Vercel.SharedEnv("ApiUrl", { + * key: "API_URL", + * value: "https://api.example.com", + * projects: [project], + * }); + * ``` + * + * @example Production-only sensitive secret + * ```typescript + * const secret = yield* Vercel.SharedEnv("SigningKey", { + * key: "SIGNING_KEY", + * value: signingKey, + * type: "sensitive", + * target: ["production"], + * projects: [project], + * }); + * ``` + * + * @section Linking and unlinking projects + * @example Declarative project links + * ```typescript + * // Adding/removing entries in `projects` links/unlinks the variable on + * // the next deploy — the full list is reconciled against observed state. + * const flag = yield* Vercel.SharedEnv("ApiUrl", { + * key: "API_URL", + * value: "https://api.example.com", + * projects: [projectA, projectB], + * }); + * ``` + * + * @see https://vercel.com/docs/environment-variables/shared-environment-variables + */ +export const SharedEnv = Resource("Vercel.SharedEnv"); + +const DEFAULT_TARGET: SharedEnvTarget[] = [ + "production", + "preview", + "development", +]; + +/** + * Structural shape shared by every shared-env-var response item (get, list + * data item, create `created[]` item, update `updated[]` item) — the + * generated types are per-operation but identical in the fields we read. + */ +interface SharedEnvVarShape { + readonly id?: string; + readonly key?: string; + readonly type?: "encrypted" | "plain" | "sensitive" | "system"; + readonly target?: ReadonlyArray; + readonly projectId?: ReadonlyArray; + readonly comment?: string; + readonly value?: string; + readonly createdAt?: number; + readonly updatedAt?: number; +} + +const teamScope = Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + return teamId !== undefined ? { teamId } : {}; +}); + +const resolveProjectId = (source: SharedEnvProjectSource): string => { + if (typeof source === "string") return source; + if (source && "projectId" in source && source.projectId) { + return source.projectId as unknown as string; + } + throw new Error( + "Invalid Vercel project source: must be a Project or a project id", + ); +}; + +const hashValue = (value: string) => + Effect.sync(() => createHash("sha256").update(value).digest("hex")); + +const sameMembers = ( + a: readonly string[] | undefined, + b: readonly string[] | undefined, +): boolean => { + const as = [...(a ?? [])].sort(); + const bs = [...(b ?? [])].sort(); + return as.length === bs.length && as.every((v, i) => v === bs[i]); +}; + +const toAttributes = ( + env: SharedEnvVarShape, + valueHash: string, +): SharedEnvAttributes => ({ + sharedEnvId: env.id ?? "", + key: env.key ?? "", + type: env.type ?? "encrypted", + target: [...(env.target ?? [])], + projectIds: [...(env.projectId ?? [])], + comment: env.comment, + valueHash, + createdAt: env.createdAt, + updatedAt: env.updatedAt, +}); + +/** + * Surface per-item failures from the create/update/delete 200-envelope as a + * typed error (the APIs report partial failures inside the body). Codes in + * `ignore` (e.g. `id_not_found` on an idempotent delete) are tolerated. + */ +const failFromEnvelope = ( + failed: ReadonlyArray<{ + error: { code: string; message: string; key?: string; envVarKey?: string }; + }>, + ignore: ReadonlyArray = [], +) => { + const first = failed.find((f) => !ignore.includes(f.error.code)); + return first + ? Effect.fail( + new SharedEnvVarError({ + code: first.error.code, + message: first.error.message, + key: first.error.key ?? first.error.envVarKey, + }), + ) + : Effect.void; +}; + +/** + * Single-page exact-key lookup (the list API exposes no pagination inputs). + * Used to recover a create whose state persistence failed, and to resolve + * the create-race (`Conflict`) path. + */ +const findByKey = (key: string) => + Effect.gen(function* () { + const team = yield* teamScope; + const res = yield* listSharedEnvVariable({ search: key, ...team }); + const matches = res.data.filter((item) => item.key === key); + // Ambiguity (several vars sharing the key across disjoint targets) is + // unresolvable without an id — treat as not found. + return matches.length === 1 ? matches[0] : undefined; + }); + +export const SharedEnvProvider = () => + Provider.succeed(SharedEnv, { + stables: ["sharedEnvId", "createdAt"], + read: Effect.fn(function* ({ olds, output }) { + const team = yield* teamScope; + if (output?.sharedEnvId) { + return yield* getSharedEnvVar({ id: output.sharedEnvId, ...team }).pipe( + Effect.map((env) => toAttributes(env, output.valueHash)), + Effect.catchTag("NotFound", () => Effect.succeed(undefined)), + ); + } + // State-loss recovery: shared env vars carry no stamp; an unambiguous + // exact key match from the prior props is the only usable identity. + if (!olds?.key) return undefined; + const match = yield* findByKey(olds.key); + // An empty valueHash marks the value as unknown, so the next + // reconcile rewrites it from props. + return match !== undefined ? toAttributes(match, "") : undefined; + }), + reconcile: Effect.fn(function* ({ news, output }) { + const team = yield* teamScope; + const desiredTarget = news.target ?? DEFAULT_TARGET; + const desiredProjectIds = (news.projects ?? []).map(resolveProjectId); + const desiredType = news.type ?? "encrypted"; + const valueHash = yield* hashValue(news.value); + + // Observe — the persisted id is a cache, not proof of existence; with + // no id, an unambiguous exact key match recovers a crashed create. + let observed: SharedEnvVarShape | undefined = output?.sharedEnvId + ? yield* getSharedEnvVar({ id: output.sharedEnvId, ...team }).pipe( + Effect.catchTag("NotFound", () => Effect.succeed(undefined)), + ) + : yield* findByKey(news.key); + + // Ensure — missing → create with the full desired shape. A Conflict + // (`existing_key_and_target`) means the key sprang into existence + // between observe and create — a race; re-observe and fall through to + // sync it instead. + if (observed === undefined) { + const created = yield* createSharedEnvVariable({ + evs: [ + { + key: news.key, + value: news.value, + ...(news.comment !== undefined ? { comment: news.comment } : {}), + }, + ], + type: desiredType, + target: desiredTarget, + ...(desiredProjectIds.length > 0 + ? { projectId: desiredProjectIds } + : {}), + ...team, + }).pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined))); + if (created !== undefined) { + yield* failFromEnvelope(created.failed); + const item = created.created[0]; + if (item === undefined) { + return yield* Effect.die( + "Vercel createSharedEnvVariable returned neither a created item nor a failure", + ); + } + return toAttributes(item, valueHash); + } + observed = yield* findByKey(news.key); + if (observed === undefined) { + return yield* Effect.die( + `Vercel rejected creating shared env var "${news.key}" as already existing, but no unambiguous variable with that key is visible`, + ); + } + } + + // Sync — diff observed cloud state against desired, PATCH only the + // delta. The observed value is readable for `encrypted` vars; for + // `sensitive` ones it is write-only, so drift falls back to the + // persisted content hash. + const valueDrifted = + observed.value !== undefined + ? observed.value !== news.value + : output?.valueHash !== valueHash; + const delta = { + ...(observed.key !== news.key ? { key: news.key } : {}), + ...(valueDrifted ? { value: news.value } : {}), + ...((observed.type ?? "encrypted") !== desiredType + ? { type: desiredType } + : {}), + ...(!sameMembers(observed.target, desiredTarget) + ? { target: desiredTarget } + : {}), + ...(!sameMembers(observed.projectId, desiredProjectIds) + ? { projectId: desiredProjectIds } + : {}), + ...((observed.comment ?? undefined) !== (news.comment ?? undefined) + ? { comment: news.comment ?? "" } + : {}), + }; + const id = observed.id; + if (id === undefined) { + return yield* Effect.die( + "Vercel shared env var response is missing its id", + ); + } + if (Object.keys(delta).length > 0) { + const updated = yield* updateSharedEnvVariable({ + updates: { [id]: delta }, + ...team, + }); + yield* failFromEnvelope(updated.failed); + const item = updated.updated[0]; + if (item !== undefined) return toAttributes(item, valueHash); + } + return toAttributes(observed, valueHash); + }), + delete: Effect.fn(function* ({ output }) { + const team = yield* teamScope; + const res = yield* deleteSharedEnvVariable({ + ids: [output.sharedEnvId], + ...team, + }).pipe( + // Already gone (out-of-band delete, or a re-run after a state + // persistence failure) is success, not an error. + Effect.catchTag("NotFound", () => Effect.succeed(undefined)), + ); + if (res !== undefined) { + yield* failFromEnvelope(res.failed, ["id_not_found"]); + } + }), + // Team-wide enumeration in one call (the API exposes no pagination + // inputs for this endpoint). + list: Effect.fn(function* () { + const team = yield* teamScope; + const res = yield* listSharedEnvVariable({ ...team }); + return res.data.map((item) => toAttributes(item, "")); + }), + }); diff --git a/packages/alchemy/src/Vercel/Environments/index.ts b/packages/alchemy/src/Vercel/Environments/index.ts new file mode 100644 index 0000000000..7b7b601d6b --- /dev/null +++ b/packages/alchemy/src/Vercel/Environments/index.ts @@ -0,0 +1 @@ +export * from "./SharedEnv.ts"; diff --git a/packages/alchemy/src/Vercel/FeatureFlags/FeatureFlag.ts b/packages/alchemy/src/Vercel/FeatureFlags/FeatureFlag.ts new file mode 100644 index 0000000000..96fb559b93 --- /dev/null +++ b/packages/alchemy/src/Vercel/FeatureFlags/FeatureFlag.ts @@ -0,0 +1,516 @@ +import * as featureFlags from "@distilled.cloud/vercel/feature_flags"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import { isResolved } from "../../Diff.ts"; +import { createPhysicalName } from "../../PhysicalName.ts"; +import * as Provider from "../../Provider.ts"; +import { Resource } from "../../Resource.ts"; +import type { Providers } from "../Providers.ts"; +import { teamScope } from "./internal.ts"; + +/** The value type of a feature flag. Immutable — changing it replaces the flag. */ +export type FlagKind = "boolean" | "string" | "number" | "json"; + +/** Lifecycle state of a flag. */ +export type FlagState = "active" | "archived"; + +/** One of the values a flag can resolve to, referenced by outcomes and rules. */ +export interface FlagVariant { + /** The id of the variant, referenced by outcomes (e.g. `"on"`). */ + id: string; + /** The value served when this variant is selected. */ + value: unknown; + /** A human-readable label for the variant. */ + label?: string; + /** A description of the variant. */ + description?: string; +} + +/** An outcome that resolves the flag to a single fixed variant. */ +export interface FlagVariantOutcome { + type: "variant"; + /** The id of the variant to serve. */ + variantId: string; +} + +/** + * An outcome a rule or fallthrough resolves to: a fixed variant, a weighted + * split, or a progressive rollout (mirrors the REST API's outcome grammar). + */ +export type FlagOutcome = + featureFlags.CreateFlagRequestEnvironmentsValueRulesItemOutcome; + +/** + * A targeting rule evaluated in order: an `id`, `conditions` AND-ed together + * (the REST API's `lhs`/`cmp`/`rhs` grammar), and the `outcome` served when + * they all match. + */ +export type FlagRule = featureFlags.CreateFlagRequestEnvironmentsValueRulesItem; + +/** + * Direct variant targeting, bypassing the flag's rules: + * variant id → attribute kind → attribute name → targeted values. + */ +export type FlagTargets = + featureFlags.CreateFlagRequestEnvironmentsValueTargetsMap; + +/** Per-environment configuration of a flag. */ +export interface FlagEnvironmentConfig { + /** + * Whether the flag is evaluated in this environment. When `false`, the + * `pausedOutcome` is served. + * @default true + */ + active?: boolean; + /** + * Outcome served while the environment is paused (`active: false`). The + * API requires one for every environment; when omitted it defaults to the + * environment's `fallthrough`, which must then be a fixed variant. + */ + pausedOutcome?: FlagVariantOutcome; + /** Outcome when no rule matches. */ + fallthrough: FlagOutcome; + /** + * Targeting rules evaluated in order. + * @default [] + */ + rules?: FlagRule[]; + /** Direct variant targeting, bypassing rules. */ + targets?: FlagTargets; + /** Link this environment to another environment's configuration. */ + reuse?: { active: boolean; environment: string }; +} + +/** + * A flag environment is misconfigured in a way the API would reject — + * surfaced at reconcile time before any API call is made. + */ +export class FeatureFlagConfigError extends Data.TaggedError( + "Vercel.FeatureFlagConfigError", +)<{ + readonly environment: string; + readonly message: string; +}> {} + +export interface FeatureFlagProps { + /** + * The project the flag lives on: a project id (`prj_…`) or project name. + * Changing the project replaces the flag. + */ + project: string; + /** + * A unique (per project) key for the flag, composed of letters, numbers, + * dashes, and underscores. If omitted, a unique slug is generated from + * `${app}-${stage}-${id}`. Changing the slug replaces the flag. + */ + slug?: string; + /** + * The value type of the flag. Immutable — changing it replaces the flag. + */ + kind: FlagKind; + /** + * The variants the flag can resolve to. Outcomes reference these by id. + */ + variants?: FlagVariant[]; + /** + * Per-environment configuration, keyed by environment (`production`, + * `preview`, `development`, or a custom environment slug). + */ + environments: Readonly>; + /** A description of the flag. */ + description?: string; + /** + * Lifecycle state of the flag. + * @default "active" + */ + state?: FlagState; + /** + * Whether the flag is marked permanent (should not be removed). + * @default false + */ + permanent?: boolean; + /** Tags for categorizing the flag. */ + tags?: string[]; + /** User ids of the flag's maintainers. */ + maintainerIds?: string[]; + /** + * A random seed preventing split points in different flags from sharing + * targets. Assigned by Vercel when omitted. + */ + seed?: number; +} + +export type FeatureFlag = Resource< + "Vercel.FeatureFlag", + FeatureFlagProps, + { + /** The flag id (`flg_…`). */ + flagId: string; + /** The flag's per-project key. */ + slug: string; + /** The id of the project the flag lives on. */ + projectId: string; + /** The value type of the flag. */ + kind: string; + /** Lifecycle state as reported by Vercel. */ + state: string; + /** Monotonic revision, bumped on every update. */ + revision: number; + /** The split-point seed assigned to the flag. */ + seed: number; + /** Creation time in epoch milliseconds. */ + createdAt: number; + /** Last update time in epoch milliseconds. */ + updatedAt: number; + }, + never, + Providers +>; + +type FeatureFlagAttributes = FeatureFlag["Attributes"]; + +/** + * A Vercel Feature Flag — a typed, per-project flag evaluated by the Flags + * SDK, with per-environment activation, targeting rules, and progressive + * rollouts. + * + * @resource + * @section Creating a flag + * @example A boolean flag + * ```typescript + * const project = yield* Vercel.Project("Api", {}); + * const flag = yield* Vercel.FeatureFlag("UploadsEnabled", { + * project: project.projectId, + * kind: "boolean", + * variants: [ + * { id: "on", value: true }, + * { id: "off", value: false }, + * ], + * environments: { + * production: { fallthrough: { type: "variant", variantId: "off" } }, + * preview: { fallthrough: { type: "variant", variantId: "on" } }, + * }, + * }); + * ``` + * + * @example A string flag with an explicit slug + * ```typescript + * yield* Vercel.FeatureFlag("Banner", { + * project: project.projectId, + * slug: "banner-text", + * kind: "string", + * variants: [ + * { id: "default", value: "Welcome!" }, + * { id: "sale", value: "Sale on now" }, + * ], + * environments: { + * production: { fallthrough: { type: "variant", variantId: "default" } }, + * }, + * }); + * ``` + * + * @section Pausing an environment + * @example Deactivate production, serving the paused outcome + * ```typescript + * yield* Vercel.FeatureFlag("UploadsEnabled", { + * project: project.projectId, + * kind: "boolean", + * variants: [ + * { id: "on", value: true }, + * { id: "off", value: false }, + * ], + * environments: { + * production: { + * active: false, + * pausedOutcome: { type: "variant", variantId: "off" }, + * fallthrough: { type: "variant", variantId: "on" }, + * }, + * }, + * }); + * ``` + * + * @see https://vercel.com/docs/feature-flags + */ +export const FeatureFlag = Resource("Vercel.FeatureFlag"); + +const createFlagSlug = (id: string) => + createPhysicalName({ id, lowercase: true }); + +/** The wire shape of one environment's config (API-required fields filled). */ +interface WireEnvironment { + active: boolean; + pausedOutcome: FlagVariantOutcome; + fallthrough: FlagOutcome; + rules: FlagRule[]; + targets?: FlagTargets; + reuse?: { active: boolean; environment: string }; +} + +const isVariantOutcome = (o: FlagOutcome): o is FlagVariantOutcome => + typeof o === "object" && + o !== null && + "variantId" in o && + !("base" in o) && + (o as { type?: unknown }).type === "variant"; + +const wireEnvironments = ( + environments: Readonly>, +) => + Effect.gen(function* () { + const out: Record = {}; + for (const [name, env] of Object.entries(environments)) { + const pausedOutcome = + env.pausedOutcome ?? + (isVariantOutcome(env.fallthrough) ? env.fallthrough : undefined); + if (pausedOutcome === undefined) { + return yield* Effect.fail( + new FeatureFlagConfigError({ + environment: name, + message: + "pausedOutcome is required when fallthrough is not a fixed variant outcome", + }), + ); + } + out[name] = { + active: env.active ?? true, + pausedOutcome, + fallthrough: env.fallthrough, + rules: env.rules ?? [], + ...(env.targets !== undefined ? { targets: env.targets } : {}), + ...(env.reuse !== undefined ? { reuse: env.reuse } : {}), + }; + } + return out; + }); + +/** Canonical JSON (sorted keys, `undefined` dropped) for drift comparison. */ +const canonical = (value: unknown): string => + JSON.stringify(value, function replacer(_key, v) { + if (v !== null && typeof v === "object" && !Array.isArray(v)) { + return Object.fromEntries( + Object.entries(v as Record) + .filter(([, x]) => x !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)), + ); + } + return v; + }); + +/** + * Normalize an environments map for comparison: strip response-only + * bookkeeping (per-environment `revision`) and empty `targets` maps so an + * omitted desired value never churns against an empty observed one. + */ +const normalizeEnvironments = (environments: Record) => + Object.fromEntries( + Object.entries(environments).map(([name, env]) => { + if (env !== null && typeof env === "object") { + const { + revision: _revision, + targets, + ...rest + } = env as Record; + const keepTargets = + targets !== null && + typeof targets === "object" && + Object.keys(targets as object).length > 0; + return [name, keepTargets ? { ...rest, targets } : rest]; + } + return [name, env]; + }), + ); + +const toAttributes = (flag: { + id: string; + slug: string; + projectId: string; + kind: string; + state: string; + revision: number; + seed: number; + createdAt: number; + updatedAt: number; +}): FeatureFlagAttributes => ({ + flagId: flag.id, + slug: flag.slug, + projectId: flag.projectId, + kind: flag.kind, + state: flag.state, + revision: flag.revision, + seed: flag.seed, + createdAt: flag.createdAt, + updatedAt: flag.updatedAt, +}); + +export const FeatureFlagProvider = () => + Provider.succeed(FeatureFlag, { + stables: ["flagId", "projectId", "slug", "kind", "seed", "createdAt"], + diff: Effect.fn(function* ({ olds, news, output }) { + if (!isResolved(news)) return undefined; + if (olds?.project !== undefined && news.project !== olds.project) { + return { action: "replace" } as const; + } + // The slug is the flag's identity within the project and updateFlag + // cannot rename; an explicit slug change is a replacement. (An + // omitted slug means the generated one, which is stable.) + const oldSlug = olds?.slug ?? output?.slug; + if ( + news.slug !== undefined && + oldSlug !== undefined && + news.slug !== oldSlug + ) { + return { action: "replace" } as const; + } + // The kind (value type) is immutable on the API. + const oldKind = output?.kind ?? olds?.kind; + if (oldKind !== undefined && news.kind !== oldKind) { + return { action: "replace" } as const; + } + return undefined; + }), + read: Effect.fn(function* ({ id, olds, output }) { + const scope = yield* teamScope; + const project = output?.projectId ?? olds?.project; + if (project === undefined) return undefined; + const flagIdOrSlug = + output?.flagId ?? + output?.slug ?? + olds?.slug ?? + (yield* createFlagSlug(id)); + const observed = yield* featureFlags + .getFlag({ projectIdOrName: project, flagIdOrSlug, ...scope }) + .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))); + return observed === undefined ? undefined : toAttributes(observed); + }), + reconcile: Effect.fn(function* ({ id, news, output }) { + const scope = yield* teamScope; + const slug = news.slug ?? output?.slug ?? (yield* createFlagSlug(id)); + const desiredEnvironments = yield* wireEnvironments(news.environments); + + // Observe — the cloud flag is authoritative; `output` only caches the + // stable id. + const observed = yield* featureFlags + .getFlag({ + projectIdOrName: news.project, + flagIdOrSlug: output?.flagId ?? slug, + ...scope, + }) + .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))); + + // Ensure — missing → create. A Conflict (`flag_exists`) is a + // concurrent-create race: re-read the winner and fall through. + if (observed === undefined) { + const created = yield* featureFlags + .createFlag({ + projectIdOrName: news.project, + slug, + kind: news.kind, + environments: desiredEnvironments, + ...(news.variants !== undefined ? { variants: news.variants } : {}), + ...(news.description !== undefined + ? { description: news.description } + : {}), + ...(news.state !== undefined ? { state: news.state } : {}), + ...(news.permanent !== undefined + ? { permanent: news.permanent } + : {}), + ...(news.tags !== undefined ? { tags: news.tags } : {}), + ...(news.maintainerIds !== undefined + ? { maintainerIds: news.maintainerIds } + : {}), + ...(news.seed !== undefined ? { seed: news.seed } : {}), + ...scope, + }) + .pipe( + Effect.catchTag("Conflict", () => + featureFlags.getFlag({ + projectIdOrName: news.project, + flagIdOrSlug: slug, + ...scope, + }), + ), + ); + return toAttributes(created); + } + + // Sync — diff OBSERVED cloud state against the desired wire shape and + // PATCH only the drifted fields (updateFlag supports partial updates). + const patch: { + environments?: typeof desiredEnvironments; + variants?: FlagVariant[]; + description?: string; + state?: FlagState; + permanent?: boolean; + tags?: string[]; + maintainerIds?: string[]; + seed?: number; + } = {}; + if ( + canonical( + normalizeEnvironments( + observed.environments as Record, + ), + ) !== canonical(normalizeEnvironments(desiredEnvironments)) + ) { + patch.environments = desiredEnvironments; + } + if ( + news.variants !== undefined && + canonical(observed.variants) !== canonical(news.variants) + ) { + patch.variants = news.variants; + } + if ((observed.description ?? undefined) !== news.description) { + patch.description = news.description ?? ""; + } + const desiredState = news.state ?? "active"; + if (observed.state !== desiredState) patch.state = desiredState; + const desiredPermanent = news.permanent ?? false; + if ((observed.permanent ?? false) !== desiredPermanent) { + patch.permanent = desiredPermanent; + } + if ( + news.tags !== undefined && + canonical([...(observed.tags ?? [])].sort()) !== + canonical([...news.tags].sort()) + ) { + patch.tags = news.tags; + } + if ( + news.maintainerIds !== undefined && + canonical([...(observed.maintainerIds ?? [])].sort()) !== + canonical([...news.maintainerIds].sort()) + ) { + patch.maintainerIds = news.maintainerIds; + } + if (news.seed !== undefined && observed.seed !== news.seed) { + patch.seed = news.seed; + } + + if (Object.keys(patch).length === 0) { + return toAttributes(observed); + } + const updated = yield* featureFlags.updateFlag({ + projectIdOrName: news.project, + flagIdOrSlug: observed.id, + ...patch, + ...scope, + }); + return toAttributes(updated); + }), + delete: Effect.fn(function* ({ output }) { + const scope = yield* teamScope; + // Already gone (out-of-band delete, or a deleted host project) is + // success, not an error. + yield* featureFlags + .deleteFlag({ + projectIdOrName: output.projectId, + flagIdOrSlug: output.flagId, + ...scope, + }) + .pipe( + Effect.asVoid, + Effect.catchTag("NotFound", () => Effect.void), + ); + }), + }); diff --git a/packages/alchemy/src/Vercel/FeatureFlags/index.ts b/packages/alchemy/src/Vercel/FeatureFlags/index.ts new file mode 100644 index 0000000000..a98109722a --- /dev/null +++ b/packages/alchemy/src/Vercel/FeatureFlags/index.ts @@ -0,0 +1 @@ +export * from "./FeatureFlag.ts"; diff --git a/packages/alchemy/src/Vercel/FeatureFlags/internal.ts b/packages/alchemy/src/Vercel/FeatureFlags/internal.ts new file mode 100644 index 0000000000..0c2a9437e4 --- /dev/null +++ b/packages/alchemy/src/Vercel/FeatureFlags/internal.ts @@ -0,0 +1,19 @@ +// Shared scaffolding for the FeatureFlags service — NOT exported from +// `FeatureFlags/index.ts` (generic helper names must not leak into the flat +// `Vercel` namespace). +import * as Effect from "effect/Effect"; +import { VercelEnvironment } from "../VercelEnvironment.ts"; + +/** + * Vercel scopes team requests via a per-op `teamId` query parameter, resolved + * INSIDE lifecycle operations and omitted entirely when undefined (personal + * scope). + */ +export const teamScope: Effect.Effect< + { teamId?: string }, + never, + VercelEnvironment +> = Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + return teamId === undefined ? {} : { teamId }; +}); diff --git a/packages/alchemy/src/Vercel/Functions/CronEventSource.ts b/packages/alchemy/src/Vercel/Functions/CronEventSource.ts new file mode 100644 index 0000000000..137201a11b --- /dev/null +++ b/packages/alchemy/src/Vercel/Functions/CronEventSource.ts @@ -0,0 +1,123 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Namespace from "../../Namespace.ts"; +import { RuntimeContext } from "../../RuntimeContext.ts"; +import type { VercelFunctionContext } from "./FunctionBridge.ts"; +import { Function } from "./Function.ts"; + +/** + * Cron handler routes are registered under this stable prefix; the bridge + * dispatches them BEFORE user `fetch`, guarded by `CRON_SECRET`. + */ +export const CRON_PATH_PREFIX = "/_alchemy/cron/"; + +/** + * Subscribe a Vercel Function to a cron schedule with an Effect handler. + * + * A single call wires both halves of a scheduled Function: + * + * - **Deploy-time**: contributes `{ crons: [{ path, schedule }] }` through + * the Function's binding contract — the deploy engine lowers it into the + * Build Output `config.json`, and the provider auto-mints a sensitive + * `CRON_SECRET` project env var (Vercel's cron invoker sends + * `Authorization: Bearer $CRON_SECRET` whenever that env var exists). + * - **Runtime**: registers the handler under a stable internal route + * (`/_alchemy/cron/{n}`); the bridge routes it before user `fetch` and + * verifies the bearer with a constant-time comparison — an unauthorized + * request is a 401 and never reaches the handler. + * + * Platform semantics (D3/D8): crons are deployment artifacts, UTC, and fire + * only on **production** deployments — a preview/tenant stage's crons never + * run. Hobby plans are limited to daily schedules (a sub-daily schedule + * fails the deployment, not the plan). A failing handler responds 500 so + * Vercel records the invocation as failed; express retries declaratively + * with `Effect.retry` inside the handler. + * + * Requires `CronEventSourceLive` provided on the Function's Effect. + * + * @binding + * @product Functions + * + * @section Declare a schedule + * @example Nightly cleanup + * ```typescript + * import * as Vercel from "alchemy/Vercel"; + * import * as Effect from "effect/Effect"; + * import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; + * + * export default Vercel.Function( + * "Api", + * { main: import.meta.url }, + * Effect.gen(function* () { + * yield* Vercel.cron("0 3 * * *", Effect.log("nightly cleanup")); + * + * return { + * fetch: Effect.succeed(HttpServerResponse.text("ok")), + * }; + * }).pipe(Effect.provide(Vercel.CronEventSourceLive)), + * ); + * ``` + * + * @section Multiple schedules + * @example One handler per expression + * ```typescript + * // Each handler gets its own guarded route, so a daily fire never runs + * // the hourly handler. + * yield* Vercel.cron("0 * * * *", syncFeeds); + * yield* Vercel.cron("0 0 * * *", purgeExpired); + * ``` + * + * @see https://vercel.com/docs/cron-jobs + */ +export const cron = ( + schedule: string, + handler: Effect.Effect, +): Effect.Effect> => + CronEventSource.use((source) => source(schedule, handler)); + +export type CronEventSourceService = ( + schedule: string, + handler: Effect.Effect, +) => Effect.Effect>; + +export class CronEventSource extends Context.Service< + CronEventSource, + CronEventSourceService +>()("Vercel.Functions.CronEventSource") {} + +export const CronEventSourceLive = Layer.effect( + CronEventSource, + Effect.gen(function* () { + const host = yield* Function; + // Registration-order index: the SAME init code runs at plan time and + // inside the deployed bundle, so the deploy-time path in `config.json` + // and the runtime route registration always agree. + let counter = 0; + return Effect.fn(function* ( + schedule: string, + handler: Effect.Effect, + ) { + const sid = counter++; + const path = `${CRON_PATH_PREFIX}${sid}`; + // Deploy-time: contribute the cron entry to the host Function's + // binding channel. Skipped once running inside the deployed bundle + // (the global guard), where the only work is registering the runtime + // handler below. + if (!globalThis.__ALCHEMY_RUNTIME__) { + yield* Namespace.push( + host.LogicalId, + host.bind(`Cron(${sid}:${schedule})`, { + crons: [{ path, schedule }], + }), + ); + } + + const ctx = (yield* RuntimeContext) as unknown as VercelFunctionContext; + yield* ctx.registerCron( + path, + handler as Effect.Effect, + ); + }) as CronEventSourceService; + }), +); diff --git a/packages/alchemy/src/Vercel/Functions/DevShim.ts b/packages/alchemy/src/Vercel/Functions/DevShim.ts new file mode 100644 index 0000000000..d3353acf3b --- /dev/null +++ b/packages/alchemy/src/Vercel/Functions/DevShim.ts @@ -0,0 +1,297 @@ +/** + * The Vercel local dev shim — a Node HTTP server that emulates Vercel's + * Node launcher for `alchemy dev`. + * + * The `LocalFunctionProvider` bundles this module INTO the dev bundle (a + * generated virtual entry imports the user's module — or the generated + * Effect bridge — and calls {@link startVercelFunctionDevServer}), then + * runs the emitted bundle as a child process. One child per Function keeps + * `process.env` faithful to the deployed instance (each Vercel Function + * runs in its own process with its own env). + * + * **Launcher matrix** (dev/prod skew here is a bug factory — this shim + * implements the FULL matrix Vercel's Node launcher accepts): + * + * 1. `export default { fetch }` — web-standard handler object. + * 2. `export default function` — web `(request: Request) => Response` when + * the function declares 0–1 parameters, Node `(req, res)` style when it + * declares 2+. + * 3. Method exports (`export function GET/POST/...`) — routed by request + * method; a missing `HEAD` falls back to `GET` (the launcher strips the + * body); an unexported method is a 405 with an `Allow` header. + * + * Also emulated: + * + * - **`waitUntil`** — the `Symbol.for("@vercel/request-context")` contract + * `@vercel/functions` wraps: handed promises are tracked and drained on + * SIGTERM before exit (mirroring Fluid's shutdown grace window). + * - **Platform request headers** — `x-vercel-id`, deployment URL, geo and + * forwarding headers are injected when absent so header-reading code + * behaves like it does deployed. + * - **Queue seam** — when the generated entry passes `queueFetch` (the + * queue-mode Effect bridge), requests to {@link QUEUE_DELIVERY_PATH} are + * dispatched to it. This is the local queue broker's delivery endpoint + * (see `LocalFunctionInstance.queueEndpoint` in `Vercel/LocalRuntime.ts`). + * + * This module MUST stay free of `effect`/alchemy imports: it is bundled + * into async-mode functions that deliberately ship no Effect runtime. + */ +import * as http from "node:http"; +import { Readable, type Duplex } from "node:stream"; + +/** Marker line printed to stdout once the server is listening. */ +export const DEV_READY_PREFIX = "__ALCHEMY_VERCEL_DEV_READY__["; +export const DEV_READY_SUFFIX = "]__"; + +/** + * The dev-only route queue deliveries are POSTed to (proxied through the + * function's stable local URL). Only served when the entry passed a + * `queueFetch` dispatch. + */ +export const QUEUE_DELIVERY_PATH = "/_alchemy/dev/queue"; + +/** Env var carrying the port the shim should listen on (`0` = ephemeral). */ +export const DEV_PORT_ENV = "ALCHEMY_DEV_PORT"; + +export interface VercelDevShimOptions { + /** + * The Function's module namespace (async mode) or `{ default: bridge }` + * (Effect mode, where `bridge` is the `makeVercelBridge` result). + */ + readonly module: Record; + /** + * The queue-mode bridge's `fetch`, when the Function registered queue + * subscriptions. Dispatches {@link QUEUE_DELIVERY_PATH} requests. + */ + readonly queueFetch?: (request: Request) => Promise; +} + +const METHODS_WITH_BODY = new Set(["POST", "PUT", "PATCH", "DELETE"]); +const METHOD_EXPORTS = [ + "GET", + "HEAD", + "POST", + "PUT", + "PATCH", + "DELETE", + "OPTIONS", +] as const; + +const toWebRequest = (req: http.IncomingMessage): Request => { + const host = req.headers.host ?? "localhost"; + const url = `http://${host}${req.url ?? "/"}`; + const headers = new Headers(); + for (const [key, value] of Object.entries(req.headers)) { + if (value === undefined) continue; + if (Array.isArray(value)) { + for (const item of value) headers.append(key, item); + } else { + headers.set(key, value); + } + } + const method = req.method ?? "GET"; + return new Request(url, { + method, + headers, + body: METHODS_WITH_BODY.has(method) + ? (Readable.toWeb(req) as unknown as BodyInit) + : undefined, + // Node requires half-duplex for streamed request bodies. + ...({ duplex: "half" } as object), + }); +}; + +const writeWebResponse = async ( + res: http.ServerResponse, + response: Response, + options?: { readonly stripBody?: boolean }, +): Promise => { + res.statusCode = response.status; + response.headers.forEach((value, key) => { + if (key === "set-cookie") return; + res.setHeader(key, value); + }); + const setCookie = response.headers.getSetCookie?.() ?? []; + if (setCookie.length > 0) res.setHeader("set-cookie", setCookie); + if (response.body === null || options?.stripBody) { + res.end(); + return; + } + await new Promise((resolve, reject) => { + const body = Readable.fromWeb( + response.body as unknown as Parameters[0], + ); + body.once("error", reject); + res.once("close", resolve); + res.once("error", reject); + body.pipe(res as unknown as Duplex, { end: true }); + }); +}; + +/** + * Inject the platform headers Vercel puts on every request, when absent — + * so header-reading user code (request ids, geo, forwarding info) behaves + * like it does deployed. + */ +const injectPlatformHeaders = (req: http.IncomingMessage): void => { + const setDefault = (key: string, value: string | undefined) => { + if (value !== undefined && req.headers[key] === undefined) { + req.headers[key] = value; + } + }; + setDefault( + "x-vercel-id", + `dev1::dev1::${Math.random().toString(36).slice(2, 12)}`, + ); + setDefault("x-vercel-deployment-url", process.env.VERCEL_URL); + setDefault("x-real-ip", "127.0.0.1"); + setDefault("x-forwarded-for", "127.0.0.1"); + setDefault("x-forwarded-host", req.headers.host); + setDefault("x-forwarded-proto", "http"); + setDefault("x-vercel-ip-country", "US"); + setDefault("x-vercel-ip-country-region", "CA"); + setDefault("x-vercel-ip-city", "Localhost"); + setDefault("x-vercel-ip-latitude", "37.7749"); + setDefault("x-vercel-ip-longitude", "-122.4194"); + setDefault("x-vercel-ip-timezone", "America/Los_Angeles"); +}; + +type WebHandler = (request: Request) => Response | Promise; +type NodeHandler = ( + req: http.IncomingMessage, + res: http.ServerResponse, +) => unknown; + +type Dispatch = ( + req: http.IncomingMessage, + res: http.ServerResponse, +) => Promise; + +/** Resolve the module's export shape to a single dispatch function. */ +const resolveDispatch = (mod: Record): Dispatch => { + const dflt = mod.default; + const webDispatch = + (handler: WebHandler, stripBody = false): Dispatch => + async (req, res) => { + const response = await handler(toWebRequest(req)); + await writeWebResponse(res, response, { + stripBody: stripBody || req.method === "HEAD", + }); + }; + if ( + typeof dflt === "object" && + dflt !== null && + typeof (dflt as { fetch?: unknown }).fetch === "function" + ) { + const fetchHandler = (dflt as { fetch: WebHandler }).fetch.bind(dflt); + return webDispatch(fetchHandler); + } + if (typeof dflt === "function") { + // Arity heuristic (matching the launcher): a `(req, res)` Node handler + // declares two parameters; a web handler declares at most one. + if (dflt.length >= 2) { + const nodeHandler = dflt as NodeHandler; + return async (req, res) => { + await nodeHandler(req, res); + }; + } + return webDispatch(dflt as WebHandler); + } + // Method exports. + const methodHandlers = new Map(); + for (const method of METHOD_EXPORTS) { + const handler = mod[method]; + if (typeof handler === "function") { + methodHandlers.set(method, handler as WebHandler); + } + } + if (methodHandlers.size > 0) { + return async (req, res) => { + const method = req.method ?? "GET"; + const handler = + methodHandlers.get(method) ?? + // The launcher serves HEAD with the GET handler, body stripped. + (method === "HEAD" ? methodHandlers.get("GET") : undefined); + if (handler === undefined) { + res.statusCode = 405; + res.setHeader("allow", Array.from(methodHandlers.keys()).join(", ")); + res.end("Method Not Allowed"); + return; + } + await webDispatch(handler, method === "HEAD")(req, res); + }; + } + return async (_req, res) => { + res.statusCode = 500; + res.end( + "No handler export found — export default { fetch }, a default function, or method exports (GET/POST/...)", + ); + }; +}; + +/** + * Start the dev server for the given module. Called by the generated dev + * bundle entry; never imported by deployed code. + */ +export const startVercelFunctionDevServer = ( + options: VercelDevShimOptions, +): void => { + // ── waitUntil shim: the `@vercel/request-context` contract. + const pending = new Set>(); + const waitUntil = (promise: Promise): void => { + const tracked = Promise.resolve(promise) + .catch(() => {}) + .finally(() => pending.delete(tracked)); + pending.add(tracked); + }; + (globalThis as Record & typeof globalThis)[ + Symbol.for("@vercel/request-context") + ] = { get: () => ({ waitUntil }) }; + + const dispatch = resolveDispatch(options.module); + const queueFetch = options.queueFetch; + + const server = http.createServer((req, res) => { + injectPlatformHeaders(req); + const pathname = (req.url ?? "/").split("?")[0]; + const run = + queueFetch !== undefined && pathname === QUEUE_DELIVERY_PATH + ? toWebRequest(req) + : undefined; + void ( + run !== undefined + ? queueFetch!(run).then((response) => writeWebResponse(res, response)) + : dispatch(req, res) + ).catch((error) => { + // eslint-disable-next-line no-console + console.error("[alchemy dev] request failed", error); + if (!res.headersSent) { + res.statusCode = 500; + } + res.end(); + }); + }); + + const port = Number(process.env[DEV_PORT_ENV] ?? "0"); + server.listen(port, "127.0.0.1", () => { + const address = server.address(); + const boundPort = + typeof address === "object" && address !== null ? address.port : port; + // The readiness marker the provider watches stdout for. + // eslint-disable-next-line no-console + console.log( + `${DEV_READY_PREFIX}http://127.0.0.1:${boundPort}${DEV_READY_SUFFIX}`, + ); + }); + + // Graceful shutdown: stop accepting, drain waitUntil promises (bounded — + // Fluid's real window is ~500ms; locally we allow a little more), exit. + process.once("SIGTERM", () => { + server.close(); + const drain = Promise.all(Array.from(pending)); + void Promise.race([ + drain, + new Promise((resolve) => setTimeout(resolve, 3_000)), + ]).finally(() => process.exit(0)); + }); +}; diff --git a/packages/alchemy/src/Vercel/Functions/Function.ts b/packages/alchemy/src/Vercel/Functions/Function.ts new file mode 100644 index 0000000000..97deed65e7 --- /dev/null +++ b/packages/alchemy/src/Vercel/Functions/Function.ts @@ -0,0 +1,1421 @@ +import type { Credentials } from "@distilled.cloud/vercel/Credentials"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import type * as rolldown from "rolldown"; +import { Unowned } from "../../AdoptPolicy.ts"; +import type * as Bundle from "../../Bundle/Bundle.ts"; +import { isResolved } from "../../Diff.ts"; +import type { InputProps } from "../../Input.ts"; +import * as Output from "../../Output.ts"; +import { createPhysicalName } from "../../PhysicalName.ts"; +import { Platform, type Main, type PlatformProps } from "../../Platform.ts"; +import * as Provider from "../../Provider.ts"; +import type { Resource, ResourceBinding } from "../../Resource.ts"; +import * as crypto from "node:crypto"; +import type { RuntimeContext } from "../../RuntimeContext.ts"; +import type { Self as SelfService } from "../../Self.ts"; +import { isYieldableEffectLike } from "../../Util/effect.ts"; +import { sha256 } from "../../Util/sha256.ts"; +import { + fromBuildOutputDir, + fromFunctionBundle, + type BuildOutputRoute, + type CronEntry, + type QueueTriggerEntry, + type VcConfig, +} from "../Deploy/BuildOutput.ts"; +import { + ALCHEMY_META_KEY, + deleteAliasByIdOrName, + deleteDeploymentById, + deleteProjectByIdOrName, + deployArtifact, + ensureProject, + ensureProtectionBypass, + ensureStageAlias, + findAutomationBypassSecret, + findDeploymentByContentHash, + readAssignedProductionUrl, + hashDesiredEnv, + listOwnDeployments, + listProjectEnvs, + makeDeploymentMeta, + makeOwnershipStamp, + observeProject, + readOwnershipStamp, + readProductionUrl, + stageAliasName, + syncProjectEnv, + syncProjectSettings, + type DesiredEnv, + type ManagedEnvEntry, + type ProjectSettingsDesired, + type StageAliasResult, +} from "../Deploy/Engine.ts"; +import type { Providers } from "../Providers.ts"; +import { + artifactFromSource, + type FunctionSourceDescriptor, +} from "../Website/Source.ts"; +import { VercelEnvironment } from "../VercelEnvironment.ts"; +import type { MemoryTier } from "../Projects/Project.ts"; +import { + CRON_SECRET_ENV, + makeVercelFunctionContext, + type FunctionEnvironment, + type VercelFunctionContext, +} from "./FunctionBridge.ts"; +import { makeFunctionBundler } from "./FunctionBundle.ts"; +import { VercelFunctionLogs } from "./Logs.ts"; + +export const FunctionTypeId = "Vercel.Function" as const; +export type FunctionTypeId = typeof FunctionTypeId; + +export const isFunction = (value: any): value is Function => + typeof value === "object" && + value !== null && + "Type" in value && + value.Type === FunctionTypeId; + +export interface FunctionBuildOptions + extends Partial, Bundle.BundleExtraOptions { + readonly output?: Partial; +} + +export type FunctionRuntime = "nodejs22.x" | "nodejs24.x" | "bun1.x"; + +export interface FunctionProps extends PlatformProps { + /** + * Entry module bundled with rolldown. The module exports web-standard + * handlers that Vercel's Node launcher accepts natively — a default + * `{ fetch }` export, method exports (`export function GET`), or + * `(req, res)` style. Exactly one of `main`, `script`, `prebuilt`, or + * `source` is required. + */ + main?: string; + /** Inline module source shipped verbatim as `index.mjs` (no bundling). */ + script?: string; + /** + * Path to a prebuilt `.vercel/output` (Build Output v3) directory, + * deployed as-is. Escape hatch for framework adapters. + */ + prebuilt?: string; + /** + * A built website source descriptor — how `Vercel.Website.*` transformers + * feed the deploy engine (`kind: "static"` wraps a directory in a + * static-only artifact; `kind: "buildOutput"` passes a complete + * `.vercel/output` tree through). Mutually exclusive with `main`, + * `script`, and `prebuilt`. + */ + source?: FunctionSourceDescriptor; + /** + * Explicit Vercel project name. If omitted, a unique name is generated + * from `${stack}-${id}-${stage}`. Changing an explicit name replaces the + * function's project. + */ + name?: string; + /** + * Tenant mode: deploy into an existing project (a `Vercel.Project`'s + * `projectId`, or a foreign project id/name) instead of auto-provisioning + * one. In tenant mode the Function owns only its deployments, its stable + * per-stage alias, and the env it delivers — never the project's + * settings or lifecycle — and deploys `target: "preview"` by default. + * + * Preview tenants (the default) are **additive**: any number of stages + * (PR stacks, per-dev scratch) can share one project without clobbering + * each other. Each stage serves from a stable + * `{projectName}-{stage}.vercel.app` alias (SSO-gated on team accounts — + * drive it with the `protectionBypass` secret), and its env is delivered + * per-deployment via the generated `.vc-config.json` — a tenant env key + * that already exists in project env is a typed + * `Vercel.TenantEnvConflict` error, because project env would silently + * shadow it (live-verified precedence). Destroying the stage removes + * only its alias and its meta-stamped deployments, leaving the shared + * project and every other stage untouched. + * + * Caveat (live-verified platform behavior): when the shared project has + * NO production deployment yet, Vercel auto-promotes the first + * deployment to production even though the tenant requested a preview — + * the tenant's content then serves on the project's production domain + * until a real production deployment supersedes it. Deploy a production + * owner first if that matters. A preview-intent tenant still destroys + * cleanly (deleting its auto-promoted deployment restores the project's + * pre-tenant state); only an explicit `target: "production"` tenant + * refuses to delete its active production deployment on destroy. + */ + project?: string; + /** + * Environment variables synced into project env. Values may be strings, + * JSON-serializable values, `Redacted` secrets (become `sensitive` env + * vars with fingerprint-comment drift detection), or `Output`s of other + * resources' attributes. + */ + env?: Record; + /** + * Node runtime for the function. + * @default "nodejs22.x" + */ + runtime?: FunctionRuntime; + /** Fluid compute resources. */ + resources?: { + /** + * Memory tier (project-level default on owned projects). + * @default "standard" + */ + memory?: MemoryTier; + /** Max duration in seconds. */ + maxDuration?: number; + }; + /** Regions to deploy the function to (e.g. `["iad1"]`). */ + regions?: string[]; + /** + * Deployment target. + * @default "production" (owned mode) / "preview" (tenant mode) + */ + target?: "production" | "preview"; + /** Bundler configuration for {@link main}. */ + build?: FunctionBuildOptions; + /** + * Local dev-server options (`alchemy dev`). Ignored by live deploys. + */ + dev?: { + /** + * Preferred port for the function's stable local URL. Falls back to an + * ephemeral port when unavailable (a warning is logged). + * @default an ephemeral port, stable for the dev session + */ + port?: number; + }; +} + +export interface Function extends Resource< + FunctionTypeId, + FunctionProps, + { + projectId: string; + projectName: string; + deploymentId: string; + /** + * The function's URL — always read back from Vercel (assigned + * production domain, or the deployment-specific URL for previews); + * never computed. + */ + url: string | undefined; + /** + * The stable per-stage alias a preview tenant claims on the shared + * project — `{projectName}-{stage}.vercel.app` (or the deterministic + * suffix fallback when that hostname is taken). `undefined` outside + * tenant-preview mode. Assigned via `assignAlias` (an upsert) after + * every deploy, so any number of PR stages coexist in one project, + * each behind its own stable hostname (DESIGN §5.1). + */ + stageAlias: { uid: string; alias: string } | undefined; + hash: { + /** Full artifact tree hash (THE deploy skip key). */ + artifact: string; + /** Bundle-only identity hash (diff-time change detection). */ + code: string; + /** Desired-env hash (env changes force a redeploy). */ + env: string; + }; + /** + * Env rows alchemy manages on the project — removal baseline, plus the + * persisted fingerprint/id for `sensitive` rows (which Vercel never + * returns from the env listing). + */ + managedEnv: ManagedEnvEntry[]; + /** + * The project's automation protection-bypass secret (DESIGN §6.7). + * Minted during project ensure BEFORE the first deploy (bypass secrets + * only open deployments created after them — live-verified) and never + * regenerated; send it as `x-vercel-protection-bypass` (header or query + * param) to reach SSO-protected deployment URLs. `undefined` only in + * tenant mode when the foreign project has no existing secret. + */ + protectionBypass: Redacted.Redacted | undefined; + /** + * The auto-minted secret guarding cron routes: synced into project env + * as the sensitive `CRON_SECRET` var (Vercel's cron invoker sends + * `Authorization: Bearer $CRON_SECRET` whenever it exists) and verified + * by the bridge with a constant-time comparison. Stable across + * reconciles; present only while crons are registered. + */ + cronSecret: Redacted.Redacted | undefined; + }, + { + env?: Record; + crons?: CronEntry[]; + routes?: BuildOutputRoute[]; + queues?: QueueTriggerEntry[]; + }, + Providers +> {} + +export type FunctionServices = + | Credentials + | VercelEnvironment + | FunctionEnvironment + // The ambient host resource — capability impl layers (e.g. + // `ReadEdgeConfigHttp`) resolve it to key per-Function child resources + // (mirrors `SelfService` in Cloudflare's WorkerServices). + | SelfService; + +export type FunctionShape = Main; + +/** + * Map a Function's `env` prop to its runtime manifestation — every value + * arrives in `process.env` as a string on Vercel: + * + * ```ts + * export type ApiEnv = Vercel.InferEnv; + * const env = process.env as ApiEnv; + * ``` + */ +export type InferEnv = + F extends Effect.Effect + ? InferEnv + : F extends { Props: infer P } + ? P extends { env?: infer E } + ? { [K in keyof Exclude & string]: string } + : {} + : never; + +/** The project referenced by a tenant-mode `project:` prop does not exist. */ +export class TenantProjectNotFound extends Data.TaggedError( + "Vercel.TenantProjectNotFound", +)<{ + readonly message: string; + readonly project: string; +}> {} + +/** + * A tenant Function's env key already exists in the shared project's env + * (DESIGN §5.1). Tenant env is delivered per-deployment via + * `.vc-config.json` `environment`, and project env WINS on key conflict + * (live-verified, PROBES.md probe 3) — the tenant's value would be + * silently shadowed, so the conflict is a hard typed error, not advisory. + */ +export class TenantEnvConflict extends Data.TaggedError( + "Vercel.TenantEnvConflict", +)<{ + readonly message: string; + readonly projectId: string; + /** The env keys present both on the tenant and in project env. */ + readonly keys: ReadonlyArray; +}> {} + +/** + * Tenant-mode destroy refuses to delete a foreign project's active + * production deployment (DESIGN §5.3). + */ +export class TenantProductionDeleteRefused extends Data.TaggedError( + "Vercel.TenantProductionDeleteRefused", +)<{ + readonly message: string; + readonly projectId: string; + readonly deploymentId: string; +}> {} + +/** The env key the resolved URL is injected under when `yield*`-ed. */ +const SELF_URL_BINDING_NAME = "FUNCTION_URL"; + +const SELF_URL_KIND = "Vercel.Functions.URL" as const; + +/** + * The serializable marker a `Function.URL` binding carries through the env + * channel. The provider substitutes it with the project's READ-BACK + * production URL during env sync — the computed `{name}.vercel.app` is + * never load-bearing (DESIGN §6.5). + */ +export const SELF_URL_MARKER = { "~alchemy/Kind": SELF_URL_KIND } as const; + +/** + * Effect-native accessor for the Function's own URL. The value is injected + * as a project env var that only exists on the deployed Function, so + * reading it is deferred behind an Effect that requires + * {@link RuntimeContext}. Yield it inside a handler to obtain the URL + * string. + */ +export type URLAccessor = Effect.Effect; + +/** + * The type of `Function.URL`. + * + * It is a real `Effect` — `yield* Vercel.Function.URL` inside a Function + * init attaches the env binding and resolves the deferred + * {@link URLAccessor} — but it also carries the `~alchemy/Kind` marker + * statically, so when declared on a Function's `env` the provider + * recognises it as a self-URL sentinel (`isSelfUrl`) instead of running it. + */ +export interface URLEffect extends Effect.Effect { + "~alchemy/Kind": typeof SELF_URL_KIND; +} + +/** + * Returns true when the value is a `Function.URL` sentinel — either the + * yieldable `URLEffect` or the plain serialized marker it lowers to on the + * binding channel. The URLEffect is a real Effect, so every env-resolution + * site must check this before `Effect.isEffect`. + */ +export const isSelfUrl = ( + value: unknown, +): value is URLEffect | typeof SELF_URL_MARKER => + typeof value === "object" && + value !== null && + "~alchemy/Kind" in value && + (value as { "~alchemy/Kind": unknown })["~alchemy/Kind"] === SELF_URL_KIND; + +/** + * Lower the async `env:` prop onto the binding channel contract. In v1 + * (async mode, no capabilities) plain values and attribute Outputs stay in + * props — the engine resolves them before `reconcile` — so this hook only + * validates: whole-resource Outputs and Effect-valued entries (capability + * bindings, which don't exist for Vercel yet) fail loudly instead of + * silently uploading garbage. + */ +export const bindFunctionAsyncEnv = Effect.fn(function* ( + resource: Function, + props: InputProps, +) { + if (globalThis.__ALCHEMY_RUNTIME__) return; + // The env prop may itself be an unresolved Input (Config/Effect/Output + // wrapper) — validation only applies to the plain-record shape; the + // engine resolves the rest before reconcile. + if ( + !props.env || + typeof props.env !== "object" || + Output.isOutput(props.env) || + isYieldableEffectLike(props.env) + ) { + return; + } + const env = props.env as Record; + for (const bindingName in env) { + const value = env[bindingName]; + // `Function.URL` is Effect-shaped but is a sentinel, not a runnable env + // value — the provider substitutes the read-back production URL during + // env sync. Check it before the Output/Effect guards below. It is also + // mirrored onto the binding channel as its PLAIN marker: the local + // (dev) provider's config pipeline strips Effect-valued prop leaves, + // so the binding-channel copy is what carries the self-URL request + // into `alchemy dev`. The live provider reads the sentinel from the + // merged env either way, so the duplicate key is harmless. + if (isSelfUrl(value)) { + yield* resource.bind(bindingName, { + env: { [bindingName]: SELF_URL_MARKER }, + }); + continue; + } + if (Output.isResourceExpr(value) || Output.isRefExpr(value)) { + return yield* Effect.die( + `Cannot bind whole-resource Output "${bindingName}": bind one of the resource's attribute Outputs instead`, + ); + } + if (isYieldableEffectLike(value) && !Output.isOutput(value)) { + return yield* Effect.die( + `Vercel.Function env "${bindingName}": capability bindings are not supported yet — pass a plain value, Redacted secret, or attribute Output`, + ); + } + } +}); + +/** + * A Vercel Function — a Fluid compute function deployed as an immutable + * prebuilt Deployment of its own auto-provisioned Vercel Project. + * + * Two modes share one resource: + * + * - **Effect mode** (class/inline impl): the same two-phase pattern as + * `Cloudflare.Worker` — the init Effect registers bindings and returns + * `{ fetch }` handlers; the generated bundle wraps `main` with the Vercel + * runtime bridge (layer stack built once per instance, fresh Scope per + * request, request finalizers settle post-response via `waitUntil`). + * - **Async mode** (no impl): `main` exports web-standard handlers + * directly; no Effect runtime ships in the bundle. Bindings flow through + * project env vars (`InferEnv` types the runtime cast). + * + * @resource + * @section Effect Functions + * @example Effect-native Function (two-phase) + * ```typescript + * import * as Vercel from "alchemy/Vercel"; + * import * as Effect from "effect/Effect"; + * import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; + * import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; + * + * export default class Api extends Vercel.Function()( + * "Api", + * { main: import.meta.url }, + * Effect.gen(function* () { + * // init phase — runs at plan time AND once per instance at runtime + * const selfUrl = yield* Vercel.Function.URL; + * + * return { + * // runtime phase — fresh Scope per request + * fetch: Effect.gen(function* () { + * const request = yield* HttpServerRequest; + * return yield* HttpServerResponse.json({ + * url: yield* selfUrl, + * path: request.url, + * }); + * }), + * }; + * }), + * ) {} + * ``` + * + * @example Cron schedules (Effect mode) + * ```typescript + * export default class Api extends Vercel.Function()( + * "Api", + * { main: import.meta.url }, + * Effect.gen(function* () { + * yield* Vercel.cron("0 3 * * *", Effect.log("nightly cleanup")); + * return { fetch: Effect.succeed(HttpServerResponse.text("ok")) }; + * }).pipe(Effect.provide(Vercel.CronEventSourceLive)), + * ) {} + * ``` + * + * @section Async Functions + * @example Defining an async Function in your stack + * ```typescript + * const api = yield* Vercel.Function("Api", { + * main: "./src/api.ts", + * }); + * ``` + * + * @example Writing the async handler + * ```typescript + * // src/api.ts — web-standard fetch export, no bridge, no Effect runtime + * export default { + * async fetch(request: Request): Promise { + * return Response.json({ ok: true }); + * }, + * }; + * ``` + * + * @example Typed environment variables + * ```typescript + * // stack.ts + * export const Hooks = Vercel.Function("Hooks", { + * main: "./src/hooks.ts", + * env: { + * API_URL: api.url, + * SIGNING_KEY: Config.redacted("SIGNING_KEY"), + * }, + * }); + * export type HooksEnv = Vercel.InferEnv; + * + * // src/hooks.ts + * import type { HooksEnv } from "../stack.ts"; + * const env = process.env as HooksEnv; + * ``` + * + * @section Configuration + * @example Fluid resources and regions + * ```typescript + * const api = yield* Vercel.Function("Api", { + * main: "./src/api.ts", + * resources: { memory: "performance", maxDuration: 60 }, + * regions: ["iad1"], + * }); + * ``` + * + * @example Prebuilt Build Output passthrough + * ```typescript + * const site = yield* Vercel.Function("Site", { + * prebuilt: "./web/.vercel/output", + * }); + * ``` + * + * @section Tenancy + * @example Deploying into a managed project + * ```typescript + * const legacy = yield* Vercel.Project("Legacy", { nodeVersion: "22.x" }); + * const fn = yield* Vercel.Function("Api", { + * main: "./src/api.ts", + * project: legacy.projectId, + * }); + * ``` + * + * @example Ephemeral PR stages sharing a durable stage's project + * ```typescript + * // staging stage: owns the shared project, exports it + * const shared = yield* Vercel.Project("Shared"); + * return { projectId: shared.projectId.as() }; + * + * // PR stage: a preview tenant of staging's project (cross-stage ref). + * // Deploys are additive (staging's production is untouched); the stage + * // serves from a stable {project}-{stage}.vercel.app alias, its env is + * // delivered per-deployment, and stack.destroy() removes only this + * // stage's alias + deployments. + * const fn = yield* Vercel.Function("Api", { + * main: "./src/api.ts", + * project: App.stage.staging.projectId, + * env: { FEATURE_FLAG: "pr-preview" }, + * }); + * ``` + * + * @section Local development + * During `alchemy dev` the Function runs locally instead of deploying: the + * same bundle (Effect bridge included) serves from a stable + * `http://localhost:` URL with hot rebuilds, the Vercel platform env + * (`VERCEL=1`, `VERCEL_ENV=development`, `VERCEL_URL`, + * `VERCEL_DEPLOYMENT_ID=dev:…`) injected, and crons fired on schedule with + * the platform invoker's exact request shape. + * + * @example Pin the local port + * ```typescript + * const api = yield* Vercel.Function("Api", { + * main: "./src/api.ts", + * dev: { port: 3000 }, + * }); + * ``` + * + * @example Opt out of local emulation + * ```typescript + * // Deploys to real Vercel even during `alchemy dev` + * const api = yield* Vercel.Function("Api", { + * main: "./src/api.ts", + * }).pipe(Alchemy.remote()); + * ``` + * + * @see https://vercel.com/docs/functions + */ +export const Function: Platform< + Function, + FunctionServices, + FunctionShape, + VercelFunctionContext +> & { + /** + * The Function's own public URL, injected as a project env var on that + * same Function. Declare it on `env` + * (`env: { SELF_URL: Vercel.Function.URL }`) or `yield*` it inside an + * Effect-native Function to obtain a deferred accessor. The value is + * always the project's READ-BACK production alias — never computed. + * See {@link URLEffect}. + */ + readonly URL: URLEffect; +} = Platform( + FunctionTypeId, + { + // Wrapped in arrows so imported references resolve at call time rather + // than module-load time (mirrors Worker.ts's TDZ note). + createRuntimeContext: (id: string) => makeVercelFunctionContext(id), + onCreate: (resource, props) => + bindFunctionAsyncEnv(resource as Function, props), + }, + { + URL: Object.assign( + Effect.gen(function* () { + // Deploy-time only: register the env binding on the host Function. + // The provider substitutes the marker with the resolved production + // URL during env sync, just before deploy. + if (!globalThis.__ALCHEMY_RUNTIME__) { + yield* (yield* Function).bind(SELF_URL_BINDING_NAME, { + env: { [SELF_URL_BINDING_NAME]: SELF_URL_MARKER }, + }); + } + // The deferred read only runs at the exec phase (the accessor is + // colored with RuntimeContext), where the env var is populated. + return Effect.sync( + () => process.env[SELF_URL_BINDING_NAME]!, + ) as URLAccessor; + }), + { + "~alchemy/Kind": SELF_URL_KIND, + // Persisted props (async `env:` channel) serialize the sentinel as + // its stable marker instead of Effect internals. + toJSON: () => SELF_URL_MARKER, + }, + ) as URLEffect, + }, +); + +// ───────────────────────────────────────────────────────────────────────────── +// Provider +// ───────────────────────────────────────────────────────────────────────────── + +const createProjectName = (id: string, name: string | undefined) => + Effect.gen(function* () { + return ( + name ?? + // maxLength 35, NOT Vercel's 100-char project-name cap: Vercel + // truncates the auto-assigned `{name}.vercel.app` domain to the FIRST + // 35 CHARS of the project name (live-verified, + // .probes/depth2e-name-lengths.ts) and disambiguates taken labels + // with a random word suffix — a process that RACES under concurrent + // project creation and can leave the losing project with NO assigned + // domain at all (.probes/depth2d-concurrent-collide.ts), i.e. no + // public URL. Engine-generated names sharing a stack/stage prefix + // would collide past 35 chars whenever a stack deploys two+ + // Functions, so keep the whole name inside the never-truncated + // budget (8-char suffix + createPhysicalName's 8-char truncation + // hash keep it unique). + (yield* createPhysicalName({ + id, + maxLength: 35, + suffixLength: 8, + lowercase: true, + })) + ); + }); + +const desiredSettings = (props: FunctionProps): ProjectSettingsDesired => ({ + ...(props.resources !== undefined || props.regions !== undefined + ? { + resourceConfig: { + fluid: true, + ...(props.resources?.memory !== undefined + ? { functionDefaultMemoryType: props.resources.memory } + : {}), + ...(props.regions !== undefined + ? { functionDefaultRegions: props.regions } + : {}), + }, + } + : {}), +}); + +/** Coerce a resolved env value to its project-env string manifestation. */ +const coerceEnvValue = (value: unknown): string => + typeof value === "string" ? value : JSON.stringify(value); + +/** Mint a fresh cron secret (48 hex chars). */ +const mintCronSecret = Effect.sync(() => + Redacted.make(crypto.randomBytes(24).toString("hex")), +); + +export const FunctionProvider = () => + Provider.effect( + Function, + Effect.gen(function* () { + const { bundleFunctionCode } = yield* makeFunctionBundler; + const functionLogs = yield* VercelFunctionLogs; + + /** Merge the binding channel into env/crons/routes/queues. */ + const collectBindings = ( + bindings: ResourceBinding[], + ) => { + const active = bindings.filter( + (b: ResourceBinding & { action?: string }) => + b.action !== "delete", + ); + const env = active + .map((b) => b?.data?.env) + .reduce>((acc, e) => ({ ...acc, ...e }), {}); + const crons = active.flatMap((b) => b?.data?.crons ?? []); + const routes = active.flatMap((b) => b?.data?.routes ?? []); + const queues = active.flatMap((b) => b?.data?.queues ?? []); + return { env, crons, routes, queues }; + }; + + /** + * Desired project-env rows: props env ⊎ binding env ⊎ managed vars + * (`CRON_SECRET`, alchemy stamp). `Function.URL` sentinels are + * substituted with the READ-BACK production URL here, so a changed + * URL flows into the env hash and forces a redeploy. + */ + const desiredEnvRows = (input: { + id: string; + news: FunctionProps; + bindingEnv: Record; + tenant: boolean; + selfUrl: string | undefined; + cronSecret: Redacted.Redacted | undefined; + }) => + Effect.gen(function* () { + const rows: DesiredEnv[] = []; + const merged = { ...input.bindingEnv, ...input.news.env }; + for (const key of Object.keys(merged)) { + let value = merged[key]; + if (value === undefined) continue; + if (isSelfUrl(value)) { + if (input.selfUrl === undefined) { + return yield* Effect.die( + `Vercel.Function(${input.id}): cannot resolve Function.URL for env "${key}" — the project has no assigned production domain to read back`, + ); + } + value = input.selfUrl; + } + rows.push({ + key, + // Redacted secrets stay wrapped — syncProjectEnv turns them + // into `sensitive` env vars with fingerprint comments. + value: Redacted.isRedacted(value) + ? (value as Redacted.Redacted) + : coerceEnvValue(value), + }); + } + if (input.cronSecret !== undefined) { + // Sensitive (Redacted ⇒ sensitive): Vercel's cron invoker sends + // `Authorization: Bearer $CRON_SECRET` when this var exists; the + // bridge verifies it constant-time on every cron route. + rows.push({ key: CRON_SECRET_ENV, value: input.cronSecret }); + } + if (!input.tenant) { + const stamp = yield* makeOwnershipStamp(input.id); + rows.push({ + key: ALCHEMY_META_KEY, + value: JSON.stringify(stamp), + type: "plain", + }); + } + return rows; + }); + + /** Build the artifact for the resolved props + contributed bindings. */ + const buildArtifact = (input: { + id: string; + news: FunctionProps; + crons: CronEntry[]; + routes: BuildOutputRoute[]; + queues: QueueTriggerEntry[]; + /** + * Per-deployment env baked into `.vc-config.json` `environment` + * (tenant mode, DESIGN §5.1). Part of the artifact — and therefore + * the artifact hash — so tenant env changes force a redeploy. + */ + environment?: Record; + }) => + Effect.gen(function* () { + if ( + input.news.source !== undefined || + input.news.prebuilt !== undefined + ) { + if (input.queues.length > 0) { + return yield* Effect.die( + `Vercel.Function(${input.id}): queue subscriptions require an Effect-mode \`main\` Function — a source/prebuilt output tree cannot carry the generated consumer function`, + ); + } + if ( + input.environment !== undefined && + Object.keys(input.environment).length > 0 + ) { + return yield* Effect.die( + `Vercel.Function(${input.id}): tenant-mode env delivery rewrites the generated \`.vc-config.json\` — a source/prebuilt output tree cannot carry per-deployment env. Move the env onto the shared project (Vercel.ProjectEnv) or use a \`main\` Function`, + ); + } + } + if (input.news.source !== undefined) { + // Website source (Website.* transformers): the artifact IS the + // built tree — no bundling, no synthesized config. + const artifact = yield* artifactFromSource(input.news.source); + return { artifact, codeHash: artifact.hash }; + } + if (input.news.prebuilt !== undefined) { + const artifact = yield* fromBuildOutputDir(input.news.prebuilt); + return { artifact, codeHash: artifact.hash }; + } + const bundle = yield* bundleFunctionCode(input.id, input.news); + const vcConfig: VcConfig = { + runtime: input.news.runtime ?? "nodejs22.x", + handler: "index.mjs", + launcherType: "Nodejs", + supportsResponseStreaming: true, + ...(input.environment !== undefined && + Object.keys(input.environment).length > 0 + ? { environment: input.environment } + : {}), + ...(input.news.resources?.maxDuration !== undefined + ? { maxDuration: input.news.resources.maxDuration } + : {}), + ...(input.news.regions !== undefined + ? { regions: input.news.regions } + : {}), + }; + // Queue subscriptions lower into a SEPARATE consumer function + // (D9a): the trigger lives in ITS `.vc-config.json` so the public + // function keeps its HTTP routing (a trigger on the main function + // kills ALL public routes — live-verified). The consumer bundle + // shares the user's module; its bridge dispatches deliveries to + // the init-registered `subscribe` handlers. + let queueConsumer: + | { + bundle: ReadonlyArray<{ path: string; bytes: Uint8Array }>; + vcConfig: VcConfig; + } + | undefined; + if (input.queues.length > 0) { + const consumerBundle = yield* bundleFunctionCode( + input.id, + input.news, + "queue", + ); + queueConsumer = { + bundle: consumerBundle.files, + vcConfig: { + ...vcConfig, + experimentalTriggers: input.queues.map((queue) => ({ + type: "queue/v2beta" as const, + ...queue, + })), + }, + }; + } + const artifact = yield* fromFunctionBundle({ + bundle: bundle.files, + vcConfig, + crons: input.crons, + routes: input.routes, + ...(queueConsumer !== undefined ? { queueConsumer } : {}), + }); + return { artifact, codeHash: bundle.identityHash }; + }); + + /** Resolve the target project (owned: ensure; tenant: observe). */ + const resolveProject = (input: { + id: string; + news: FunctionProps; + output: Function["Attributes"] | undefined; + }) => + Effect.gen(function* () { + if (input.news.project !== undefined) { + const project = yield* observeProject(input.news.project); + if (project === undefined) { + return yield* new TenantProjectNotFound({ + message: `Vercel.Function(${input.id}): tenant project '${input.news.project}' does not exist`, + project: input.news.project, + }); + } + return { project, tenant: true as const }; + } + // `|| undefined` guards the inert precreate stub (empty strings), + // which must never become a project name. + const name = + (input.output?.projectName || undefined) ?? + (yield* createProjectName(input.id, input.news.name)); + const project = yield* ensureProject({ + name, + desired: desiredSettings(input.news), + }); + return { project, tenant: false as const }; + }); + + const functionUrl = (input: { + projectId: string; + target: "production" | "preview"; + deploymentUrl: string | undefined; + }) => + Effect.gen(function* () { + if (input.target === "production") { + const fresh = yield* observeProject(input.projectId); + const url = + fresh !== undefined ? readProductionUrl(fresh) : undefined; + if (url !== undefined) return url; + // The project body often carries no alias rows right after a + // deploy — read the ASSIGNED production domain off the project's + // domains listing instead. This must come before any + // deployment-derived fallback: for >63-char project names + // Vercel truncates hostnames, and the deployment's `alias` + // array then contains a team-suffixed alias that is SSO-gated + // AND identical across projects sharing the truncated prefix + // (live-verified, .probes/depth2c-dep-alias.ts) — while the + // domains listing is always project-unique (Vercel + // disambiguates with a suffix). + const assigned = yield* readAssignedProductionUrl(input.projectId); + if (assigned !== undefined) return assigned; + } + return input.deploymentUrl !== undefined + ? `https://${input.deploymentUrl}` + : undefined; + }); + + return { + stables: ["projectId", "projectName"], + diff: Effect.fn(function* ({ id, olds, news, output }) { + if (!isResolved(news)) return undefined; + if (!output) return undefined; + // Reparenting between projects (or between owned and tenant mode) + // replaces the function. + if ((olds?.project ?? undefined) !== (news.project ?? undefined)) { + return { action: "replace" } as const; + } + // Engine-owned physical names: only an explicit name change can + // force a replace. + if (news.project === undefined) { + const newName = news.name ?? output.projectName; + if (newName !== output.projectName) { + return { action: "replace" } as const; + } + } + // Code identity — catches `main` content changes that props alone + // can't see. Prebuilt/source trees hash the whole directory. + if (news.source !== undefined) { + const artifact = yield* artifactFromSource(news.source); + if (artifact.hash !== output.hash.code) { + return { action: "update" } as const; + } + } else if (news.prebuilt !== undefined) { + const artifact = yield* fromBuildOutputDir(news.prebuilt); + if (artifact.hash !== output.hash.code) { + return { action: "update" } as const; + } + } else { + const bundle = yield* bundleFunctionCode(id, news); + if (bundle.identityHash !== output.hash.code) { + return { action: "update" } as const; + } + } + // Everything else falls through to the engine's default + // props/bindings comparison. + return undefined; + }), + read: Effect.fn(function* ({ id, olds, output }) { + // `|| undefined` guards the inert precreate stub (empty strings). + const idOrName = + (output?.projectId || undefined) ?? + olds?.project ?? + (yield* createProjectName(id, olds?.name)); + const project = yield* observeProject(idOrName); + if (project === undefined) return undefined; + const observedBypass = findAutomationBypassSecret(project); + const attrs: Function["Attributes"] = { + projectId: project.id, + projectName: project.name, + deploymentId: output?.deploymentId ?? "", + url: + output?.stageAlias !== undefined + ? `https://${output.stageAlias.alias}` + : (readProductionUrl(project) ?? output?.url), + stageAlias: output?.stageAlias, + hash: output?.hash ?? { artifact: "", code: "", env: "" }, + managedEnv: output?.managedEnv ?? [], + protectionBypass: + observedBypass !== undefined + ? Redacted.make(observedBypass) + : output?.protectionBypass, + cronSecret: output?.cronSecret, + }; + if (olds?.project !== undefined) { + // Tenant mode: the project is foreign by definition; ownership + // is per-deployment via meta stamps, so the row is ours. + return attrs; + } + const stamp = yield* readOwnershipStamp(project.id); + const expected = yield* makeOwnershipStamp(id); + return stamp !== undefined && + stamp.stack === expected.stack && + stamp.stage === expected.stage && + stamp.logicalId === expected.logicalId + ? attrs + : Unowned(attrs); + }), + precreate: Effect.fn(function* ({ id, news }) { + // Precreate runs BEFORE upstream resolution (it exists to break + // binding cycles), so props may still carry unresolved Inputs. + // The `env` and `exports` channels are EXPECTED to be unresolved + // here — cycle partners' init captures (e.g. a blob store's + // `storeId` accessor riding the env channel) resolve only after + // the partner reconciles against the pre-created stub, and + // precreate never reads either channel. Gate the inert stub only + // on the project-facing props precreate actually consumes + // (tenant `project:` ref, `name`, settings): if e.g. a tenant + // `project:` still references an unreconciled Project there is + // nothing to pre-create against — return the stub and let + // reconcile observe the real project once the reference + // resolves. + const { + env: _cycleEnv, + exports: _cycleExports, + ...projectFacing + } = (news ?? {}) as Record; + if (!isResolved(projectFacing)) { + return { + projectId: "", + projectName: "", + deploymentId: "", + url: undefined, + stageAlias: undefined, + hash: { artifact: "", code: "", env: "" }, + managedEnv: [], + protectionBypass: undefined, + cronSecret: undefined, + } satisfies Function["Attributes"]; + } + // Ensure the project exists and READ BACK the assigned domain — + // the computed `https://{name}.vercel.app` is never load-bearing + // (DESIGN §6.5). + const { project, tenant } = yield* resolveProject({ + id, + news: projectFacing as FunctionProps, + output: undefined, + }); + // Mint the automation bypass secret at project ensure, BEFORE any + // deploy exists — bypass secrets only open deployments created + // AFTER them (§6.7, live-verified). Minted in tenant mode too + // (additive; the tenant's SSO-gated stage alias needs it), while + // an existing secret is always kept, never regenerated. + const protectionBypass = yield* ensureProtectionBypass(project); + let managedEnv: ManagedEnvEntry[] = []; + if (!tenant) { + const stamp = yield* makeOwnershipStamp(id); + const envSync = yield* syncProjectEnv({ + idOrName: project.id, + desired: [ + { + key: ALCHEMY_META_KEY, + value: JSON.stringify(stamp), + type: "plain", + }, + ], + managedEnv: [{ key: ALCHEMY_META_KEY, type: "plain" }], + }); + managedEnv = [...envSync.managedEnv]; + } + return { + projectId: project.id, + projectName: project.name, + deploymentId: "", + // Greenfield projects carry no alias rows before the first + // deployment, but the assigned `{name}.vercel.app` project + // domain exists from creation — read it back so circular + // capabilities (InvokeFunction A↔B) can bind a real URL off + // the pre-created stub. Still read-back, never computed. + url: + readProductionUrl(project) ?? + (yield* readAssignedProductionUrl(project.id)), + stageAlias: undefined, + hash: { artifact: "", code: "", env: "" }, + managedEnv, + protectionBypass, + cronSecret: undefined, + } satisfies Function["Attributes"]; + }), + reconcile: Effect.fn(function* ({ + id, + news, + olds, + output, + bindings, + session, + }) { + // 1. Observe + ensure the project. + const { project, tenant } = yield* resolveProject({ + id, + news, + output, + }); + const target = news.target ?? (tenant ? "preview" : "production"); + + // 1b. Protection bypass (§6.7): observe-and-keep — mint only when + // absent, always BEFORE deploying (a bypass secret only opens + // deployments created after it). Minted in TENANT mode too: + // the tenant's stage alias and deployment URLs are SSO-gated + // on team accounts, and the automation bypass secret is the + // only way to drive them. Minting is additive — it never + // changes the foreign project's protection level, and an + // existing secret is always kept, never regenerated. + const protectionBypass = yield* ensureProtectionBypass(project); + + // 2. Sync settings (owned mode only — a tenant never touches a + // foreign project's settings). + if (!tenant) { + yield* syncProjectSettings({ + project, + desired: desiredSettings(news), + }); + } + + // 3. Desired env. Owned mode syncs PROJECT env against the + // observed rows (adoption-safe); tenant mode delivers env + // PER-DEPLOYMENT via `.vc-config.json` `environment` + // (DESIGN §5.1) and never touches the shared project's env. + const contributed = collectBindings(bindings); + // Cron secret: stable across reconciles (a rotating value would + // force a redeploy every deploy); dropped with the last cron. + const cronSecret = + contributed.crons.length > 0 + ? (output?.cronSecret ?? (yield* mintCronSecret)) + : undefined; + const stamp = yield* makeOwnershipStamp(id); + // Self-URL for `Function.URL` bindings, resolved BEFORE the + // deploy (env only takes effect on new deployments). Owned mode: + // the project body's alias when it exists, else the assigned + // project domain (greenfield — no deployment yet, but the + // production domain is assigned at project creation), always read + // back. Tenant preview: the stable per-stage alias this reconcile + // is about to claim — the persisted alias when present (it + // survives a suffix fallback), else the deterministic base name. + const needsSelfUrl = Object.values({ + ...contributed.env, + ...news.env, + }).some(isSelfUrl); + const selfUrl = !needsSelfUrl + ? undefined + : tenant && target === "preview" + ? `https://${output?.stageAlias?.alias ?? stageAliasName(project.name, stamp.stage)}` + : (readProductionUrl(project) ?? + (yield* readAssignedProductionUrl(project.id)) ?? + output?.url); + const envRows = yield* desiredEnvRows({ + id, + news, + bindingEnv: contributed.env, + tenant, + selfUrl, + cronSecret, + }); + const envHash = yield* hashDesiredEnv(envRows); + + // 3b. Tenant mode: the MANDATORY cross-tenant conflict check — + // project env WINS over `.vc-config.json` env on key conflict + // (live-verified, PROBES.md probe 3), so a conflicting tenant + // key would be silently shadowed. Hard typed error, then + // resolve the per-deployment environment map. Owned mode: + // sync project env by observed-vs-desired delta. + let tenantEnvironment: Record | undefined; + let envChanged = false; + let managedEnv: ManagedEnvEntry[] = [...(output?.managedEnv ?? [])]; + if (tenant) { + const observedProjectEnv = yield* listProjectEnvs(project.id); + const projectKeys = new Set( + observedProjectEnv.map((row) => row.key), + ); + const conflicts = envRows + .map((row) => row.key) + .filter((key) => projectKeys.has(key)); + if (conflicts.length > 0) { + return yield* new TenantEnvConflict({ + message: + `Vercel.Function(${id}): tenant env key(s) [${conflicts.join(", ")}] already exist in shared project ${project.id}'s env — ` + + "project env wins over per-deployment env on conflict, so the tenant's value would be silently shadowed. " + + "Rename the key(s) or manage them on the shared project instead.", + projectId: project.id, + keys: conflicts, + }); + } + tenantEnvironment = Object.fromEntries( + envRows.map((row) => [ + row.key, + Redacted.isRedacted(row.value) + ? Redacted.value(row.value) + : row.value, + ]), + ); + } else { + const envSync = yield* syncProjectEnv({ + idOrName: project.id, + desired: envRows, + managedEnv: output?.managedEnv ?? [], + }); + envChanged = envSync.changed; + managedEnv = [...envSync.managedEnv]; + } + + // 4. Build the artifact. Tenant env rides inside the generated + // `.vc-config.json`, so it participates in the artifact hash + // (tenant env changes force a redeploy through the artifact, + // not through `envChanged`). + const { artifact, codeHash } = yield* buildArtifact({ + id, + news, + crons: contributed.crons, + routes: contributed.routes, + queues: contributed.queues, + ...(tenantEnvironment !== undefined + ? { environment: tenantEnvironment } + : {}), + }); + // Target participates in the content identity: retargeting + // production ↔ preview must produce a new deployment even when + // code and env are unchanged. + const contentHash = yield* sha256( + `${artifact.hash}:${envHash}:${target}`, + ); + const meta = makeDeploymentMeta(stamp, contentHash); + + // Tenant-preview: converge the stable per-stage alias onto the + // final deployment — on EVERY path (skip, crash recovery, fresh + // deploy), so an out-of-band alias deletion or re-point heals. + const settleStageAlias = (deploymentId: string) => + tenant && target === "preview" + ? Effect.map( + ensureStageAlias({ + projectName: project.name, + stage: stamp.stage, + stack: stamp.stack, + deploymentId, + previous: output?.stageAlias, + }), + (alias): StageAliasResult | undefined => alias, + ) + : Effect.succeed(undefined); + + // 5. Skip-on-hash: same artifact + same env + a live deployment + // means nothing to deploy (env changes force a redeploy — + // Vercel env only takes effect on new deployments). + const previousTarget = + olds?.target ?? + (olds?.project !== undefined ? "preview" : "production"); + if ( + output !== undefined && + output.deploymentId !== "" && + output.hash.artifact === artifact.hash && + output.hash.env === envHash && + !envChanged && + olds !== undefined && + previousTarget === target + ) { + const stageAlias = yield* settleStageAlias(output.deploymentId); + return { + ...output, + projectId: project.id, + projectName: project.name, + ...(stageAlias !== undefined + ? { stageAlias, url: `https://${stageAlias.alias}` } + : {}), + hash: { artifact: artifact.hash, code: codeHash, env: envHash }, + managedEnv, + protectionBypass, + cronSecret, + } satisfies Function["Attributes"]; + } + + // 6. Crash recovery: an unrecorded-but-matching deployment (state + // persistence died after createDeployment) is reused, not + // duplicated. + const recovered = yield* findDeploymentByContentHash({ + projectId: project.id, + contentHash, + }); + if (recovered !== undefined) { + const stageAlias = yield* settleStageAlias(recovered.uid); + return { + projectId: project.id, + projectName: project.name, + deploymentId: recovered.uid, + url: + stageAlias !== undefined + ? `https://${stageAlias.alias}` + : yield* functionUrl({ + projectId: project.id, + target, + deploymentUrl: recovered.url, + }), + stageAlias, + hash: { artifact: artifact.hash, code: codeHash, env: envHash }, + managedEnv, + protectionBypass, + cronSecret, + } satisfies Function["Attributes"]; + } + + // 7. Deploy. + yield* session.note( + `Deploying ${artifact.files.length} file(s) to Vercel project ${project.name}`, + ); + const result = yield* deployArtifact({ + projectName: project.name, + artifact, + target, + meta, + }); + + // 8. Converge the tenant stage alias onto the fresh deployment, + // then return fresh attributes — URL read back, never computed. + const stageAlias = yield* settleStageAlias(result.deploymentId); + return { + projectId: project.id, + projectName: project.name, + deploymentId: result.deploymentId, + url: + stageAlias !== undefined + ? `https://${stageAlias.alias}` + : yield* functionUrl({ + projectId: project.id, + target, + // NOT `result.aliases[0]`: the deployment alias rows are + // nondeterministically ordered and can lead with the + // truncated team-suffixed alias, which is SSO-gated and + // collides across long-named projects. The + // deployment-specific URL is always unique. + deploymentUrl: result.deploymentUrl, + }), + stageAlias, + hash: { artifact: artifact.hash, code: codeHash, env: envHash }, + managedEnv, + protectionBypass, + cronSecret, + } satisfies Function["Attributes"]; + }), + delete: Effect.fn(function* ({ id, olds, output }) { + // An inert precreate stub (props never resolved, reconcile never + // ran) tracks no cloud state. + if (output.projectId === "") return; + if (olds?.project !== undefined) { + // Tenant mode: remove ONLY this stage's alias and our own + // meta-stamped deployments plus the env keys we manage — never + // the shared project itself, and never its active production + // deployment. + const stamp = yield* makeOwnershipStamp(id); + const own = yield* listOwnDeployments({ + projectId: output.projectId, + stamp, + }); + const activeProduction = own.find( + (d) => + d.target === "production" && d.readySubstate === "PROMOTED", + ); + // The refusal keys on the tenant's declared INTENT, not the + // observed target: Vercel auto-promotes the first deployment of + // a project that has no production deployment, even when + // `target` is omitted (live-verified — a preview-intent tenant + // deployed into a fresh shared project lands `target: + // "production"`, PROMOTED). Deleting that auto-promoted + // deployment restores the project's pre-tenant state (no + // production existed before us), so a preview-intent tenant + // always drains its own deployments; only an explicit + // `target: "production"` tenant refuses to brick the site. + const productionIntent = olds.target === "production"; + if (activeProduction !== undefined && productionIntent) { + return yield* new TenantProductionDeleteRefused({ + message: + `Vercel.Function(${id}): refusing to delete deployment ${activeProduction.uid} — it is the active production deployment of foreign project ${output.projectId}. ` + + "Promote another deployment (or delete it manually) first.", + projectId: output.projectId, + deploymentId: activeProduction.uid, + }); + } + // Stage alias first (after the refusal gate, so a refused + // destroy leaves the tenant fully intact), then deployments. + if (output.stageAlias !== undefined) { + yield* deleteAliasByIdOrName(output.stageAlias.uid); + } + for (const deployment of own) { + yield* deleteDeploymentById(deployment.uid); + } + if (output.managedEnv.length > 0) { + yield* syncProjectEnv({ + idOrName: output.projectId, + desired: [], + managedEnv: output.managedEnv, + }).pipe( + // The foreign project may already be gone. + Effect.catchTag("NotFound", () => Effect.void), + ); + } + return; + } + // Owned mode: the project cascades everything (deployments, env, + // domains). Idempotent on the typed NotFound. + yield* deleteProjectByIdOrName(output.projectId); + }), + tail: ({ output }) => + functionLogs.tail({ + projectId: output.projectId, + deploymentId: output.deploymentId, + }), + logs: ({ output, options }) => + functionLogs.logs({ + target: { + projectId: output.projectId, + deploymentId: output.deploymentId, + }, + options, + }), + }; + }), + ); diff --git a/packages/alchemy/src/Vercel/Functions/FunctionBridge.ts b/packages/alchemy/src/Vercel/Functions/FunctionBridge.ts new file mode 100644 index 0000000000..c79045ecda --- /dev/null +++ b/packages/alchemy/src/Vercel/Functions/FunctionBridge.ts @@ -0,0 +1,601 @@ +/** + * The Vercel Function runtime bridge (Effect mode, DESIGN §6.4). + * + * The generated bundle entry wraps the user's `main` module: + * + * ```ts + * import entrypoint from ""; + * import { makeVercelBridge } from "alchemy/Vercel"; + * export default makeVercelBridge(entrypoint, { stack: { name, stage } }); + * ``` + * + * and emits a web-standard `{ fetch }` export that Vercel's Node launcher + * accepts natively (streaming-capable — `supportsResponseStreaming` is set + * by the provider). + * + * Contract: + * - The layer stack is built ONCE per instance (Fluid keeps instances warm), + * lazily on the first request, with success-only memoization so a + * transient init failure heals on the next request. + * - The bridge is RE-ENTRANT: Fluid runs concurrent requests against one + * instance — every request runs in its own Effect fiber with a fresh + * `Scope` and its own `HttpServerRequest`; there is zero per-instance + * mutable request state. + * - Request finalizers settle post-response: the request scope is closed + * into Vercel's `waitUntil` (the `@vercel/request-context` contract that + * `@vercel/functions` wraps — honored exactly, live-verified), so + * `Effect.addFinalizer` in a handler never delays the response. + * - Shutdown: Fluid delivers SIGTERM with a ~500ms grace window — a plain + * signal handler closes the instance scope so init-level finalizers run. + * - Cron routes (`/_alchemy/cron/*`, registered by the `cron` event source) + * are dispatched BEFORE the user's `fetch`, guarded by a constant-time + * comparison of `Authorization: Bearer $CRON_SECRET` — the secret the + * provider auto-mints as a sensitive project env var, and the same value + * Vercel's cron invoker sends when `CRON_SECRET` is set. + */ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Cause from "effect/Cause"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Option from "effect/Option"; +import { MinimumLogLevel } from "effect/References"; +import * as Scope from "effect/Scope"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import * as EffectHttp from "effect/unstable/http/HttpEffect"; +import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import { createHash, timingSafeEqual } from "node:crypto"; +import * as Http from "../../Http.ts"; +import { isScopeEjected } from "../../Http.ts"; +import * as Output from "../../Output.ts"; +import { + makeEntrypointLayer, + reifyBoundConfigProvider, +} from "../../Runtime.ts"; +import { + packEnvValueKeepRedacted, + unpackEnvValue, +} from "../../RuntimeContext.ts"; +import { Self } from "../../Self.ts"; +import type * as Serverless from "../../Serverless/index.ts"; +import { Stack } from "../../Stack.ts"; +import { buildEventTelemetry } from "../../Telemetry.ts"; +import { + runQueueCallback, + type QueueSubscriptionEntry, +} from "../Queues/QueueCallback.ts"; +import type { Function } from "./Function.ts"; + +/** + * The env var Vercel's cron invoker reads: when a project defines + * `CRON_SECRET`, every platform cron request carries + * `Authorization: Bearer $CRON_SECRET`. The Function provider auto-mints it + * (sensitive, production+preview targets) whenever crons are registered. + */ +export const CRON_SECRET_ENV = "CRON_SECRET"; + +/** + * The runtime environment of a deployed Vercel Function — `process.env` + * (≙ Cloudflare's `WorkerEnvironment`). Resolved once at instance init and + * closed over by runtime clients. + */ +export class FunctionEnvironment extends Context.Service< + FunctionEnvironment, + Record +>()("Vercel.Functions.FunctionEnvironment") {} + +/** The single event shape the Vercel bridge dispatches. */ +export interface VercelFunctionEvent { + readonly kind: "Vercel.Functions.FunctionEvent"; + readonly type: "fetch"; + readonly request: Request; +} + +export const isVercelFunctionEvent = ( + event: unknown, +): event is VercelFunctionEvent => + typeof event === "object" && + event !== null && + (event as { kind?: unknown }).kind === "Vercel.Functions.FunctionEvent"; + +/** + * A per-request dispatch produced by the context's `exports`: routes cron + * paths first, then the user's `fetch`, returning the handler Effect plus + * the init-captured services it must run against. + */ +export type VercelFetchDispatch = ( + request: Request, +) => readonly [ + Effect.Effect, + Context.Context, +]; + +export interface VercelFunctionContext extends Serverless.FunctionContext { + /** + * Register a cron handler under its stable route path. Called by the + * `cron` event source at init in BOTH phases (plan and runtime) — the + * bridge's dispatcher consults the registry before user `fetch`. + */ + registerCron( + path: string, + handler: Effect.Effect, + ): Effect.Effect; + /** + * Register a queue push subscription for a topic. Called by the + * `subscribe` event source at init in BOTH phases (plan and runtime); the + * queue-mode bridge (the separate consumer function, D9a) dispatches + * incoming deliveries against this registry. + */ + registerQueueSubscription(entry: QueueSubscriptionEntry): Effect.Effect; +} + +/** + * Constant-time string comparison (per-char timing leaks closed by + * comparing fixed-length sha256 digests; the trailing `===` guards the + * astronomically-unlikely digest collision). + */ +const timingSafeStringEqual = (a: string, b: string): boolean => { + const digestA = createHash("sha256").update(a).digest(); + const digestB = createHash("sha256").update(b).digest(); + return timingSafeEqual(digestA, digestB) && a === b; +}; + +/** + * Hand a promise to Vercel's request context so it settles after the + * response without being cut off — the same contract `@vercel/functions`'s + * `waitUntil` wraps (probe-verified: honored with exact timing). When no + * request context is present (local harnesses), the promise floats — Fluid + * keeps the instance's event loop running. + */ +const vercelWaitUntil = (promise: Promise): void => { + const requestContext = ( + globalThis as Record & typeof globalThis + )[Symbol.for("@vercel/request-context")] as + | { get?: () => { waitUntil?: (promise: Promise) => void } } + | undefined; + const ctx = requestContext?.get?.(); + if (ctx?.waitUntil !== undefined) { + ctx.waitUntil(promise); + } +}; + +const toHandledWebResponse = ( + handler: Effect.Effect, + requestScope: Scope.Scope, +) => + Effect.gen(function* () { + // `toHandled` exposes the final response through this callback, not its + // return value. Keep the assignment isolated here so callers get Response. + const context = yield* Effect.context(); + const webResponse = yield* Deferred.make(); + + yield* EffectHttp.toHandled(handler, (request, response) => + Effect.flatMap(Effect.scope, (handlerScope) => + Effect.gen(function* () { + // `toHandled` runs the handler under its OWN internal scope + // (shadowing the bridge's request scope) and closes it INLINE + // right after this callback — a handler's `Effect.addFinalizer` + // would delay the response. Eject it and settle it with the + // bridge's request scope instead, which closes post-response via + // waitUntil. Streaming bodies keep effect's native transfer: + // `scopeTransferToStream` ejects the scope itself and closes it + // when the body stream ends. + if (response.body._tag !== "Stream") { + EffectHttp.scopeDisableClose(handlerScope); + yield* Scope.addFinalizerExit(requestScope, (exit) => + Scope.close(handlerScope, exit), + ); + } + yield* Deferred.succeed( + webResponse, + HttpServerResponse.toWeb( + EffectHttp.scopeTransferToStream(response), + { + withoutBody: request.method === "HEAD", + context, + }, + ), + ); + }), + ), + ); + return yield* Deferred.await(webResponse); + }); + +/** + * Wrap the user's `fetch` handler into a request Effect: builds an + * `HttpServerRequest` from the incoming web `Request` and converts the + * handler's `HttpServerResponse` back to a web `Response` (streaming bodies + * transfer the request scope to the stream). + */ +export const makeVercelRequestEffect = ( + webRequest: Request, + handler: Http.HttpEffect | Effect.Effect>, +) => { + const safeHandler = Http.safeHttpEffect(handler); + return Effect.gen(function* () { + // The bridge-provided per-request scope — handed to toHandledWebResponse + // so the handler's finalizers settle post-response with it. + const requestScope = yield* Effect.scope; + const request = HttpServerRequest.fromWeb(webRequest).modify({ + remoteAddress: Option.fromUndefinedOr( + webRequest.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + webRequest.headers.get("x-real-ip") ?? + undefined, + ), + }); + return yield* toHandledWebResponse(safeHandler, requestScope).pipe( + Effect.provide([ + Layer.succeed(HttpServerRequest.HttpServerRequest, request), + ]), + ); + // Residual requirements (RuntimeContext, init services, the per-request + // Scope) are provided by the bridge's dispatch — erased here so the + // listener signature (`FunctionListener`) accepts the handler. + }) as any as Effect.Effect; +}; + +const makeVercelRequestHandler = + ( + handler: Http.HttpEffect | Effect.Effect>, + ) => + (event: unknown) => + isVercelFunctionEvent(event) && event.type === "fetch" + ? makeVercelRequestEffect(event.request, handler) + : undefined; + +/** + * Verify + run a registered cron handler for an incoming platform cron + * request. Runs BEFORE user `fetch`; a missing/wrong bearer is a 401 + * (constant-time comparison), a failing handler is a 500 so Vercel records + * the invocation as failed. + */ +const runCronRequest = ( + request: Request, + handler: Effect.Effect, +): Effect.Effect => + Effect.gen(function* () { + const authorized = yield* Effect.sync(() => { + const secret = process.env[CRON_SECRET_ENV]; + if (secret === undefined || secret.length === 0) { + return false; + } + const header = request.headers.get("authorization"); + return ( + header !== null && timingSafeStringEqual(header, `Bearer ${secret}`) + ); + }); + if (!authorized) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + const succeeded = yield* ( + handler as Effect.Effect + ).pipe( + Effect.as(true), + Effect.catchCause((cause) => + Effect.logError("Vercel cron handler failed", cause).pipe( + Effect.as(false), + ), + ), + ); + return succeeded + ? Response.json({ ok: true }) + : Response.json({ error: "cron handler failed" }, { status: 500 }); + }); + +const pathnameOf = (url: string): string => { + try { + return new globalThis.URL(url, "http://localhost").pathname; + } catch { + return url; + } +}; + +/** + * Build the Vercel Function's runtime context — the `createRuntimeContext` + * hook of the `Vercel.Function` Platform. The SAME code runs at plan time + * (registering bindings, collecting the export shape) and inside the + * deployed bundle (where `exports` hands the bridge its dispatcher). + */ +export const makeVercelFunctionContext = ( + id: string, +): VercelFunctionContext => { + const env: Record = {}; + const listeners: Effect.Effect[] = []; + const cronHandlers = new Map>(); + const queueSubscriptions = new Map(); + + const ctx: VercelFunctionContext = { + Type: "Vercel.Function", + id, + env, + set: (key: string, output: Output.Output) => + Effect.sync(() => { + // Marker-pack Redacted values so they survive the Output → env var + // round-trip; the runtime `get` below unpacks them. The Redacted + // wrapper is kept on the OUTSIDE of the packed string so the env + // sync routes secrets (e.g. EdgeConfigToken connection strings) + // as `sensitive` project env vars instead of plain ones (mirrors + // the Cloudflare Container platform). + env[key] = output.pipe(Output.map(packEnvValueKeepRedacted)); + return key; + }), + get: (key: string) => + // Key is already canonical (see RuntimeContext.sanitizeKey). Read + // straight from `process.env` — see `unpackEnvValue` for why this + // must never resolve through `Config.string`. + Effect.sync(() => unpackEnvValue(process.env[key])), + serve: ( + handler: Http.HttpEffect | Effect.Effect>, + ) => + ctx.listen(makeVercelRequestHandler(handler)) as Effect.Effect< + void, + never, + Req + >, + listen: (( + handler: + | Serverless.FunctionListener + | Effect.Effect, + ) => + Effect.sync(() => + Effect.isEffect(handler) + ? listeners.push(handler) + : listeners.push(Effect.succeed(handler)), + )) as any as Serverless.FunctionContext["listen"], + registerCron: (path, handler) => + Effect.sync(() => { + cronHandlers.set(path, handler); + }), + registerQueueSubscription: (entry) => + Effect.suspend(() => { + if (queueSubscriptions.has(entry.topic.topicName)) { + return Effect.die( + new Error( + `Vercel.subscribe: duplicate subscription for topic '${entry.topic.topicName}' — a Function may subscribe to a topic at most once`, + ), + ); + } + queueSubscriptions.set(entry.topic.topicName, entry); + return Effect.void; + }), + // Lets init code `yield* FunctionEnvironment` during plan; the real env + // is provided by the bridge at runtime. + planServices: Layer.succeed(FunctionEnvironment, {}), + exports: Effect.gen(function* () { + const handlers = yield* Effect.all(listeners, { + concurrency: "unbounded", + }); + // Instance-lifetime services captured at init. The build's memo map is + // stripped so a Layer the user `Effect.provide`s inside a handler + // builds per request instead of sharing one instance across + // concurrent events (mirrors WorkerBridge / Lambda). + const services = Context.omit(Layer.CurrentMemoMap)( + yield* Effect.context(), + ); + + const dispatch: VercelFetchDispatch = (request) => { + // Cron routes are dispatched BEFORE user fetch. + const cronHandler = cronHandlers.get(pathnameOf(request.url)); + if (cronHandler !== undefined) { + return [runCronRequest(request, cronHandler), services] as const; + } + const event: VercelFunctionEvent = { + kind: "Vercel.Functions.FunctionEvent", + type: "fetch", + request, + }; + for (const handler of handlers) { + const eff = handler(event); + if (Effect.isEffect(eff)) { + return [ + eff as Effect.Effect, + services, + ] as const; + } + } + return [ + Effect.die( + new Error( + "No fetch handler registered — return a `fetch` handler from the Function's init Effect", + ), + ), + services, + ] as const; + }; + + // The queue-mode dispatch (used ONLY by the separate consumer + // function's bridge, D9a): every incoming request is a platform queue + // delivery, processed against the init-registered subscriptions. + const queue: VercelFetchDispatch = (request) => [ + runQueueCallback(queueSubscriptions, request) as Effect.Effect< + Response, + unknown, + unknown + >, + services, + ]; + + return { fetch: dispatch, queue }; + }), + }; + return ctx; +}; + +interface FunctionBuild { + readonly context: Context.Context; + readonly dispatch: VercelFetchDispatch; + readonly telemetry: () => Layer.Layer | undefined; +} + +/** + * Bridge the user's Effect-native entrypoint to Vercel's web-standard + * `{ fetch }` export. See the module doc for the full contract. + */ +export const makeVercelBridge = ( + entrypoint: any, + meta: { + stack: { name: string; stage: string }; + /** + * Which dispatch this bundle mounts: `"fetch"` (default) serves public + * HTTP; `"queue"` is the dedicated consumer function (D9a) — every + * request is a platform queue delivery dispatched to the registered + * `subscribe` handlers. + */ + mode?: "fetch" | "queue"; + }, +): { fetch: (request: Request) => Promise } => { + const tag = Self as any as Context.Service< + never, + Function & { RuntimeContext: VercelFunctionContext } + >; + + const layer = makeEntrypointLayer(tag, entrypoint); + + const platform = Layer.mergeAll( + NodeServices.layer, + FetchHttpClient.layer, + Logger.layer([Logger.consolePretty()]), + ); + + const entryLayer = layer.pipe( + Layer.provideMerge( + Layer.succeed(Stack, { + name: meta.stack.name, + stage: meta.stack.stage, + bindings: {}, + resources: {}, + actions: {}, + }), + ), + Layer.provideMerge(platform), + Layer.provideMerge( + Layer.succeed( + ConfigProvider.ConfigProvider, + ConfigProvider.orElse( + ConfigProvider.fromUnknown({ ALCHEMY_PHASE: "runtime" }), + // Auto-bound `Config` values arrive in env as + // `{"_tag":"Redacted","value":...}` markers; reify them so a + // `Config` re-read inside a request handler decodes the raw + // source value instead of the marker JSON. + reifyBoundConfigProvider( + ConfigProvider.fromEnv(), + process.env as Record, + ), + ), + ), + ), + Layer.provideMerge( + Layer.sync( + FunctionEnvironment, + () => process.env as Record, + ), + ), + Layer.provideMerge( + Layer.succeed(MinimumLogLevel, process.env.DEBUG ? "Debug" : "Info"), + ), + ); + + // Instance scope: the instance-lifetime layer build lives under it; the + // SIGTERM handler below closes it inside Fluid's ~500ms grace window so + // init-level finalizers run before the instance dies. + const instanceScope = Scope.makeUnsafe(); + + let built: Promise | undefined; + + /** + * Build the instance-lifetime layer stack exactly once (lazily, on the + * first request); every request reuses the memoized build. Only success + * is memoized — a transient init failure resets the memo and heals on + * the next request. + */ + const build = (): Promise => + (built ??= Effect.runPromise( + Layer.buildWithScope(entryLayer, instanceScope).pipe( + Effect.flatMap((context) => + Effect.gen(function* () { + const func = yield* tag; + const runtimeContext = func.RuntimeContext; + const exports = yield* runtimeContext.exports; + return { + context: Context.omit(Layer.CurrentMemoMap)(context), + dispatch: (meta.mode === "queue" + ? exports.queue + : exports.fetch) as VercelFetchDispatch, + telemetry: () => runtimeContext.telemetry, + } satisfies FunctionBuild; + }).pipe(Effect.provideContext(context)), + ), + Scope.provide(instanceScope), + ), + ).catch((error) => { + built = undefined; + throw error; + })); + + // Fluid's Shutdown: SIGTERM + ~500ms — close the instance scope so + // init-level finalizers run, then exit inside the budget. SIGKILL follows + // if we overstay, so finalizers must be fast and best-effort. + process.once("SIGTERM", () => { + Effect.runPromise(Scope.close(instanceScope, Exit.void)) + .catch((error) => + console.error("[alchemy] shutdown finalizers failed", error), + ) + .finally(() => process.exit(0)); + }); + + const fetch = async (request: Request): Promise => { + const build_ = await build(); + // Fresh request scope per event — `Effect.addFinalizer` in a handler + // attaches here and settles post-response via waitUntil below. Each + // request runs in its own fiber: the bridge is re-entrant under Fluid + // concurrency. + const scope = Scope.makeUnsafe(); + const [eff, services] = build_.dispatch(request); + const exit = await (eff as Effect.Effect).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(Scope.Scope, scope), + // The configured telemetry exporters, attached to the request + // scope by `buildEventTelemetry` so buffered spans/logs flush + // when the scope settles below. + Layer.effectContext( + buildEventTelemetry(build_.context, scope, build_.telemetry()), + ), + ).pipe( + Layer.provideMerge(Layer.succeedContext(services)), + Layer.provideMerge(Layer.succeedContext(build_.context)), + ), + ), + Effect.runPromiseExit, + ); + if (!isScopeEjected(scope)) { + // Close the request scope post-response: the HttpMiddleware tracer + // ends the request's root span in a dispatcher task scheduled after + // the handler resolves — yield one macrotask so it reaches the + // exporter's buffer before the flush finalizer, then settle the scope + // under Vercel's waitUntil so it never delays the response. An + // ejected scope (streaming body) is closed by its new owner instead. + vercelWaitUntil( + new Promise((resolve) => setTimeout(resolve, 0)).then(() => + Effect.runPromise( + Scope.close(scope, exit as Exit.Exit), + ).catch((error) => + console.error("[alchemy] request finalizers failed", error), + ), + ), + ); + } + if (Exit.isSuccess(exit)) { + return exit.value; + } + throw Cause.squash(exit.cause); + }; + + return { fetch }; +}; diff --git a/packages/alchemy/src/Vercel/Functions/FunctionBundle.ts b/packages/alchemy/src/Vercel/Functions/FunctionBundle.ts new file mode 100644 index 0000000000..fb402030da --- /dev/null +++ b/packages/alchemy/src/Vercel/Functions/FunctionBundle.ts @@ -0,0 +1,189 @@ +/** + * Vercel Function code bundling. + * + * **Async (external) mode**: the user's `main` module exports web-standard + * handlers (`{ fetch }`, method exports, or `(req, res)`) that Vercel's + * Node launcher accepts natively, so there is NO generated bridge: the + * module is bundled as-is with the shared rolldown machinery + * (`src/Bundle`) and shipped as `index.mjs` inside `functions/index.func/`. + * + * **Effect mode** (`isExternal` unset): a virtual entry wraps the user's + * `main` with the Vercel runtime bridge (DESIGN §6.4): + * + * ```ts + * import entrypoint from ""; + * import { makeVercelBridge } from "alchemy/Vercel"; + * export default makeVercelBridge(entrypoint, { stack: { name, stage } }); + * ``` + * + * The bridge emits a web-standard, streaming-capable `{ fetch }` export; + * deploy-time halves of the init Effect are DCE'd out of the shipped + * bundle via the `__ALCHEMY_RUNTIME__` define. + */ +import * as Effect from "effect/Effect"; +import type * as rolldown from "rolldown"; +import * as Bundle from "../../Bundle/Bundle.ts"; +import * as TempRoot from "../../Bundle/TempRoot.ts"; +import { Stack } from "../../Stack.ts"; +import { Stage } from "../../Stage.ts"; +import { sha256 } from "../../Util/sha256.ts"; +import type { FunctionBuildOptions, FunctionProps } from "./Function.ts"; + +export interface FunctionBundleResult { + /** Identity hash driving change detection in `diff`. */ + readonly identityHash: string; + /** Module files relative to `index.func/` — entry is `index.mjs`. */ + readonly files: ReadonlyArray<{ path: string; bytes: Uint8Array }>; +} + +const encoder = new TextEncoder(); + +const toBytes = (content: string | Uint8Array): Uint8Array => + typeof content === "string" ? encoder.encode(content) : content; + +/** + * Which bridge the generated entry mounts: `"fetch"` (default) is the public + * HTTP function; `"queue"` is the dedicated queue-consumer function (D9a) — + * same user module, but the bridge dispatches CloudEvent queue deliveries to + * the init-registered `subscribe` handlers instead of user `fetch`. + */ +export type FunctionBundleVariant = "fetch" | "queue"; + +/** + * Assemble the rolldown input/output options shared by the one-shot deploy + * build ({@link makeFunctionBundler}) and the dev-mode watch bundler + * (`LocalFunctionProvider`) — keeping them identical is what keeps dev and + * prod bundles from skewing. + */ +export const functionRolldownOptions = (input: { + readonly realMain: string; + readonly cwd: string; + readonly build: FunctionBuildOptions | undefined; + readonly entryPlugin: rolldown.Plugin | undefined; +}): { + readonly inputOptions: rolldown.InputOptions; + readonly outputOptions: rolldown.OutputOptions; +} => { + const { + output: buildOutput, + pure: _pure, + bundleAnalyzer: _bundleAnalyzer, + ...inputOptions + } = input.build ?? {}; + return { + inputOptions: { + ...inputOptions, + input: input.realMain, + cwd: input.cwd, + platform: "node", + plugins: [inputOptions.plugins, input.entryPlugin], + resolve: { + ...inputOptions.resolve, + conditionNames: [ + "bun", + ...( + inputOptions.resolve?.conditionNames ?? [ + "node", + "import", + "module", + "default", + ] + ).filter((condition) => condition !== "bun"), + ], + }, + }, + outputOptions: { + ...buildOutput, + format: "esm", + sourcemap: buildOutput?.sourcemap ?? false, + minify: buildOutput?.minify ?? false, + entryFileNames: "index.mjs", + chunkFileNames: buildOutput?.chunkFileNames ?? "[name]-[hash].mjs", + codeSplitting: buildOutput?.codeSplitting ?? false, + }, + }; +}; + +export const makeFunctionBundler = Effect.gen(function* () { + const virtualEntryPlugin = yield* Bundle.virtualEntryPlugin; + + const bundleFunctionCode: ( + id: string, + props: FunctionProps, + variant?: FunctionBundleVariant, + ) => Effect.Effect = Effect.fn(function* ( + _id: string, + props: FunctionProps, + variant?: FunctionBundleVariant, + ) { + // Inline script: shipped verbatim, no bundling. + if (props.script !== undefined) { + if (variant === "queue") { + return yield* Effect.die( + "Vercel queue subscriptions require an Effect-mode `main` Function — inline `script` cannot carry the generated consumer bundle", + ); + } + const bytes = encoder.encode(props.script); + return { + identityHash: yield* sha256(bytes), + files: [{ path: "index.mjs", bytes }], + } satisfies FunctionBundleResult; + } + if (props.main === undefined) { + return yield* Effect.die( + "Vercel.Function requires one of `main`, `script`, or `prebuilt`", + ); + } + if (variant === "queue" && props.isExternal) { + return yield* Effect.die( + "Vercel queue subscriptions require an Effect-mode Function (a class/inline impl) — async-mode Functions have no init Effect to register `subscribe` handlers", + ); + } + + const realMain = yield* TempRoot.resolveMainPath(props.main); + const cwd = yield* TempRoot.findCwdForBundle(realMain); + + // Effect mode: wrap `main` with the runtime bridge via a virtual entry. + // Async (external) mode ships the user's module as the entry directly. + let entryPlugin: ReturnType | undefined; + if (!props.isExternal) { + const stack = yield* Stack; + const stage = yield* Stage; + entryPlugin = virtualEntryPlugin( + (importPath) => ` +import entrypoint from ${JSON.stringify(importPath)}; +import { makeVercelBridge } from "alchemy/Vercel"; + +export default makeVercelBridge(entrypoint, { + stack: { + name: ${JSON.stringify(stack.name)}, + stage: ${JSON.stringify(stage)}, + },${variant === "queue" ? `\n mode: "queue",` : ""} +}); +`, + ); + } + + const { inputOptions, outputOptions } = functionRolldownOptions({ + realMain, + cwd, + build: props.build, + entryPlugin, + }); + const bundleOutput = yield* Bundle.build( + inputOptions, + outputOptions, + props.build, + ); + + return { + identityHash: bundleOutput.hash, + files: bundleOutput.files.map((file) => ({ + path: file.path, + bytes: toBytes(file.content), + })), + } satisfies FunctionBundleResult; + }); + + return { bundleFunctionCode }; +}); diff --git a/packages/alchemy/src/Vercel/Functions/InvokeFunction.ts b/packages/alchemy/src/Vercel/Functions/InvokeFunction.ts new file mode 100644 index 0000000000..c73084087f --- /dev/null +++ b/packages/alchemy/src/Vercel/Functions/InvokeFunction.ts @@ -0,0 +1,274 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import type * as HttpClientError from "effect/unstable/http/HttpClientError"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as Binding from "../../Binding.ts"; +import type { Named } from "../../Named.ts"; +import { Resource } from "../../Resource.ts"; +import type { RuntimeContext } from "../../RuntimeContext.ts"; +import { sanitizeKey } from "../../RuntimeContext.ts"; +import { FunctionTypeId, isFunction, type Function } from "./Function.ts"; + +/** + * Forward-reference constructor for a target Function: registers (or joins) + * the target's resource row by logical id WITHOUT building its class — the + * #874 bare-tag pattern. The returned handle's attribute Outputs carry the + * dependency edge; the target class's own build repairs the row's Props. + */ +const FunctionForwardRef = Resource(FunctionTypeId); + +/** + * The header Vercel's deployment protection accepts to bypass SSO/preview + * protection — sent on every request when the target function's + * automation-bypass secret is bound (§6.7: the secret is minted at project + * ensure, BEFORE the first deploy, and never regenerated). + */ +const PROTECTION_BYPASS_HEADER = "x-vercel-protection-bypass"; + +/** Env keys the deploy half binds for a target function's logical id. */ +const invokeEnvKey = (logicalId: string, suffix: "URL" | "BYPASS"): string => + `${sanitizeKey(logicalId)}_${suffix}`; + +/** + * The typed HTTP client returned by {@link InvokeFunction} — a full + * `HttpClient` surface (`get`/`post`/`execute`/…) whose requests are + * rewritten against the target Function's read-back URL and carry the + * target's protection-bypass secret. Colored with {@link RuntimeContext}: + * it only functions inside a deployed Function, where the bound env vars + * exist. + */ +export interface InvokeFunctionClient extends HttpClient.HttpClient.With< + HttpClientError.HttpClientError, + RuntimeContext +> { + /** + * The target Function's URL — the value bound from the target's + * READ-BACK production URL (never computed), resolved from env at call + * time. + */ + readonly url: Effect.Effect; +} + +/** + * A by-logical-id forward reference to a target Function. This is the way + * one side of a circular invoke pair names its sibling WITHOUT importing + * the sibling's module: importing the class would make each class's *type* + * depend on its own initializer through the other side's impl, which + * TypeScript collapses to `any` (TS7022). The deploy half resolves the id + * through the same `Function.ref` forward reference it uses for a class + * target, so the runtime topology is identical. + */ +export interface InvokeRef { + readonly LogicalId: string; +} + +/** + * What {@link invoke} accepts: a resolved `Vercel.Function` handle, a + * Function class (its `LogicalId` is read statically — the class is + * deliberately NEVER yielded, see {@link invoke}), or a by-logical-id + * forward reference ({@link InvokeRef}) for the module-cycle-free side of + * a circular pair. + */ +export type InvokeTarget = + | Function + | (Effect.Effect & Named) + | InvokeRef; + +/** + * Invoke another Vercel Function over HTTP. + * + * The deploy half binds two project env vars on the host — the target's + * read-back URL (`{LogicalId}_URL`) and its automation protection-bypass + * secret (`{LogicalId}_BYPASS`, a sensitive env var) — so the runtime + * client can reach the target even when Vercel's deployment protection + * (team SSO / preview protection) guards it. Circular topologies (A + * invokes B, B invokes A) work: the engine pre-creates both projects, so + * each side's URL and bypass secret exist before either deploys. + * + * Provide the implementation with + * `Effect.provide(Vercel.InvokeFunctionHttp)`. + * + * @binding + * @section Invoking another Function + * @example Calling a sibling Function from a handler + * ```typescript + * import Billing from "./billing.ts"; + * + * export default class Api extends Vercel.Function()( + * "Api", + * { main: import.meta.url }, + * Effect.gen(function* () { + * const billing = yield* Vercel.invoke(Billing); + * return { + * fetch: Effect.gen(function* () { + * const res = yield* billing.get("/invoices").pipe(Effect.orDie); + * return yield* HttpServerResponse.json(yield* res.json.pipe(Effect.orDie)); + * }), + * }; + * }).pipe(Effect.provide(Vercel.InvokeFunctionHttp)), + * ) {} + * ``` + * + * @example Circular invocation (A ↔ B) + * ```typescript + * // a.ts — imports B's class normally and invokes it. + * import B from "./b.ts"; + * const b = yield* Vercel.invoke(B); + * + * // b.ts — deliberately does NOT import a.ts. Referencing the sibling by + * // logical id keeps the module graph — and therefore the type graph — + * // acyclic (a mutual class import makes each class's type depend on its + * // own initializer and TypeScript collapses both to `any`). The deploy + * // half forward-refs the id exactly like a class target, and the + * // engine's pre-create step provides both URLs + bypass secrets before + * // either function deploys, so the RUNTIME topology is fully circular. + * const a = yield* Vercel.invoke({ LogicalId: "A" }); + * ``` + */ +export interface InvokeFunction extends Binding.Service< + InvokeFunction, + "Vercel.InvokeFunction", + (fn: Function) => Effect.Effect +> {} + +export const InvokeFunction = Binding.Service( + "Vercel.InvokeFunction", +); + +/** + * Ergonomic alias: `const billing = yield* Vercel.invoke(Billing)`. + * + * Accepts the target Function **class**, a resolved handle, or a + * by-logical-id forward reference (`{ LogicalId: "Billing" }`, see + * {@link InvokeRef}). The class is wrapped so the binding machinery never + * yields it — yielding a sibling class inside another Function's init + * resolves that sibling's `Self`, which deadlocks the layer build in a + * circular A↔B topology. The implementation instead forward-references + * the target through `Function.ref(LogicalId)`, which routes through + * Output resolution and stays acyclic. In a circular pair, at least one + * side must use the by-id form so the *module* graph stays acyclic too — + * see the Circular example on {@link InvokeFunction}. + */ +export const invoke = ( + target: InvokeTarget, +): Effect.Effect => + InvokeFunction(Effect.sync(() => target as unknown as Function)); + +/** + * HTTP implementation of {@link InvokeFunction}. + * + * Deploy half (guarded by `__ALCHEMY_RUNTIME__`): binds the target's `url` + * and `protectionBypass` attributes onto the host's env channel — via a + * `Function.ref` forward reference when handed a class, so circular + * topologies never resolve each other's init. Runtime half: an + * `HttpClient` whose requests are prefixed with the bound URL and carry + * `x-vercel-protection-bypass` when the target has a bypass secret (it may + * not, e.g. a tenant-mode target on a foreign project without one). + * + * ## Runtime authorization + * + * The caller is authorized by the target's **automation protection-bypass + * secret** — minted once per target project at ensure time (before the + * first deploy, never regenerated) and bound here as a `sensitive` env var + * (`{LogicalId}_BYPASS`). It grants exactly one thing: passing Vercel's + * deployment protection (team SSO / preview protection) on requests to + * that one project's deployments — it is NOT a management credential and + * cannot touch the Vercel API. Targets without a secret (foreign tenant + * projects) are called unauthenticated. + * + * Raw-HTTP note: this client is deliberately a plain `HttpClient`, not a + * distilled service — the requests go to the **user's own deployed + * Function** (an arbitrary application endpoint), not to a Vercel API; + * there is no API contract for distilled to type. + * + * Provide on the Function's init Effect: + * `Effect.provide(Vercel.InvokeFunctionHttp)`. + */ +export const InvokeFunctionHttp: Layer.Layer< + InvokeFunction, + never, + HttpClient.HttpClient +> = Layer.effect( + InvokeFunction, + Effect.gen(function* () { + const base = yield* HttpClient.HttpClient; + // Yield the forward-ref class here (its own Effect identity resolves + // the requirement-free constructor) instead of calling the class + // inside the runtime callable — a direct class call would drag the + // Function resource's `Providers` union into the callable's `R`, + // which the Binding.Service contract declares as `never`. + const forwardRef = yield* FunctionForwardRef; + return Effect.fn(function* (fn: Function) { + const logicalId = (fn as { LogicalId?: string }).LogicalId; + if (logicalId === undefined) { + return yield* Effect.die( + new Error( + "Vercel.InvokeFunction: target has no LogicalId — pass a Vercel.Function class or handle", + ), + ); + } + const urlKey = invokeEnvKey(logicalId, "URL"); + const bypassKey = invokeEnvKey(logicalId, "BYPASS"); + if (!globalThis.__ALCHEMY_RUNTIME__) { + // Deploy-time only: bind the TARGET function's read-back URL and + // protection-bypass secret onto the host's project env. Both are + // attribute Outputs, resolved by the engine right before the host + // reconciles (pre-create attributes in circular topologies). A + // class target is turned into a `ref` — never yielded — so mutual + // A↔B invokes stay acyclic at init time. + const host = yield* Binding.Host; + if (isFunction(host)) { + const target = isFunction(fn) + ? fn + : yield* forwardRef(logicalId, undefined); + yield* host.bind`InvokeFunction(${host}, ${logicalId})`({ + env: { + [urlKey]: target.url, + // Redacted attribute ⇒ sensitive project env var; undefined + // (foreign tenant project without a secret) is skipped by the + // provider's env sync and the runtime omits the header. + [bypassKey]: target.protectionBypass, + }, + }); + } + } + // Lazy env reads — the bound vars only exist at the exec phase; the + // widening to the RuntimeContext-colored interface is safe (R is + // contravariant) and pins the client to deployed-Function usage. + const url = Effect.suspend(() => { + const value = process.env[urlKey]; + return value === undefined || value === "" + ? Effect.die( + new Error( + `Vercel.InvokeFunction(${logicalId}): env ${urlKey} is not set — the target's URL binding did not resolve`, + ), + ) + : Effect.succeed(value); + }) as Effect.Effect; + + const client = base.pipe( + HttpClient.mapRequestEffect( + (request) => + Effect.map(url, (targetUrl) => { + let next = HttpClientRequest.prependUrl(request, targetUrl); + const bypass = process.env[bypassKey]; + if (bypass !== undefined && bypass !== "") { + next = HttpClientRequest.setHeader( + next, + PROTECTION_BYPASS_HEADER, + bypass, + ); + } + return next; + }) as Effect.Effect< + HttpClientRequest.HttpClientRequest, + never, + RuntimeContext + >, + ), + ); + return Object.assign(client, { url }) as InvokeFunctionClient; + }); + }), +); diff --git a/packages/alchemy/src/Vercel/Functions/LocalFunctionProvider.ts b/packages/alchemy/src/Vercel/Functions/LocalFunctionProvider.ts new file mode 100644 index 0000000000..54838e9a50 --- /dev/null +++ b/packages/alchemy/src/Vercel/Functions/LocalFunctionProvider.ts @@ -0,0 +1,779 @@ +/** + * The dev-mode (`alchemy dev`) provider for `Vercel.Function` — emulates a + * Fluid Function on the developer's machine instead of deploying it + * (DESIGN §"Wave 3", ProviderMode doctrine). + * + * Architecture (mirrors `Cloudflare/Workers/LocalWorkerProvider.ts`, one + * size smaller): + * + * - **Bundling**: the SAME rolldown options as the deploy path + * (`functionRolldownOptions`) in watch mode, with a dev virtual entry + * that mounts the user module (async mode) or the `makeVercelBridge` + * wrapper (Effect mode) inside the {@link DevShim} — a Node HTTP server + * implementing Vercel's full launcher matrix (`{ fetch }`, method + * exports, `(req, res)`). `__ALCHEMY_RUNTIME__` folds to `true` exactly + * like a deployed bundle. + * - **Isolation**: each Function runs as its own child process, because + * each deployed Vercel Function owns its `process.env`. The provider + * injects `VERCEL=1`, `VERCEL_ENV=development`, `VERCEL_URL`, + * `VERCEL_DEPLOYMENT_ID=dev:…`, the resolved binding env (Redacted + * unwrapped at the process boundary), and `CRON_SECRET` when crons are + * registered. + * - **URL stability**: a per-Function proxy + * ({@link serveFunctionProxy}) owns the stable `http://localhost:` + * URL OUTSIDE the instance scope (torn down only in `stop`); rebuilds + * and restarts are make-before-break — the previous child serves until + * the replacement is ready, then the proxy cuts over. + * - **Cron emulation**: an instance-scoped timer per registered cron + * entry, firing `GET ` through the proxy with + * `user-agent: vercel-cron/1.0` and `Authorization: Bearer $CRON_SECRET` + * — byte-identical to the platform invoker's request shape. + * - **Queues**: when the Function registered `subscribe` bindings, the dev + * entry also mounts the queue-mode bridge and the shim serves it at + * `QUEUE_DELIVERY_PATH`; the running instance is published in + * {@link LocalFunctionState}, from which the sidecar's + * {@link LocalQueueBroker} push-delivers. Every child is pointed at the + * broker's data plane via `VERCEL_QUEUE_BASE_URL`/`VERCEL_QUEUE_TOKEN` + * (the official `vercel dev` env contract), so `SendMessage` / + * `ReceiveMessages` / the consumer bridge's ack work locally unchanged. + * + * `source`/`prebuilt` Functions (Websites) have no local emulation yet — + * they must be piped through `Alchemy.remote()` in dev. + */ +import * as Cause from "effect/Cause"; +import * as Cron from "effect/Cron"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as MutableHashMap from "effect/MutableHashMap"; +import * as Path from "effect/Path"; +import * as Redacted from "effect/Redacted"; +import * as Result from "effect/Result"; +import * as Schedule from "effect/Schedule"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import { fileURLToPath } from "node:url"; +import { AlchemyContext } from "../../AlchemyContext.ts"; +import * as Bundle from "../../Bundle/Bundle.ts"; +import * as TempRoot from "../../Bundle/TempRoot.ts"; +import * as LocalProvider from "../../Local/LocalProvider.ts"; +import { createPhysicalName } from "../../PhysicalName.ts"; +import type { ResourceBinding } from "../../Resource.ts"; +import { Stack } from "../../Stack.ts"; +import { sha256 } from "../../Util/sha256.ts"; +import type { + BuildOutputRoute, + CronEntry, + QueueTriggerEntry, +} from "../Deploy/BuildOutput.ts"; +import { LOCAL_ENTRY_URL, LocalFunctionState } from "../LocalRuntime.ts"; +import { + DEV_QUEUE_TOKEN, + LocalQueueBroker, +} from "../Queues/LocalQueueBroker.ts"; +import { + DEV_PORT_ENV, + DEV_READY_PREFIX, + DEV_READY_SUFFIX, + QUEUE_DELIVERY_PATH, +} from "./DevShim.ts"; +import { CRON_SECRET_ENV } from "./FunctionBridge.ts"; +import { functionRolldownOptions } from "./FunctionBundle.ts"; +import { Function, isSelfUrl, type FunctionProps } from "./Function.ts"; +import { serveFunctionProxy } from "./LocalFunctionProxy.ts"; + +// Resolved lazily at bundle time, NOT at module load (mirrors ViteChild's +// resolveRunner note): `import.meta.resolve` only exists in Node/bun. +const shimModulePath = () => + fileURLToPath( + import.meta.resolve( + import.meta.url.endsWith(".ts") ? "./DevShim.ts" : "./DevShim.js", + ), + ); + +/** How long a spawned child may take to print its readiness line. */ +const CHILD_READY_TIMEOUT = "60 seconds"; + +export const LocalFunctionProvider = () => + LocalProvider.make( + Function, + LOCAL_ENTRY_URL, + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const stack = yield* Stack; + const { dotAlchemy } = yield* AlchemyContext; + const localState = yield* LocalFunctionState; + const httpClient = yield* HttpClient.HttpClient; + const virtualEntry = yield* Bundle.virtualEntryPlugin; + const rootScope = yield* Effect.scope; + // The local queue data plane (send/receive/ack) every dev child is + // pointed at via VERCEL_QUEUE_BASE_URL — the same env contract the + // official `vercel dev` uses. Serving is idempotent; the server lives + // in the provider's root scope. + const queueBroker = yield* LocalQueueBroker; + const queueBrokerUrl = yield* queueBroker.serve; + + const baseDirectory = path.join(dotAlchemy, "local", "vercel"); + + // ── Proxies: deliberately NOT owned by the per-instance scope — they + // survive restarts so the Function's URL stays stable across rebuilds + // and config changes. Torn down by `stop` on delete. + const proxies = new Map< + string, + { + port: number | undefined; + instance: { url: string; set: (next: string) => Effect.Effect }; + scope: Scope.Closeable; + } + >(); + + const ensureProxy = Effect.fn(function* ( + id: string, + port: number | undefined, + ) { + const existing = proxies.get(id); + if (existing !== undefined) { + if (existing.port === port) return existing.instance; + yield* Scope.close(existing.scope, Exit.void); + proxies.delete(id); + } + const scope = yield* Scope.fork(rootScope); + const instance = yield* serveFunctionProxy({ port }).pipe( + Scope.provide(scope), + Effect.onExit((exit) => + exit._tag === "Failure" ? Scope.close(scope, exit) : Effect.void, + ), + ); + proxies.set(id, { port, instance, scope }); + return instance; + }); + + // ── Running children: keyed by logical id, scopes forked from the + // provider root scope (NOT the instance scope) so the last good child + // keeps serving across instance restarts until its replacement's + // first serve completes (make-before-break). Reclaimed by the next + // successful serve, by `stop`, or by provider shutdown. + const children = new Map< + string, + { scope: Scope.Closeable; directory: string; token: object } + >(); + + // Serializes serves per id so an in-flight rebuild serve can never + // interleave with another and leak a child scope. + const serveLocks = new Map(); + const serveLock = (id: string) => { + let lock = serveLocks.get(id); + if (!lock) { + lock = Semaphore.makeUnsafe(1); + serveLocks.set(id, lock); + } + return lock; + }; + + const closeChild = Effect.fn(function* (id: string) { + const existing = children.get(id); + if (existing !== undefined) { + children.delete(id); + yield* Scope.close(existing.scope, Exit.void); + yield* fs + .remove(existing.directory, { recursive: true }) + .pipe(Effect.ignore); + } + }); + + /** + * The restart-relevant, canonically-hashable view of a local + * Function's desired state — `LocalProvider`'s `resolveConfig`. + * Plain data only; the bundler and child-process wiring are + * materialized in `start`. + */ + const resolveConfig = Effect.fn(function* ({ + id, + news, + bindings, + }: LocalProvider.LocalProviderInput) { + const props = news as FunctionProps; + if (props.source !== undefined || props.prebuilt !== undefined) { + return yield* Effect.die( + `Vercel.Function(${id}): \`source\`/\`prebuilt\` Functions (Websites) have no local dev emulation yet — pipe the resource through Alchemy.remote() to deploy it live during dev`, + ); + } + const name = + props.name ?? + // Mirrors the live provider's createProjectName (35-char cap — + // see Function.ts for the domain-truncation rationale) so dev + // and live generate identical names. + (yield* createPhysicalName({ + id, + maxLength: 35, + suffixLength: 8, + lowercase: true, + })); + // Merge the binding channel (same shape as the live provider's + // collectBindings). + const active = bindings.filter( + (b: ResourceBinding & { action?: string }) => + b.action !== "delete", + ); + const bindingEnv = active + .map((b) => b?.data?.env) + .reduce>((acc, e) => ({ ...acc, ...e }), {}); + const crons: CronEntry[] = active.flatMap((b) => b?.data?.crons ?? []); + const routes: BuildOutputRoute[] = active.flatMap( + (b) => b?.data?.routes ?? [], + ); + const queues: QueueTriggerEntry[] = active.flatMap( + (b) => b?.data?.queues ?? [], + ); + return { + id, + name, + main: + props.main !== undefined + ? yield* TempRoot.resolveMainPath(props.main) + : undefined, + /** Inline module source (shipped verbatim on deploy) — part of + * the hashed config so editing it restarts the instance. */ + script: props.script, + isExternal: props.isExternal ?? false, + /** Bundler overrides. Function-valued members (plugins) are + * dropped by the canonical hasher — same caveat as the live + * Cloudflare local config. */ + build: props.build, + /** User env (Redacted preserved — the canonical hasher unwraps). */ + env: props.env, + bindingEnv, + crons, + /** Contributed Build Output routes. Hash-relevant, but the local + * shim serves the single function for every path (matching the + * deployed filesystem-handler fallthrough for a lone function) — + * route entries are not interpreted locally. */ + routes, + queues, + runtime: props.runtime, + port: + typeof props.dev?.port === "number" ? props.dev.port : undefined, + }; + }); + + type LocalFunctionConfig = Effect.Success< + ReturnType + >; + + const deriveCronSecret = (name: string) => + sha256(`${stack.name}:${stack.stage}:${name}:cron-secret`); + + /** + * The child's environment: resolved binding env ⊎ props env (self-URL + * sentinels substituted with the stable proxy URL, Redacted unwrapped + * at the process boundary, non-strings JSON-coerced like the live + * `coerceEnvValue`), plus the Vercel platform variables a deployed + * instance sees. + */ + const resolveChildEnv = (input: { + config: LocalFunctionConfig; + url: string; + deploymentId: string; + cronSecret: string | undefined; + }): Record => { + const { config, url, deploymentId, cronSecret } = input; + const host = url.replace(/^https?:\/\//, ""); + const env: Record = {}; + // Props env wins over binding env on key conflict (matching the + // live provider) — EXCEPT that an `undefined` prop value must not + // shadow a binding value: `stripEffects` nulls Effect-valued prop + // leaves (e.g. the `Function.URL` sentinel), whose plain marker + // arrives through the binding channel instead. + const merged: Record = { ...config.bindingEnv }; + for (const [key, value] of Object.entries(config.env ?? {})) { + if (value !== undefined) merged[key] = value; + } + for (const [key, raw] of Object.entries(merged)) { + if (raw === undefined) continue; + const value = isSelfUrl(raw) ? url : raw; + env[key] = Redacted.isRedacted(value) + ? String(Redacted.value(value)) + : typeof value === "string" + ? value + : JSON.stringify(value); + } + if (cronSecret !== undefined) { + env[CRON_SECRET_ENV] = cronSecret; + } + return { + // Queue clients (ours and `@vercel/queue`) redirect to the local + // broker via these; user env may override them. + VERCEL_QUEUE_BASE_URL: queueBrokerUrl, + VERCEL_QUEUE_TOKEN: DEV_QUEUE_TOKEN, + ...env, + VERCEL: "1", + VERCEL_ENV: "development", + VERCEL_TARGET_ENV: "development", + VERCEL_URL: host, + VERCEL_BRANCH_URL: host, + VERCEL_PROJECT_PRODUCTION_URL: host, + VERCEL_DEPLOYMENT_ID: deploymentId, + VERCEL_REGION: "dev1", + NODE_ENV: "development", + }; + }; + + /** The generated dev bundle entry (see the module doc). */ + const devEntryCode = (config: LocalFunctionConfig) => { + const shim = JSON.stringify(shimModulePath()); + const stackMeta = JSON.stringify({ + name: stack.name, + stage: stack.stage, + }); + return (importPath: string) => { + if (config.isExternal || config.script !== undefined) { + return ` +import * as entrypoint from ${JSON.stringify(importPath)}; +import { startVercelFunctionDevServer } from ${shim}; +startVercelFunctionDevServer({ module: entrypoint }); +`; + } + const hasQueues = config.queues.length > 0; + return ` +import entrypoint from ${JSON.stringify(importPath)}; +import { makeVercelBridge } from "alchemy/Vercel"; +import { startVercelFunctionDevServer } from ${shim}; +const stack = ${stackMeta}; +const bridge = makeVercelBridge(entrypoint, { stack }); +${ + hasQueues + ? `const queueBridge = makeVercelBridge(entrypoint, { stack, mode: "queue" });` + : "" +} +startVercelFunctionDevServer({ + module: { default: bridge },${ + hasQueues ? `\n queueFetch: (request) => queueBridge.fetch(request),` : "" + } +}); +`; + }; + }; + + /** Spawn one child serving the written bundle; resolves at readiness. */ + const spawnChild = Effect.fn(function* (input: { + id: string; + entry: string; + directory: string; + env: Record; + }) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const isBun = + typeof (globalThis as { Bun?: unknown }).Bun !== "undefined"; + const child = yield* spawner.spawn( + ChildProcess.make( + process.execPath, + isBun ? ["run", input.entry] : [input.entry], + { + cwd: input.directory, + env: { ...input.env, [DEV_PORT_ENV]: "0" }, + extendEnv: true, + stdout: "pipe", + stderr: "pipe", + }, + ), + ); + const ready = yield* Deferred.make(); + const mirror = (channel: "stdout" | "stderr") => + child[channel].pipe( + Stream.decodeText, + Stream.splitLines, + Stream.runForEach((line) => + Effect.sync(() => { + const start = line.indexOf(DEV_READY_PREFIX); + const end = line.indexOf(DEV_READY_SUFFIX); + if (start !== -1 && end > start) { + Deferred.doneUnsafe( + ready, + Effect.succeed( + line.slice(start + DEV_READY_PREFIX.length, end), + ), + ); + return; + } + process[channel].write(`${input.id} | ${line}\n`); + }), + ), + Effect.forkScoped, + ); + yield* mirror("stdout"); + yield* mirror("stderr"); + const url = yield* Effect.raceAllFirst([ + Deferred.await(ready).pipe( + Effect.timeoutOrElse({ + duration: CHILD_READY_TIMEOUT, + orElse: () => + Effect.die( + `Vercel.Function(${input.id}): local dev server did not become ready within ${CHILD_READY_TIMEOUT}`, + ), + }), + ), + child.exitCode.pipe( + Effect.exit, + Effect.flatMap((exit) => + Effect.die( + `Vercel.Function(${input.id}): local dev server exited before becoming ready (${ + exit._tag === "Success" ? `code ${exit.value}` : "spawn error" + })`, + ), + ), + ), + ]); + return { url, exitCode: child.exitCode }; + }); + + /** + * Serve one bundle with make-before-break semantics: write it to a + * fresh generation directory, boot the replacement child, cut the + * proxy over, then tear the previous child down. + */ + const serveBundle = (input: { + id: string; + bundle: Bundle.BundleOutput; + env: Record; + proxy: { url: string; set: (next: string) => Effect.Effect }; + invalidate: Effect.Effect; + generation: () => number; + }) => + Semaphore.withPermits( + serveLock(input.id), + 1, + )( + // Once the replacement child is up it must be recorded and the + // previous child must be closed — an interrupt tearing this in + // half would leak a process until provider shutdown. + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const directory = path.join( + baseDirectory, + encodeURIComponent(input.id), + String(input.generation()), + ); + yield* fs.makeDirectory(directory, { recursive: true }); + for (const file of input.bundle.files) { + const filePath = path.join(directory, file.path); + yield* fs.makeDirectory(path.dirname(filePath), { + recursive: true, + }); + yield* typeof file.content === "string" + ? fs.writeFileString(filePath, file.content) + : fs.writeFile(filePath, file.content); + } + const previous = children.get(input.id); + const token = {}; + const scope = yield* Scope.fork(rootScope); + const child = yield* restore( + spawnChild({ + id: input.id, + entry: path.join(directory, input.bundle.files[0].path), + directory, + env: input.env, + }).pipe(Scope.provide(scope)), + ).pipe( + Effect.onExit((exit) => + exit._tag === "Failure" + ? Scope.close(scope, exit) + : Effect.void, + ), + ); + children.set(input.id, { scope, directory, token }); + // Unexpected child death: mark the instance for update on the + // next plan (guarded so a superseded child's death is inert). + yield* child.exitCode.pipe( + Effect.exit, + Effect.flatMap(() => + Effect.suspend(() => + children.get(input.id)?.token === token + ? Effect.andThen( + Effect.sync(() => children.delete(input.id)), + input.invalidate, + ) + : Effect.void, + ), + ), + Effect.forkIn(scope), + ); + yield* input.proxy.set(child.url); + if (previous !== undefined) { + yield* Scope.close(previous.scope, Exit.void).pipe( + Effect.catchCause((cause) => + Effect.logWarning( + `[${input.id}] Failed to stop previous local function instance`, + Cause.squash(cause), + ), + ), + ); + // The generation counter is per-instance, so the first + // serve after a restart may have REUSED the previous + // child's directory — never delete the one just served. + if (previous.directory !== directory) { + yield* fs + .remove(previous.directory, { recursive: true }) + .pipe(Effect.ignore); + } + } + }), + ), + ); + + return { + resolveConfig, + + // The physical name only survives an update when it isn't changing. + stables: ({ output, config }) => + Effect.succeed( + output?.projectName === config.name + ? (["projectId", "projectName"] as ["projectId", "projectName"]) + : undefined, + ), + + // Pre-create keeps circular bindings (InvokeFunction A↔B) working + // in dev: the stub already carries the stable proxy URL. + precreate: Effect.fn(function* ({ id, news }) { + const props = news as FunctionProps; + const name = + typeof props.name === "string" + ? props.name + : // Mirrors the live createProjectName (35-char cap, see + // Function.ts). + yield* createPhysicalName({ + id, + maxLength: 35, + suffixLength: 8, + lowercase: true, + }); + const proxy = yield* ensureProxy( + id, + typeof props.dev?.port === "number" ? props.dev.port : undefined, + ); + return { + projectId: `dev:${name}`, + projectName: name, + deploymentId: "", + url: proxy.url, + stageAlias: undefined, + hash: { artifact: "", code: "", env: "" }, + managedEnv: [], + protectionBypass: undefined, + cronSecret: undefined, + } satisfies Function["Attributes"]; + }), + + start: Effect.fn(function* ({ id, config, invalidate }) { + // Satisfy the proxy's HttpClient requirement from the layer-scope + // capture — LocalProvider.make's start contract doesn't carry + // HttpClient in its R. + const proxy = yield* ensureProxy(id, config.port).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + ); + const configHash = yield* LocalProvider.canonicalHash(config); + const deploymentId = `dev:dpl_${configHash.slice(0, 24)}`; + const cronSecret = + config.crons.length > 0 + ? yield* deriveCronSecret(config.name) + : undefined; + const env = resolveChildEnv({ + config, + url: proxy.url, + deploymentId, + cronSecret, + }); + + let generation = 0; + const nextGeneration = () => ++generation; + const serve = (bundle: Bundle.BundleOutput) => + serveBundle({ + id, + bundle, + env, + proxy, + invalidate, + generation: nextGeneration, + }); + + // ── Bundle + serve. Inline `script` is written to a synthetic + // entry file and built once (the script string is part of the + // hashed config, so edits restart the instance); `main` runs the + // rolldown watcher and re-serves every successful rebuild. + const entryFile = yield* Effect.gen(function* () { + if (config.script !== undefined) { + const scriptPath = path.join( + baseDirectory, + encodeURIComponent(id), + "script", + "index.mjs", + ); + yield* fs.makeDirectory(path.dirname(scriptPath), { + recursive: true, + }); + yield* fs.writeFileString(scriptPath, config.script); + return scriptPath; + } + if (config.main === undefined) { + return yield* Effect.die( + `Vercel.Function(${id}): one of \`main\` or \`script\` is required`, + ); + } + return config.main; + }); + const cwd = yield* TempRoot.findCwdForBundle(entryFile); + const { inputOptions, outputOptions } = functionRolldownOptions({ + realMain: entryFile, + cwd, + build: config.build, + entryPlugin: virtualEntry(devEntryCode(config)), + }); + + if (config.script !== undefined) { + const bundle = yield* Bundle.build( + inputOptions, + outputOptions, + config.build, + ); + yield* serve(bundle).pipe( + Effect.catchCause((cause) => + Effect.logError(`[${id}] Error`, Cause.squash(cause)), + ), + Effect.forkScoped, + ); + } else { + // Watch loop, forked into the instance scope: serve every + // successful bundle; log build/serve errors without tearing the + // instance down (fix the code, the watcher rebuilds). + let startedAt = Date.now(); + let status: "start" | "update" = "start"; + yield* Bundle.watch(inputOptions, outputOptions, config.build).pipe( + Stream.tap((event) => { + if (event._tag === "Start") { + startedAt = Date.now(); + return status === "update" + ? Effect.log(`[${id}] Rebuilding`) + : Effect.void; + } + if (event._tag === "Error") { + return Effect.logError(`[${id}] Bundle error`, event.error); + } + return Effect.void; + }), + Stream.filterMap((event) => + event._tag === "Success" + ? Result.succeed(event.output) + : Result.failVoid, + ), + Stream.mapEffect((bundle) => + serve(bundle).pipe( + Effect.exit, + Effect.tap((exit) => { + if (exit._tag === "Success") { + const message = Effect.log( + `[${id}] ${status === "update" ? "Updated" : "Started"} in ${Math.round(Date.now() - startedAt)}ms → ${proxy.url}`, + ); + status = "update"; + return message; + } + return Effect.logError( + `[${id}] Error`, + Cause.squash(exit.cause), + ); + }), + ), + ), + Stream.runDrain, + Effect.forkScoped, + ); + } + + // ── Cron emulation: instance-scoped timers firing the platform + // invoker's exact request shape at each schedule match (UTC). + for (const entry of config.crons) { + const parsed = Cron.parse(entry.schedule, "UTC"); + if (Result.isFailure(parsed)) { + yield* Effect.logWarning( + `[${id}] Invalid cron expression "${entry.schedule}": ${parsed.failure.message}`, + ); + continue; + } + yield* httpClient + .execute( + HttpClientRequest.get(`${proxy.url}${entry.path}`).pipe( + HttpClientRequest.setHeaders({ + "user-agent": "vercel-cron/1.0", + authorization: `Bearer ${cronSecret!}`, + }), + ), + ) + .pipe( + Effect.asVoid, + Effect.catchCause((cause) => + Effect.logWarning( + `[${id}] Local cron ${entry.schedule} fire failed`, + Cause.squash(cause), + ), + ), + Effect.schedule(Schedule.cron(parsed.success)), + Effect.forkScoped, + ); + } + + // ── Publish the running instance for sibling local services (the + // phase-2 queue-delivery seam, see LocalRuntime.ts). + MutableHashMap.set(localState.functions, id, { + functionId: id, + url: proxy.url, + deploymentId, + queues: config.queues, + queueEndpoint: + config.queues.length > 0 + ? `${proxy.url}${QUEUE_DELIVERY_PATH}` + : undefined, + cronSecret, + }); + + return { + projectId: `dev:${config.name}`, + projectName: config.name, + deploymentId, + url: proxy.url, + stageAlias: undefined, + hash: { artifact: "", code: configHash, env: "" }, + managedEnv: [], + protectionBypass: undefined, + cronSecret: + cronSecret !== undefined ? Redacted.make(cronSecret) : undefined, + } satisfies Function["Attributes"]; + }), + + stop: Effect.fn(function* ({ id }) { + // Cross-restart state: the running child (which outlives instance + // scopes for make-before-break) and the URL proxy live outside + // instance scopes and are only reclaimed on a real delete. + MutableHashMap.remove(localState.functions, id); + yield* closeChild(id); + const proxy = proxies.get(id); + if (proxy !== undefined) { + proxies.delete(id); + yield* Scope.close(proxy.scope, Exit.void); + } + }), + } satisfies LocalProvider.LocalProviderSpec< + Function, + LocalFunctionConfig, + // `spawnChild` (ChildProcessSpawner) and the bundle writer + // (FileSystem/Path) run inside `start`. + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | Path.Path + >; + }), + ); diff --git a/packages/alchemy/src/Vercel/Functions/LocalFunctionProxy.ts b/packages/alchemy/src/Vercel/Functions/LocalFunctionProxy.ts new file mode 100644 index 0000000000..c618f85680 --- /dev/null +++ b/packages/alchemy/src/Vercel/Functions/LocalFunctionProxy.ts @@ -0,0 +1,131 @@ +/** + * The stable-URL proxy in front of a locally running Vercel Function + * (the `WorkerProxy` pattern, Effect-native): + * + * - The proxy owns the Function's public `http://localhost:` URL and + * lives OUTSIDE the provider's per-instance scope, so the URL survives + * rebuilds, config restarts, and even instance replacement — it is torn + * down only by the provider's `stop` (delete). + * - Rebuilds are make-before-break: the previous child keeps serving until + * the replacement is ready, then {@link LocalFunctionProxyInstance.set} + * cuts over atomically. + * - Requests arriving before the FIRST child is ready are held until the + * upstream appears (bounded), so `deploy` + immediate `fetch` works. + */ +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import type * as Scope from "effect/Scope"; +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 * as HttpServer from "effect/unstable/http/HttpServer"; +import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import { httpServer } from "../../Util/PlatformServices.ts"; + +export interface LocalFunctionProxyInstance { + /** Stable public origin, e.g. `http://localhost:53123` (no trailing slash). */ + readonly url: string; + /** Cut requests over to a new upstream origin (make-before-break). */ + readonly set: (upstream: string) => Effect.Effect; +} + +/** How long early requests wait for the first upstream before a 503. */ +const FIRST_UPSTREAM_TIMEOUT = "120 seconds"; + +/** + * Serve a proxy in the ambient `Scope`. `port` is a preference: when taken, + * the proxy logs a warning and falls back to an ephemeral port. + */ +export const serveFunctionProxy = Effect.fn(function* (options: { + readonly port?: number | undefined; +}) { + const client = yield* HttpClient.HttpClient; + const upstream = yield* Ref.make(undefined); + const everSet = yield* Deferred.make(); + + const handler = Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + // Hold early requests until the first child is serving. + yield* Deferred.await(everSet).pipe( + Effect.timeoutOrElse({ + duration: FIRST_UPSTREAM_TIMEOUT, + orElse: () => Effect.void, + }), + ); + const origin = yield* Ref.get(upstream); + if (origin === undefined) { + return HttpServerResponse.text("local function is not ready yet", { + status: 503, + }); + } + // `toClientRequest` already parsed the query string into `urlParams`, + // so the rewritten URL carries only the path — the params re-attach at + // execution (appending the full `request.url` would duplicate them). + // The child sees the ORIGINAL Host header, so URLs it constructs show + // the stable proxy address. + let outgoing = HttpServerRequest.toClientRequest(request).pipe( + HttpClientRequest.setUrl(`${origin}${request.url.split("?")[0]}`), + ); + // A request that declares no body (no content-length, not chunked) + // must forward WITHOUT a body stream: fetch with an (empty) stream + // body fails on bodied-method requests like DELETE ("Transport + // error") and would force chunked encoding upstream. + const declaresBody = + (request.headers["content-length"] !== undefined && + request.headers["content-length"] !== "0") || + request.headers["transfer-encoding"] !== undefined; + if (!declaresBody) { + outgoing = HttpClientRequest.setBody(outgoing, HttpBody.empty); + } + return yield* client.execute(outgoing).pipe( + Effect.map(HttpServerResponse.fromClientResponse), + Effect.catchCause((cause) => + Effect.as( + Effect.logWarning("[alchemy dev] proxy forward failed", cause), + HttpServerResponse.text("local function proxy error", { + status: 502, + }), + ), + ), + ); + }); + + const build = (port: number) => + Layer.build(httpServer(port, "127.0.0.1")) as Effect.Effect< + Context.Context, + unknown, + Scope.Scope + >; + const context = yield* options.port !== undefined + ? build(options.port).pipe( + Effect.catch((error) => + Effect.andThen( + Effect.logWarning( + `Port ${options.port} is in use by another process; serving the local function on an ephemeral port instead. Stop the other process or pick a different \`dev.port\`.`, + error, + ), + build(0), + ), + ), + ) + : build(0); + const server = Context.get(context, HttpServer.HttpServer); + yield* server.serve(handler); + + const url = HttpServer.formatAddress(server.address) + .replace("127.0.0.1", "localhost") + .replace(/\/$/, ""); + + return { + url, + set: (next: string) => + Effect.andThen( + Ref.set(upstream, next.replace(/\/$/, "")), + Deferred.succeed(everSet, void 0), + ).pipe(Effect.asVoid), + } satisfies LocalFunctionProxyInstance; +}); diff --git a/packages/alchemy/src/Vercel/Functions/Logs.ts b/packages/alchemy/src/Vercel/Functions/Logs.ts new file mode 100644 index 0000000000..3321a6080a --- /dev/null +++ b/packages/alchemy/src/Vercel/Functions/Logs.ts @@ -0,0 +1,233 @@ +/** + * `alchemy logs` / `alchemy tail` wiring for {@link Function} (DESIGN § + * "logs/tail wiring"). + * + * Platform facts (live-verified, PROBES.md): + * + * - `GET /v1/projects/{projectId}/deployments/{deploymentId}/runtime-logs` + * is a **live-only hanging NDJSON stream** — it delivers 0 bytes of + * historical logs and then emits rows as invocations happen. It is + * therefore consumed as a raw `HttpClient` stream (the distilled + * `logs.getRuntimeLogs` operation models a request/response exchange and + * would hang draining the body), the same way Cloudflare's tail rides a + * raw websocket next to its distilled service. + * - `GET /v3/deployments/{idOrUrl}/events` (distilled + * `deployments.getDeploymentEvents`) is the only *historical* log source + * the API offers: build/deployment events (stdout/stderr/command lines). + * + * So the two provider hooks map as: + * + * - **`tail`** — the runtime-logs stream, re-opened when Vercel closes it + * (the server ends the response after an idle window), until the caller + * interrupts. + * - **`logs`** — historical build events, plus a short bounded live window + * on the runtime-logs stream so invocations happening right now surface + * too (the platform keeps no runtime-log history to query). + */ +import { Credentials } from "@distilled.cloud/vercel/Credentials"; +import * as deployments from "@distilled.cloud/vercel/deployments"; +import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; +import * as Stream from "effect/Stream"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import type { LogLine, LogsInput } from "../../Provider.ts"; +import { VercelEnvironment } from "../VercelEnvironment.ts"; + +/** Default `logs` page size when the caller passes no `--limit`. */ +const DEFAULT_LIMIT = 100; + +/** + * How long a `logs` call keeps the live runtime-logs stream open. The + * endpoint has no historical delivery at all, so this bounded window is the + * only way `alchemy logs` can show runtime lines — anything longer trades + * CLI latency for a bigger capture chance. + */ +const RUNTIME_LOG_WINDOW = "5 seconds"; + +/** A single NDJSON row of the runtime-logs stream (fields per Vercel docs). */ +interface RuntimeLogRow { + level?: string; + message?: string; + source?: string; + rowId?: string; + timestampInMs?: number; + requestMethod?: string; + requestPath?: string; + responseStatusCode?: number; +} + +/** Render one runtime-log row as a {@link LogLine}, or skip it. */ +const runtimeRowToLine = (raw: string): LogLine | undefined => { + const text = raw.trim(); + if (text.length === 0) return undefined; + let row: RuntimeLogRow; + try { + row = JSON.parse(text) as RuntimeLogRow; + } catch { + // Keep-alive / non-JSON noise on the stream. + return undefined; + } + if (typeof row !== "object" || row === null) return undefined; + const timestamp = new Date( + typeof row.timestampInMs === "number" ? row.timestampInMs : Date.now(), + ); + const message = typeof row.message === "string" ? row.message : ""; + if ( + message.length === 0 && + typeof row.requestMethod === "string" && + typeof row.requestPath === "string" + ) { + // Request-summary rows carry no message body. + const status = row.responseStatusCode ?? "-"; + return { + timestamp, + message: `${row.requestMethod} ${row.requestPath} > ${status}`, + }; + } + if (message.length === 0) return undefined; // delimiter/heartbeat rows + const level = row.level ?? "info"; + return { + timestamp, + message: level === "info" ? message : `${level}: ${message}`, + }; +}; + +/** Normalize one deployment (build) event to a {@link LogLine}. */ +const buildEventToLine = (event: unknown): LogLine | undefined => { + if (typeof event !== "object" || event === null) return undefined; + const item = event as { + created?: number; + date?: number; + text?: string; + payload?: { text?: string; date?: number; created?: number }; + }; + const text = item.payload?.text ?? item.text; + if (typeof text !== "string" || text.length === 0) return undefined; + const ms = + item.payload?.date ?? + item.date ?? + item.payload?.created ?? + item.created ?? + Date.now(); + return { timestamp: new Date(ms), message: text }; +}; + +export interface FunctionLogsTarget { + readonly projectId: string; + /** May be `""` on a never-deployed row (precreate stub) — yields no logs. */ + readonly deploymentId: string; +} + +/** + * Shared log-access clients for the Vercel Function provider — resolved once + * in the provider's init Effect (mirrors `CloudflareLogs`) and closed over + * by the `tail`/`logs` hooks. + */ +export const VercelFunctionLogs = Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const credentials = yield* Credentials; + const environment = yield* VercelEnvironment; + const getDeploymentEvents = yield* deployments.getDeploymentEvents; + + /** + * Open the live runtime-logs NDJSON stream for one deployment. The + * response hangs and emits rows as invocations happen; interruption + * aborts the underlying fetch. + */ + const openRuntimeStream = (target: FunctionLogsTarget) => + Effect.gen(function* () { + const { token, apiBaseUrl } = yield* credentials; + const { teamId } = yield* environment; + const url = new URL( + `/v1/projects/${target.projectId}/deployments/${target.deploymentId}/runtime-logs`, + apiBaseUrl, + ); + if (teamId !== undefined) url.searchParams.set("teamId", teamId); + const response = yield* client.execute( + HttpClientRequest.bearerToken( + HttpClientRequest.get(url.toString()), + token, + ), + ); + if (response.status < 200 || response.status >= 300) { + const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); + return yield* Effect.fail( + new Error( + `Vercel runtime-logs stream for deployment ${target.deploymentId} failed with status ${response.status}: ${body.slice(0, 300)}`, + ), + ); + } + return response.stream.pipe( + Stream.decodeText, + Stream.splitLines, + Stream.map(runtimeRowToLine), + Stream.filter((line): line is LogLine => line !== undefined), + ); + }); + + /** Bounded-retry the connect only — mid-stream failures propagate. */ + const runtimeStream = (target: FunctionLogsTarget) => + Stream.unwrap( + openRuntimeStream(target).pipe( + Effect.retry({ + schedule: Schedule.exponential("500 millis"), + times: 3, + }), + ), + ); + + /** + * `alchemy tail`: stream runtime log lines until interrupted. Vercel ends + * the hanging response after its idle window, so the session is re-opened + * on clean end (live-only delivery means reconnects never duplicate). + */ + const tail = (target: FunctionLogsTarget): Stream.Stream => + target.deploymentId === "" + ? Stream.empty + : runtimeStream(target).pipe(Stream.repeat(Schedule.spaced("1 second"))); + + /** + * `alchemy logs`: historical build/deployment events, plus whatever the + * live runtime stream delivers inside a short bounded window (the + * platform keeps no queryable runtime-log history — PROBES.md). + */ + const logs = (input: { + readonly target: FunctionLogsTarget; + readonly options: LogsInput; + }): Effect.Effect => + Effect.gen(function* () { + if (input.target.deploymentId === "") return []; + const limit = input.options.limit ?? DEFAULT_LIMIT; + const { teamId } = yield* environment; + + const events = yield* getDeploymentEvents({ + idOrUrl: input.target.deploymentId, + teamId, + builds: 1, + limit, + since: input.options.since?.getTime(), + }); + const buildLines = events.flatMap((event) => { + const line = buildEventToLine(event); + return line === undefined ? [] : [line]; + }); + + // Best-effort live capture: bounded by the window AND the limit; + // failures (e.g. a deployment past its log-retention) never mask the + // build lines above. + const runtimeLines: LogLine[] = []; + yield* runtimeStream(input.target).pipe( + Stream.take(limit), + Stream.runForEach((line) => Effect.sync(() => runtimeLines.push(line))), + Effect.timeoutOption(RUNTIME_LOG_WINDOW), + Effect.ignore, + ); + + return [...buildLines, ...runtimeLines] + .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()) + .slice(-limit); + }); + + return { tail, logs }; +}); diff --git a/packages/alchemy/src/Vercel/Local.ts b/packages/alchemy/src/Vercel/Local.ts new file mode 100644 index 0000000000..cfcc44cb3e --- /dev/null +++ b/packages/alchemy/src/Vercel/Local.ts @@ -0,0 +1,40 @@ +/** + * The Vercel local-provider sidecar entry (`alchemy dev`): serves every + * Vercel local provider over RPC so provider state (running dev servers, + * proxies, the {@link LocalFunctionState} registry) survives user-code hot + * reloads. Referenced by `LOCAL_ENTRY_URL` in `LocalRuntime.ts` — future + * Vercel local providers (queue broker, EdgeConfig registry) must register + * HERE so they share one sidecar process and its state. + */ +import * as Layer from "effect/Layer"; +import * as RpcServer from "../Local/RpcServer.ts"; +import { VercelAuth } from "./AuthProvider.ts"; +import { LocalBlobStoreProvider } from "./Blob/LocalBlobStoreProvider.ts"; +import * as Credentials from "./Credentials.ts"; +import { + LocalEdgeConfigProvider, + LocalEdgeConfigTokenProvider, +} from "./EdgeConfig/LocalEdgeConfigProvider.ts"; +import { LocalFunctionProvider } from "./Functions/LocalFunctionProvider.ts"; +import { localVercelServices } from "./LocalRuntime.ts"; +import * as VercelEnvironment from "./VercelEnvironment.ts"; + +// Management-API access for the delegating paths (e.g. the EdgeConfigToken +// local provider minting a REAL token for an `Alchemy.remote()` Edge +// Config). Resolution is lazy (`Effect.cached` inside the layers), so a +// sidecar without Vercel credentials still serves pure-local stacks. +const vercelServices = Layer.provide( + Layer.merge(Credentials.fromAuthProvider(), VercelEnvironment.fromProfile()), + VercelAuth, +); + +Layer.mergeAll( + LocalFunctionProvider(), + LocalBlobStoreProvider(), + LocalEdgeConfigProvider(), + LocalEdgeConfigTokenProvider(), +).pipe( + Layer.provide(localVercelServices()), + Layer.provide(vercelServices), + RpcServer.launch, +); diff --git a/packages/alchemy/src/Vercel/LocalRuntime.ts b/packages/alchemy/src/Vercel/LocalRuntime.ts new file mode 100644 index 0000000000..63a6612255 --- /dev/null +++ b/packages/alchemy/src/Vercel/LocalRuntime.ts @@ -0,0 +1,118 @@ +/** + * Shared state for Vercel's local (dev-mode) providers. + * + * Mirrors `Cloudflare/LocalRuntime.ts`: a module-memoized layer reference + * composed into each local provider's `ProviderLayer.dual` thunk, plus the + * sidecar entry URL every Vercel local provider registers under (one + * sidecar process serves all Vercel local providers, so they share this + * state — see `src/Vercel/Local.ts`). + */ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as MutableHashMap from "effect/MutableHashMap"; +import * as RpcProvider from "../Local/RpcProvider.ts"; +import type { QueueTriggerEntry } from "./Deploy/BuildOutput.ts"; +import { LocalEdgeConfigStateLive } from "./EdgeConfig/LocalEdgeConfigState.ts"; +import { LocalQueueBrokerLive } from "./Queues/LocalQueueBroker.ts"; + +export const LOCAL_ENTRY_URL = import.meta.resolve( + // `import.meta.url` reflects the on-disk extension of *this* file (`.ts` + // when loaded from `src/` under bun, `.js` from the compiled `lib/`), + // which is exactly the signal we need to resolve the sibling entry. + import.meta.url.endsWith(".ts") ? "./Local.ts" : "./Local.js", + import.meta.url, +); + +/** + * A locally running Vercel Function instance, registered by the + * `LocalFunctionProvider` once its dev server is serving and removed on + * delete. + * + * **This registry is the seam for local queue delivery**: the sidecar's + * `LocalQueueBroker` (see `Queues/LocalQueueBroker.ts`) delivers a topic's + * messages by finding every registered instance whose {@link queues} + * triggers match the topic and POSTing the platform's CloudEvent delivery + * format to {@link queueEndpoint} (the running dev shim proxies that route + * to the function's queue-mode bridge, which dispatches to the + * init-registered `subscribe` handlers). Cron emulation runs inside the + * function provider itself and needs nothing from here. + */ +export interface LocalFunctionInstance { + /** Logical resource id of the Function. */ + readonly functionId: string; + /** Stable local URL (`http://localhost:`, the dev proxy). */ + readonly url: string; + /** + * The instance's dev deployment id (`dev:dpl_`) — the value + * its child process sees as `VERCEL_DEPLOYMENT_ID`. The local queue + * broker uses it for per-deployment partition emulation. + */ + readonly deploymentId: string; + /** Queue triggers contributed by `subscribe` bindings. */ + readonly queues: ReadonlyArray; + /** + * POST CloudEvent queue deliveries here. `undefined` when the Function + * registered no queue subscriptions (the shim then serves no queue + * route). + */ + readonly queueEndpoint: string | undefined; + /** + * The local `CRON_SECRET` value (present only while crons are + * registered). Deliveries to cron routes must send + * `Authorization: Bearer `. + */ + readonly cronSecret: string | undefined; +} + +/** + * Registry of locally running Vercel Functions, keyed by logical id. + * Lives in the dev sidecar (or the test process when `sidecar: false`), + * shared by every Vercel local provider built from + * {@link localVercelServices}. + */ +export class LocalFunctionState extends Context.Service< + LocalFunctionState, + { + readonly functions: MutableHashMap.MutableHashMap< + string, + LocalFunctionInstance + >; + } +>()("alchemy/vercel/LocalFunctionState") {} + +const LocalFunctionStateLive = Layer.succeed( + LocalFunctionState, + LocalFunctionState.of({ functions: MutableHashMap.empty() }), +); + +const makeLocalVercelServices = () => + RpcProvider.providerServicesEffect( + Effect.succeed( + Layer.mergeAll( + LocalFunctionStateLive, + // The dev queue broker shares the SAME LocalFunctionState instance: + // the build MemoMap dedupes the repeated layer reference. + LocalQueueBrokerLive.pipe(Layer.provide(LocalFunctionStateLive)), + LocalEdgeConfigStateLive, + ), + ), + ); + +let _localVercelServices: + | ReturnType + | undefined; + +/** + * The shared local-runtime dependency layer for Vercel local providers. + * + * Returns a **module-memoized layer reference**: local providers register + * via `ProviderLayer.dual`, which builds each provider's local variant + * lazily against the stack build's shared `Layer.MemoMap` — memoization is + * keyed by layer identity, so every Vercel local provider composing this + * exact reference shares one {@link LocalFunctionState} per stack build. + * Empty when an `RpcProviderProxy` is in context (the state then lives in + * the sidecar). + */ +export const localVercelServices = () => + (_localVercelServices ??= makeLocalVercelServices()); diff --git a/packages/alchemy/src/Vercel/Microfrontends/MicrofrontendsGroup.ts b/packages/alchemy/src/Vercel/Microfrontends/MicrofrontendsGroup.ts new file mode 100644 index 0000000000..cf1a2ae400 --- /dev/null +++ b/packages/alchemy/src/Vercel/Microfrontends/MicrofrontendsGroup.ts @@ -0,0 +1,361 @@ +import * as microfrontends from "@distilled.cloud/vercel/microfrontends"; +import * as projects from "@distilled.cloud/vercel/projects"; +import * as teams from "@distilled.cloud/vercel/teams"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import { isResolved } from "../../Diff.ts"; +import { createPhysicalName } from "../../PhysicalName.ts"; +import * as Provider from "../../Provider.ts"; +import { Resource } from "../../Resource.ts"; +import type { Providers } from "../Providers.ts"; +import { VercelEnvironment } from "../VercelEnvironment.ts"; + +/** A member application of a microfrontends group. */ +export interface MicrofrontendsApp { + /** The id (`prj_…`) of the member project. */ + projectId: string; + /** + * The default route used for screenshots and preview links for the + * project. Left unmanaged when omitted. + */ + defaultRoute?: string; +} + +/** + * The group's update/delete endpoints are team-scoped + * (`/v1/teams/{teamId}/microfrontends/{groupId}`) — managing a + * microfrontends group requires a team scope (`VERCEL_TEAM_ID` or a + * team-scoped login). + */ +export class MicrofrontendsTeamRequiredError extends Data.TaggedError( + "Vercel.MicrofrontendsTeamRequiredError", +)<{ + readonly message: string; +}> {} + +/** + * A freshly created group did not appear in the team's group listing — + * a platform fault (the listing is immediately consistent, live-verified), + * not a race the reconciler can converge out of. + */ +export class MicrofrontendsGroupNotVisibleError extends Data.TaggedError( + "Vercel.MicrofrontendsGroupNotVisibleError", +)<{ + readonly groupId: string; +}> {} + +export interface MicrofrontendsGroupProps { + /** + * Name of the microfrontends group (its slug is derived from it). If + * omitted, a unique name is generated from `${app}-${stage}-${id}`. + * Renaming an existing group updates it in place. + */ + name?: string; + /** + * The default application of the group — the project whose deployment + * serves as the root of the microfrontend. Changing the default app's + * project replaces the group. + */ + defaultApp: MicrofrontendsApp; + /** + * The child applications of the group (default app excluded). Membership + * is converged exactly: projects present on the group but absent from + * this list have microfrontends disabled. + * + * @default [] + */ + applications?: MicrofrontendsApp[]; + /** + * The fallback environment for the group: `"SAME_ENV"`, `"PRODUCTION"`, + * or a custom environment slug from the default app. Left unmanaged when + * omitted. + */ + fallbackEnvironment?: string; +} + +export type MicrofrontendsGroup = Resource< + "Vercel.MicrofrontendsGroup", + MicrofrontendsGroupProps, + { + /** The group id (`mfe_…`). */ + groupId: string; + /** Name of the group. */ + name: string; + /** URL-safe slug derived from the name. */ + slug: string; + /** The group's fallback environment (e.g. `SAME_ENV`). */ + fallbackEnvironment: string | undefined; + /** The id of the default application's project. */ + defaultAppProjectId: string; + /** Ids of the child applications' projects (default app excluded). */ + applicationProjectIds: string[]; + /** Creation time in epoch milliseconds. */ + createdAt: number | undefined; + /** Last update time in epoch milliseconds. */ + updatedAt: number | undefined; + }, + never, + Providers +>; + +type MicrofrontendsGroupAttributes = MicrofrontendsGroup["Attributes"]; + +/** + * A Vercel Microfrontends Group — stitches several projects into one + * application under the default app's domain, with each child application + * serving its own routes. + * + * :::caution + * **Microfrontends is a paid Vercel add-on billed at a flat $250/month, + * charged immediately when a group is created.** Deploying this resource + * incurs that charge on your Vercel team; deleting the group does not + * refund the current month. Live tests are gated behind + * `VERCEL_TEST_MICROFRONTENDS=1` for exactly this reason. + * ::: + * + * @resource + * @section Creating a group + * @example A group with a default app and one child application + * ```typescript + * const shell = yield* Vercel.Project("Shell", {}); + * const docs = yield* Vercel.Project("Docs", {}); + * const group = yield* Vercel.MicrofrontendsGroup("Group", { + * defaultApp: { projectId: shell.projectId }, + * applications: [{ projectId: docs.projectId, defaultRoute: "/docs" }], + * }); + * ``` + * + * @section Managing membership + * @example Converge membership exactly + * ```typescript + * // Removing an entry from `applications` disables microfrontends for + * // that project on the next deploy. + * yield* Vercel.MicrofrontendsGroup("Group", { + * defaultApp: { projectId: shell.projectId }, + * applications: [], + * }); + * ``` + * + * @see https://vercel.com/docs/microfrontends + */ +export const MicrofrontendsGroup = Resource( + "Vercel.MicrofrontendsGroup", +); + +// Group names must be under 48 characters (live-verified: "Microfrontends +// group name must be less than 48 characters long"). +const createGroupName = (id: string) => + createPhysicalName({ id, maxLength: 47 }); + +/** + * Every microfrontends endpoint is team-scoped and — unlike most of the + * API — does NOT infer the team from a team-scoped token (live-verified: + * `createMicrofrontendsGroupWithApplications` rejects with "missing + * required property teamId"). Resolve it: prefer the configured tenancy; + * otherwise a team-scoped token lists exactly its own team. + */ +const requiredTeamId = Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + if (teamId !== undefined) return teamId; + const { teams: list } = yield* teams.getTeams({ limit: 2 }); + const only = list[0]; + if (list.length === 1 && only !== undefined) return only.id; + return yield* Effect.fail( + new MicrofrontendsTeamRequiredError({ + message: + "Managing a Vercel microfrontends group requires a team scope — set VERCEL_TEAM_ID or log in with a team-scoped token.", + }), + ); +}); + +const requiredTeamScope = Effect.map(requiredTeamId, (teamId) => ({ + teamId, +})); + +type GroupEntry = microfrontends.GetMicrofrontendsGroupsResponseGroupsItem; + +/** Find a group by id (preferred) or name across the team's groups. */ +const findGroup = ( + scope: { teamId?: string }, + groupId: string | undefined, + name: string, +) => + Effect.gen(function* () { + const { groups } = yield* microfrontends.getMicrofrontendsGroups(scope); + return ( + (groupId !== undefined + ? groups.find((g) => g.group.id === groupId) + : undefined) ?? groups.find((g) => g.group.name === name) + ); + }); + +/** The member projects of a group entry that are enabled in the group. */ +const membersOf = (entry: GroupEntry) => + entry.projects.filter( + (p) => + p.microfrontends?.enabled === true && + (p.microfrontends.groupIds ?? []).includes(entry.group.id), + ); + +const toAttributes = (entry: GroupEntry): MicrofrontendsGroupAttributes => { + const members = membersOf(entry); + const defaultApp = members.find((p) => p.microfrontends?.isDefaultApp); + return { + groupId: entry.group.id, + name: entry.group.name, + slug: entry.group.slug, + fallbackEnvironment: entry.group.fallbackEnvironment, + defaultAppProjectId: defaultApp?.id ?? "", + applicationProjectIds: members + .filter((p) => !p.microfrontends?.isDefaultApp) + .map((p) => p.id) + .sort(), + createdAt: entry.group.createdAt, + updatedAt: entry.group.updatedAt, + }; +}; + +export const MicrofrontendsGroupProvider = () => + Provider.succeed(MicrofrontendsGroup, { + stables: ["groupId", "createdAt"], + diff: Effect.fn(function* ({ olds, news }) { + if (!isResolved(news)) return undefined; + // The default app is the group's root; moving it to another project + // is a structural change the API has no single-call path for. + if ( + olds?.defaultApp !== undefined && + news.defaultApp.projectId !== olds.defaultApp.projectId + ) { + return { action: "replace" } as const; + } + return undefined; + }), + read: Effect.fn(function* ({ id, olds, output }) { + const scope = yield* requiredTeamScope; + const name = output?.name ?? olds?.name ?? (yield* createGroupName(id)); + const entry = yield* findGroup(scope, output?.groupId, name); + return entry === undefined ? undefined : toAttributes(entry); + }), + reconcile: Effect.fn(function* ({ id, news, output }) { + const scope = yield* requiredTeamScope; + const name = news.name ?? output?.name ?? (yield* createGroupName(id)); + const desiredApps: MicrofrontendsApp[] = [ + news.defaultApp, + ...(news.applications ?? []), + ]; + + // Observe — the group listing is the only endpoint exposing group + // metadata; `output` only caches the stable id. + let entry = yield* findGroup(scope, output?.groupId, name); + + // Ensure — missing → create with the full membership. + if (entry === undefined) { + const created = + yield* microfrontends.createMicrofrontendsGroupWithApplications({ + groupName: name, + defaultApp: { + projectId: news.defaultApp.projectId, + ...(news.defaultApp.defaultRoute !== undefined + ? { defaultRoute: news.defaultApp.defaultRoute } + : {}), + }, + otherApplications: (news.applications ?? []).map((app) => ({ + projectId: app.projectId, + ...(app.defaultRoute !== undefined + ? { defaultRoute: app.defaultRoute } + : {}), + })), + ...scope, + }); + entry = yield* findGroup( + scope, + created.newMicrofrontendsGroup.id, + name, + ); + if (entry === undefined) { + return yield* Effect.fail( + new MicrofrontendsGroupNotVisibleError({ + groupId: created.newMicrofrontendsGroup.id, + }), + ); + } + } + const groupId = entry.group.id; + + // Sync group metadata — rename and fallback environment ride the same + // team-scoped PATCH; apply only on drift. + const rename = entry.group.name !== name; + const refallback = + news.fallbackEnvironment !== undefined && + entry.group.fallbackEnvironment !== news.fallbackEnvironment; + if (rename || refallback) { + const teamId = yield* requiredTeamId; + yield* teams.updateMicrofrontendsGroup({ + teamId, + groupId, + ...(rename ? { name } : {}), + ...(refallback + ? { fallbackEnvironment: news.fallbackEnvironment } + : {}), + }); + } + + // Sync membership — diff OBSERVED members against the desired apps + // and apply only the per-project deltas. + const observedMembers = membersOf(entry); + for (const app of desiredApps) { + const observed = observedMembers.find((p) => p.id === app.projectId); + const wantDefault = app.projectId === news.defaultApp.projectId; + const isMissing = observed === undefined; + const wrongDefault = + !isMissing && + (observed.microfrontends?.isDefaultApp ?? false) !== wantDefault; + const wrongRoute = + !isMissing && + app.defaultRoute !== undefined && + observed.microfrontends?.defaultRoute !== app.defaultRoute; + if (isMissing || wrongDefault || wrongRoute) { + yield* projects.updateMicrofrontends({ + projectId: app.projectId, + microfrontendsGroupId: groupId, + enabled: true, + ...(wantDefault ? { isDefaultApp: true } : {}), + ...(app.defaultRoute !== undefined + ? { defaultRoute: app.defaultRoute } + : {}), + ...scope, + }); + } + } + for (const observed of observedMembers) { + if (!desiredApps.some((app) => app.projectId === observed.id)) { + yield* projects.updateMicrofrontends({ + projectId: observed.id, + enabled: false, + ...scope, + }); + } + } + + // Return — re-observe so the Attributes reflect the synced state. + const final = yield* findGroup(scope, groupId, name); + return toAttributes(final ?? entry); + }), + delete: Effect.fn(function* ({ output }) { + const teamId = yield* requiredTeamId; + // Already gone (out-of-band delete) is success, not an error. The + // API detaches member projects itself (live-verified: deleting a + // group with attached members succeeds). + yield* teams + .deleteMicrofrontendsGroup({ teamId, groupId: output.groupId }) + .pipe( + Effect.asVoid, + Effect.catchTag("NotFound", () => Effect.void), + ); + }), + list: Effect.fn(function* () { + const scope = yield* requiredTeamScope; + const { groups } = yield* microfrontends.getMicrofrontendsGroups(scope); + return groups.map(toAttributes); + }), + }); diff --git a/packages/alchemy/src/Vercel/Microfrontends/index.ts b/packages/alchemy/src/Vercel/Microfrontends/index.ts new file mode 100644 index 0000000000..557c353223 --- /dev/null +++ b/packages/alchemy/src/Vercel/Microfrontends/index.ts @@ -0,0 +1 @@ +export * from "./MicrofrontendsGroup.ts"; diff --git a/packages/alchemy/src/Vercel/ProjectMembers/ProjectMember.ts b/packages/alchemy/src/Vercel/ProjectMembers/ProjectMember.ts new file mode 100644 index 0000000000..ae21d3ca2c --- /dev/null +++ b/packages/alchemy/src/Vercel/ProjectMembers/ProjectMember.ts @@ -0,0 +1,292 @@ +import * as projectMembers from "@distilled.cloud/vercel/project_members"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; +import { isResolved } from "../../Diff.ts"; +import * as Provider from "../../Provider.ts"; +import { Resource } from "../../Resource.ts"; +import type { Providers } from "../Providers.ts"; +import { VercelEnvironment } from "../VercelEnvironment.ts"; + +/** Project-level role a team member can hold. */ +export type ProjectMemberRole = + | "ADMIN" + | "PROJECT_VIEWER" + | "PROJECT_DEVELOPER"; + +/** + * A member was added but never became visible in the project's member list + * within the bounded observation window. + */ +export class ProjectMemberNotVisible extends Data.TaggedError( + "Vercel.ProjectMemberNotVisible", +)<{ + readonly projectId: string; + readonly identity: string; +}> { + override get message() { + return `Project member '${this.identity}' was added to project '${this.projectId}' but did not appear in the member list.`; + } +} + +export interface ProjectMemberProps { + /** + * ID of the Vercel project. Changing this property replaces the resource + * (the member is removed from the old project and added to the new one). + */ + projectId: string; + /** + * User ID of the team member to add. Exactly one of `uid`, `username`, or + * `email` must identify an EXISTING, confirmed member of the team — + * project membership never invites new users (an unknown user is rejected + * with a typed `BadRequest`, code `not_a_member`). Changing the identity + * replaces the resource. + */ + uid?: string; + /** Username of the team member to add (alternative to `uid`). */ + username?: string; + /** Email of the team member to add (alternative to `uid`). */ + email?: string; + /** + * Project role of the member. Note the platform restricts combinations: + * a team OWNER cannot be assigned any project role (typed `BadRequest`, + * code `invalid_team_and_project_role_combination`). Role changes are + * applied in place (remove + re-add — the API has no update operation). + */ + role: ProjectMemberRole; +} + +export type ProjectMember = Resource< + "Vercel.ProjectMember", + ProjectMemberProps, + { + /** ID of the project the membership is attached to. */ + projectId: string; + /** User ID of the member. */ + uid: string; + /** Username of the member. */ + username: string; + /** Email of the member. */ + email: string; + /** Project role held by the member. */ + role: string; + /** Effective project role (accounts for team-level roles). */ + computedProjectRole: string; + /** The member's team-level role. */ + teamRole: string; + /** Timestamp (ms) when the member was added to the project. */ + createdAt: number; + }, + never, + Providers +>; + +type ProjectMemberAttributes = ProjectMember["Attributes"]; + +/** + * A project membership: grants an existing team member a role on a specific + * Vercel project. + * + * The member must already be a confirmed member of the team — this resource + * scopes existing team members to projects, it does not invite users. + * Platform role rules apply: team OWNERs (and other elevated team roles) + * cannot be assigned project roles, so manage memberships for regular + * team members (`MEMBER`, `DEVELOPER`, contributors). + * + * The API has no role-update operation, so a role change is reconciled by + * removing and re-adding the membership in place. + * + * @resource + * @section Adding a Project Member + * @example Grant a developer role by user ID + * ```typescript + * const member = yield* Vercel.ProjectMember("Alice", { + * projectId: project.projectId, + * uid: "uAbC123...", + * role: "PROJECT_DEVELOPER", + * }); + * ``` + * + * @example Grant a viewer role by email + * ```typescript + * const member = yield* Vercel.ProjectMember("Auditor", { + * projectId: project.projectId, + * email: "auditor@example.com", + * role: "PROJECT_VIEWER", + * }); + * ``` + * + * @see https://vercel.com/docs/rbac/access-roles/project-level-roles + */ +export const ProjectMember = Resource("Vercel.ProjectMember"); + +export const ProjectMemberProvider = () => + Provider.succeed(ProjectMember, { + stables: ["projectId", "uid"], + diff: Effect.fn(function* ({ olds, news, output }) { + if (!isResolved(news)) return undefined; + const oldProjectId = output?.projectId ?? olds?.projectId; + if (oldProjectId !== undefined && news.projectId !== oldProjectId) { + return { action: "replace" } as const; + } + // Changing WHO the member is replaces; changing the role updates. + if (olds !== undefined) { + const identityChanged = + news.uid !== olds.uid || + news.username !== olds.username || + news.email !== olds.email; + if (identityChanged) return { action: "replace" } as const; + } + return undefined; + }), + read: Effect.fn(function* ({ olds, output }) { + const projectId = output?.projectId ?? olds?.projectId; + if (projectId === undefined) return undefined; + const { teamId } = yield* VercelEnvironment.current; + return yield* findMember(projectId, teamId, { + uid: output?.uid ?? olds?.uid, + username: olds?.username, + email: olds?.email, + }).pipe( + Effect.map((member) => + member === undefined ? undefined : toAttributes(projectId, member), + ), + // NotFound = the host project itself is gone. + Effect.catchTag("NotFound", () => Effect.succeed(undefined)), + ); + }), + reconcile: Effect.fn(function* ({ news, output }) { + const { teamId } = yield* VercelEnvironment.current; + const projectId = news.projectId; + const identity = { + uid: news.uid ?? output?.uid, + username: news.username, + email: news.email, + }; + // Observe — the project's member list is the source of truth. + const observed = yield* findMember(projectId, teamId, identity); + if (observed !== undefined) { + if (observed.role === news.role) { + return toAttributes(projectId, observed); + } + // Role change: no update op exists — remove, then re-add below. + yield* projectMembers + .removeProjectMember({ + idOrName: projectId, + uid: observed.uid, + teamId, + }) + .pipe( + // A concurrent removal is a race, not a failure. + Effect.catchTag("NotFound", () => Effect.succeed(undefined)), + ); + } + // Ensure — add the membership with the desired role. + yield* projectMembers.addProjectMember({ + idOrName: projectId, + teamId, + uid: news.uid, + username: news.username, + email: news.email, + role: news.role, + }); + // Re-observe (bounded) until the membership is visible. + const member = yield* findMember(projectId, teamId, identity).pipe( + Effect.repeat({ + schedule: Schedule.exponential("250 millis"), + until: (m) => m !== undefined, + times: 8, + }), + ); + if (member === undefined) { + return yield* new ProjectMemberNotVisible({ + projectId, + identity: + identity.uid ?? identity.username ?? identity.email ?? "", + }); + } + return toAttributes(projectId, member); + }), + delete: Effect.fn(function* ({ output }) { + const { teamId } = yield* VercelEnvironment.current; + // Idempotent: a vanished membership OR a vanished project both + // surface as NotFound and are not errors. + yield* projectMembers + .removeProjectMember({ + idOrName: output.projectId, + uid: output.uid, + teamId, + }) + .pipe( + Effect.asVoid, + Effect.catchTag("NotFound", () => Effect.void), + ); + }), + }); + +// ───────────────────────────────────────────────────────────────────────────── +// Observation helpers +// ───────────────────────────────────────────────────────────────────────────── + +interface MemberIdentity { + uid?: string; + username?: string; + email?: string; +} + +/** + * Find a member of the project by uid/username/email, paging through the + * member list (bounded at 10 pages of 100). + */ +const findMember = Effect.fn(function* ( + projectId: string, + teamId: string | undefined, + identity: MemberIdentity, +) { + const matches = ( + member: projectMembers.GetProjectMembersResponseMembersItem, + ): boolean => + (identity.uid !== undefined && member.uid === identity.uid) || + (identity.username !== undefined && + member.username === identity.username) || + (identity.email !== undefined && member.email === identity.email); + + let until: number | undefined = undefined; + for (let page = 0; page < 10; page++) { + // Explicit annotation: `until` feeds the request from the previous + // response, which otherwise makes the inferred type self-referential. + const response: projectMembers.GetProjectMembersResponse = + yield* projectMembers.getProjectMembers({ + idOrName: projectId, + teamId, + limit: 100, + until, + // `search` narrows by name/username/email server-side when we have one. + search: identity.username ?? identity.email, + }); + const found = response.members.find(matches); + if (found !== undefined) return found; + if ( + response.pagination.hasNext !== true || + response.pagination.next == null + ) { + return undefined; + } + until = response.pagination.next; + } + return undefined; +}); + +const toAttributes = ( + projectId: string, + member: projectMembers.GetProjectMembersResponseMembersItem, +): ProjectMemberAttributes => ({ + projectId, + uid: member.uid, + username: member.username, + email: member.email, + role: member.role, + computedProjectRole: member.computedProjectRole, + teamRole: member.teamRole, + createdAt: member.createdAt, +}); diff --git a/packages/alchemy/src/Vercel/Projects/Env.ts b/packages/alchemy/src/Vercel/Projects/Env.ts new file mode 100644 index 0000000000..af4ff60bb6 --- /dev/null +++ b/packages/alchemy/src/Vercel/Projects/Env.ts @@ -0,0 +1,441 @@ +import * as projects from "@distilled.cloud/vercel/projects"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import { isResolved } from "../../Diff.ts"; +import * as Provider from "../../Provider.ts"; +import { Resource } from "../../Resource.ts"; +import { sensitiveFingerprint } from "../Deploy/Engine.ts"; +import type { Providers } from "../Providers.ts"; +import { VercelEnvironment } from "../VercelEnvironment.ts"; + +/** Deployment environments a project env var can target. */ +export type ProjectEnvTarget = "production" | "preview" | "development"; + +/** The env var type. `sensitive` values are write-only on Vercel's side. */ +export type ProjectEnvType = "plain" | "encrypted" | "sensitive"; + +/** + * A per-item failure reported inside the 201-enveloped `failed[]` array of + * `createProjectEnv` (live-verified: batch failures arrive per-item inside a + * success status), surfaced as a typed error. + */ +export class ProjectEnvWriteError extends Data.TaggedError( + "Vercel.ProjectEnvWriteError", +)<{ + readonly code: string; + readonly message: string; + readonly key: string | undefined; +}> {} + +export interface ProjectEnvProps { + /** + * The project the env var lives on: a project id (`prj_…`) or project + * name. Changing the project replaces the resource. + */ + project: string; + /** + * The environment variable name, e.g. `FLAG`. Changing the key replaces + * the resource (the new key is created before the old row is removed). + */ + key: string; + /** + * The environment variable value. Pass a `Redacted` value to store it as + * a `sensitive` env var (write-only on Vercel's side; drift is detected + * via a content fingerprint persisted in state). + */ + value: string | Redacted.Redacted; + /** + * The variable type. `encrypted` values are decryptable via the API on + * most accounts; `sensitive` values can never be read back. A `Redacted` + * `value` forces `sensitive` regardless of this setting. + * + * @default "encrypted" ("sensitive" when `value` is Redacted) + */ + type?: ProjectEnvType; + /** + * Deployment environments the variable applies to. Sensitive variables + * may not target `development` (live-verified: the API rejects it), so + * `development` is dropped from a sensitive row's targets. + * + * @default ["production", "preview", "development"] (sensitive: ["production", "preview"]) + */ + target?: ProjectEnvTarget[]; + /** + * Git branch scoping for preview-targeted variables. Requires `target` + * to include `preview`. + */ + gitBranch?: string; + /** + * A comment describing what the variable is for. + */ + comment?: string; +} + +export type ProjectEnv = Resource< + "Vercel.ProjectEnv", + ProjectEnvProps, + { + /** The project id or name the env var was created on (from props). */ + projectId: string; + /** The env row id (stable across value updates). */ + envId: string; + /** The variable name. */ + key: string; + /** The variable type as reported by Vercel. */ + type: string; + /** Deployment environments the variable applies to (sorted). */ + target: string[]; + /** Git branch scoping, if any. */ + gitBranch: string | undefined; + /** The comment, if any. */ + comment: string | undefined; + /** + * `alchemy:sha256:` of the last value written by Alchemy — the + * drift baseline whenever the cloud value is unobservable (sensitive + * rows always; encrypted rows returned as `decrypted: false` + * ciphertext on teams with v2 env encryption). + */ + fingerprint: string; + /** Creation time in epoch milliseconds. */ + createdAt: number | undefined; + /** Last update time in epoch milliseconds. */ + updatedAt: number | undefined; + }, + never, + Providers +>; + +type ProjectEnvAttributes = ProjectEnv["Attributes"]; + +/** + * A single environment variable on a Vercel project. + * + * Use this to manage one env var on a project you don't otherwise own — + * e.g. a feature flag on a `Vercel.Project` whose env is not managed by a + * compute resource, or a variable on a pre-existing project. (Env vars on a + * project deployed by `Vercel.Function` are managed by the deploy engine's + * `env` prop instead — don't mix both for the same key.) + * + * @resource + * @section Creating a project env var + * @example A feature flag on a project + * ```typescript + * const legacy = yield* Vercel.Project("Legacy", {}); + * yield* Vercel.ProjectEnv("LegacyFlag", { + * project: legacy.projectId, + * key: "FLAG", + * value: "on", + * target: ["production"], + * }); + * ``` + * + * @example A sensitive secret + * ```typescript + * import * as Redacted from "effect/Redacted"; + * + * // Redacted values are stored as `sensitive` — write-only on Vercel's + * // side; drift is detected via a fingerprint persisted in state. Note + * // sensitive vars may not target `development`. + * yield* Vercel.ProjectEnv("ApiKey", { + * project: legacy.projectId, + * key: "API_KEY", + * value: Redacted.make("sk_live_…"), + * }); + * ``` + * + * @section Branch-scoped preview variables + * @example Scope a preview variable to a branch + * ```typescript + * yield* Vercel.ProjectEnv("StagingUrl", { + * project: legacy.projectId, + * key: "API_URL", + * value: "https://staging.api.example.com", + * target: ["preview"], + * gitBranch: "staging", + * }); + * ``` + * + * @see https://vercel.com/docs/environment-variables + */ +export const ProjectEnv = Resource("Vercel.ProjectEnv"); + +const ALL_TARGETS: ProjectEnvTarget[] = [ + "production", + "preview", + "development", +]; + +/** + * Observed env row (normalized across the response unions' cases). The + * generated per-operation row types are structurally identical in the + * fields read here. + */ +interface EnvRow { + readonly id?: string; + readonly key: string; + readonly value?: string; + readonly type: string; + readonly comment?: string; + readonly target?: unknown; + readonly gitBranch?: string | null; + readonly createdAt?: number; + readonly updatedAt?: number; + /** + * `false` when `value` is an opaque ciphertext envelope even with + * decrypt=true (live-verified: teams on v2 env encryption never return + * plaintext for `encrypted` rows) — the value is unobservable then. + */ + readonly decrypted?: boolean; +} + +const targetsOf = (t: unknown): string[] => + Array.isArray(t) + ? [...t].map(String).sort() + : typeof t === "string" + ? [t] + : []; + +/** List a project's env vars, decrypting readable values for diffing. */ +const listRows = (idOrName: string) => + Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + const body = yield* projects.filterProjectEnvs({ + idOrName, + teamId, + decrypt: "true", + }); + return typeof body === "object" && body !== null && "envs" in body + ? ([...body.envs] as EnvRow[]) + : typeof body === "object" && body !== null && "key" in body + ? [body as EnvRow] + : []; + }); + +/** The desired wire row derived from props. */ +interface DesiredRow { + readonly key: string; + readonly value: string; + readonly type: ProjectEnvType; + readonly target: ProjectEnvTarget[]; + readonly gitBranch: string | undefined; + readonly comment: string | undefined; + readonly fingerprint: string; +} + +const desiredRowOf = (news: ProjectEnvProps) => + Effect.gen(function* () { + const isSensitive = + news.type === "sensitive" || Redacted.isRedacted(news.value); + const raw = Redacted.isRedacted(news.value) + ? Redacted.value(news.value) + : news.value; + const fingerprint = yield* sensitiveFingerprint(raw); + const defaults: ProjectEnvTarget[] = isSensitive + ? ["production", "preview"] + : ALL_TARGETS; + // Sensitive rows may never target development (live-verified). + const target = (news.target ?? defaults).filter( + (t) => !isSensitive || t !== "development", + ); + return { + key: news.key, + value: raw, + type: isSensitive ? ("sensitive" as const) : (news.type ?? "encrypted"), + target, + gitBranch: news.gitBranch, + comment: news.comment, + fingerprint, + } satisfies DesiredRow; + }); + +const toAttributes = ( + project: string, + row: EnvRow, + fingerprint: string, +): ProjectEnvAttributes => ({ + projectId: project, + envId: row.id ?? "", + key: row.key, + type: row.type, + target: targetsOf(row.target), + gitBranch: row.gitBranch ?? undefined, + comment: row.comment === "" ? undefined : row.comment, + fingerprint, + createdAt: row.createdAt, + updatedAt: row.updatedAt, +}); + +/** + * Resolve the observed row: the persisted id is a cache (not proof of + * existence); with no id-match, an unambiguous key+gitBranch match recovers + * a crashed create or an out-of-band id rotation. + */ +const findRow = ( + rows: ReadonlyArray, + envId: string | undefined, + key: string, + gitBranch: string | undefined, +): EnvRow | undefined => { + const byId = + envId !== undefined ? rows.find((row) => row.id === envId) : undefined; + if (byId !== undefined) return byId; + const matches = rows.filter( + (row) => row.key === key && (row.gitBranch ?? undefined) === gitBranch, + ); + // Ambiguity (several rows sharing key+branch across disjoint targets) is + // unresolvable without an id — treat as not found. + return matches.length === 1 ? matches[0] : undefined; +}; + +const failFromEnvelope = ( + failed: ReadonlyArray<{ + error: { code: string; message: string; key?: string; envVarKey?: string }; + }>, +) => { + const first = failed[0]; + return first !== undefined + ? Effect.fail( + new ProjectEnvWriteError({ + code: first.error.code, + message: first.error.message, + key: first.error.envVarKey ?? first.error.key, + }), + ) + : Effect.void; +}; + +export const ProjectEnvProvider = () => + Provider.succeed(ProjectEnv, { + stables: ["projectId", "envId", "key", "createdAt"], + diff: Effect.fn(function* ({ olds, news, output }) { + if (!isResolved(news)) return undefined; + const oldProject = output?.projectId ?? olds?.project; + if (oldProject !== undefined && news.project !== oldProject) { + return { action: "replace" } as const; + } + const oldKey = output?.key ?? olds?.key; + if (oldKey !== undefined && news.key !== oldKey) { + return { action: "replace" } as const; + } + return undefined; + }), + read: Effect.fn(function* ({ olds, output }) { + const project = output?.projectId ?? olds?.project; + const key = output?.key ?? olds?.key; + if (project === undefined || key === undefined) return undefined; + return yield* listRows(project).pipe( + Effect.map((rows) => { + const row = findRow( + rows, + output?.envId, + key, + output?.gitBranch ?? olds?.gitBranch, + ); + return row !== undefined + ? toAttributes(project, row, output?.fingerprint ?? "") + : undefined; + }), + // Project gone (404s as the status-class NotFound) → row gone. + Effect.catchTag("NotFound", () => Effect.succeed(undefined)), + ); + }), + reconcile: Effect.fn(function* ({ news, output }) { + const { teamId } = yield* VercelEnvironment.current; + const desired = yield* desiredRowOf(news); + + // Observe — the cloud row is authoritative for everything readable. + const rows = yield* listRows(news.project); + const observed = findRow(rows, output?.envId, news.key, news.gitBranch); + + // Ensure + sync in one write: `createProjectEnv?upsert=true` is a + // true upsert keyed on key+target+branch, so both the missing-row + // and the drifted-row paths converge with the same call. Per-item + // failures arrive inside the success envelope's `failed[]` + // (live-verified) and must be surfaced as typed errors. + const drifted = + observed === undefined || + observed.type !== desired.type || + targetsOf(observed.target).join(",") !== + [...desired.target].sort().join(",") || + (observed.gitBranch ?? undefined) !== desired.gitBranch || + ((observed.comment === "" ? undefined : observed.comment) ?? + undefined) !== commentFor(desired) || + (desired.type === "sensitive" || observed.decrypted === false + ? // Value unobservable — diff the persisted fingerprint. + output?.fingerprint !== desired.fingerprint + : observed.value !== desired.value); + + if (!drifted && observed !== undefined) { + return toAttributes(news.project, observed, desired.fingerprint); + } + + const response = yield* projects.createProjectEnv({ + idOrName: news.project, + teamId, + upsert: "true", + body: [ + { + key: desired.key, + value: desired.value, + type: desired.type, + target: desired.target, + ...(desired.gitBranch !== undefined + ? { gitBranch: desired.gitBranch } + : {}), + ...(commentFor(desired) !== undefined + ? { comment: commentFor(desired) } + : {}), + }, + ], + }); + yield* failFromEnvelope(response.failed); + const createdRows = Array.isArray(response.created) + ? response.created + : [response.created]; + const created = createdRows[0] as EnvRow | undefined; + if (created !== undefined) { + return toAttributes(news.project, created, desired.fingerprint); + } + // Defensive: an empty envelope (no created row, no failure) — fall + // back to re-observing the row we just wrote. + const after = findRow( + yield* listRows(news.project), + output?.envId, + news.key, + news.gitBranch, + ); + if (after === undefined) { + return yield* Effect.die( + `Vercel createProjectEnv for '${news.key}' on ${news.project} returned neither a created row nor a failure, and the row is not visible`, + ); + } + return toAttributes(news.project, after, desired.fingerprint); + }), + delete: Effect.fn(function* ({ output }) { + const { teamId } = yield* VercelEnvironment.current; + if (output.envId === "") return; + // Already gone (out-of-band delete, or a re-run after a state + // persistence failure) is success, not an error. A deleted host + // project also surfaces as NotFound. + yield* projects + .removeProjectEnv({ + idOrName: output.projectId, + id: output.envId, + teamId, + }) + .pipe( + Effect.asVoid, + Effect.catchTag("NotFound", () => Effect.void), + ); + }), + }); + +/** + * The comment that ships on the wire: the user's comment when given; for + * sensitive rows without one, the value fingerprint (a human-visible hint + * in the dashboard — nothing depends on reading it back). + */ +const commentFor = (desired: DesiredRow): string | undefined => + desired.comment ?? + (desired.type === "sensitive" ? desired.fingerprint : undefined); diff --git a/packages/alchemy/src/Vercel/Projects/Project.ts b/packages/alchemy/src/Vercel/Projects/Project.ts new file mode 100644 index 0000000000..73b04046a2 --- /dev/null +++ b/packages/alchemy/src/Vercel/Projects/Project.ts @@ -0,0 +1,263 @@ +import * as Effect from "effect/Effect"; +import { Unowned } from "../../AdoptPolicy.ts"; +import { isResolved } from "../../Diff.ts"; +import { createPhysicalName } from "../../PhysicalName.ts"; +import * as Provider from "../../Provider.ts"; +import { Resource } from "../../Resource.ts"; +import { + ALCHEMY_META_KEY, + deleteProjectByIdOrName, + ensureProject, + makeOwnershipStamp, + observeProject, + readOwnershipStamp, + readProductionUrl, + listAllProjects, + syncProjectEnv, + syncProjectSettings, + type ProjectSettingsDesired, +} from "../Deploy/Engine.ts"; +import type { Providers } from "../Providers.ts"; + +/** Fluid compute memory tier (project-level default). */ +export type MemoryTier = + | "standard_legacy" + | "standard" + | "performance" + | "performance_xl"; + +export type NodeVersion = "24.x" | "22.x" | "20.x" | "18.x"; + +export interface ProjectProps { + /** + * Name of the project. If omitted, a unique name is generated from + * `${stack}-${id}-${stage}`. Changing an explicit name replaces the + * project. + */ + name?: string; + /** + * Node.js version for the project's builds and functions. + * @default Vercel's current default (currently "22.x") + */ + nodeVersion?: NodeVersion; + /** + * Default resource configuration for the project's Vercel Functions. + */ + resources?: { + /** + * Fluid compute memory tier. + * @default "standard" + */ + memory?: MemoryTier; + /** + * Default max duration (seconds) for the project's functions. + */ + maxDuration?: number; + }; + /** + * Default regions to deploy the project's Vercel Functions to. + */ + regions?: string[]; +} + +export type Project = Resource< + "Vercel.Project", + ProjectProps, + { + projectId: string; + projectName: string; + /** + * Assigned production URL, read back from the project (never computed — + * `.vercel.app` is a global namespace). `undefined` until a production + * alias exists. + */ + url: string | undefined; + nodeVersion: string; + }, + never, + Providers +>; + +type ProjectAttributes = Project["Attributes"]; + +/** + * A Vercel Project — the long-lived container that settings, env vars, and + * domains attach to. + * + * You only need a standalone `Project` for **tenancy**: a project whose + * settings you manage explicitly, with other resources renting env/domain + * space in it (pass its id via a Function's `project:` prop). A + * `Vercel.Function` auto-provisions and owns its own project otherwise. + * + * @resource + * @section Creating a Project + * @example Basic project + * ```typescript + * const project = yield* Vercel.Project("MyProject"); + * ``` + * + * @example Project with explicit settings + * ```typescript + * const project = yield* Vercel.Project("MyProject", { + * nodeVersion: "22.x", + * resources: { memory: "performance" }, + * regions: ["iad1"], + * }); + * ``` + * + * @section Tenancy + * @example Renting a Function into a managed project + * ```typescript + * const legacy = yield* Vercel.Project("Legacy", { nodeVersion: "22.x" }); + * const fn = yield* Vercel.Function("Api", { + * main: "./src/api.ts", + * project: legacy.projectId, + * }); + * ``` + * + * @see https://vercel.com/docs/projects + */ +export const Project = Resource("Vercel.Project"); + +const desiredSettings = (props: ProjectProps): ProjectSettingsDesired => ({ + ...(props.nodeVersion !== undefined + ? { nodeVersion: props.nodeVersion } + : {}), + ...(props.resources !== undefined || props.regions !== undefined + ? { + resourceConfig: { + ...(props.resources?.memory !== undefined + ? { functionDefaultMemoryType: props.resources.memory } + : {}), + ...(props.resources?.maxDuration !== undefined + ? { functionDefaultTimeout: props.resources.maxDuration } + : {}), + ...(props.regions !== undefined + ? { functionDefaultRegions: props.regions } + : {}), + }, + } + : {}), +}); + +const createProjectName = (id: string, name: string | undefined) => + Effect.gen(function* () { + return ( + name ?? + // 35 chars, not Vercel's 100-char cap: the auto-assigned + // `{name}.vercel.app` domain truncates to the first 35 chars of the + // name, and Vercel's taken-label disambiguation races under + // concurrent creation — the loser gets NO domain (see + // Functions/Function.ts createProjectName for the probe references). + (yield* createPhysicalName({ + id, + maxLength: 35, + suffixLength: 8, + lowercase: true, + })) + ); + }); + +export const ProjectProvider = () => + Provider.succeed(Project, { + stables: ["projectId"], + diff: Effect.fn(function* ({ id, olds = {}, news = {}, output }) { + if (!isResolved(news)) return undefined; + if (!output) return undefined; + // Engine-owned physical names: the deployed name stays authoritative + // even if the generator would name this id differently today. Only an + // explicit user-provided name can force a replace. + const oldName = + output.projectName ?? (yield* createProjectName(id, olds.name)); + const newName = news.name ?? oldName; + if (newName !== oldName) { + return { action: "replace" } as const; + } + return undefined; + }), + read: Effect.fn(function* ({ id, olds, output }) { + const idOrName = + output?.projectId ?? (yield* createProjectName(id, olds?.name)); + const project = yield* observeProject(idOrName); + if (project === undefined) return undefined; + const attrs: ProjectAttributes = { + projectId: project.id, + projectName: project.name, + url: readProductionUrl(project), + nodeVersion: project.nodeVersion, + }; + const stamp = yield* readOwnershipStamp(project.id); + const expected = yield* makeOwnershipStamp(id); + return stamp !== undefined && + stamp.stack === expected.stack && + stamp.stage === expected.stage && + stamp.logicalId === expected.logicalId + ? attrs + : Unowned(attrs); + }), + list: Effect.fn(function* () { + const summaries = yield* listAllProjects(); + const rows = yield* Effect.forEach( + summaries, + (summary) => + Effect.gen(function* () { + const project = yield* observeProject(summary.id); + if (project === undefined) return undefined; + // Only surface alchemy-stamped projects — Vercel has no tags, + // so the env stamp is the ownership signal. + const stamp = yield* readOwnershipStamp(project.id); + if (stamp === undefined) return undefined; + return { + projectId: project.id, + projectName: project.name, + url: readProductionUrl(project), + // Generated enum widened to the attrs' plain string. + nodeVersion: project.nodeVersion as string, + } satisfies ProjectAttributes; + }), + { concurrency: 8 }, + ); + return rows.filter((row): row is ProjectAttributes => row !== undefined); + }), + reconcile: Effect.fn(function* ({ id, news = {}, output }) { + // Observe + ensure — prefer the deployed name (engine-owned physical + // names); an explicit name change arrives as a replacement instance + // with no output. + const name = + output?.projectName ?? (yield* createProjectName(id, news.name)); + const desired = desiredSettings(news); + const project = yield* ensureProject({ name, desired }); + + // Sync mutable settings by observed-vs-desired delta. + yield* syncProjectSettings({ project, desired }); + + // Ownership stamp (DESIGN §5.4) — the primary read/adoption signal. + const stamp = yield* makeOwnershipStamp(id); + yield* syncProjectEnv({ + idOrName: project.id, + desired: [ + { + key: ALCHEMY_META_KEY, + value: JSON.stringify(stamp), + type: "plain", + }, + ], + // The stamp is a `plain` (observable) row, so a constant baseline + // suffices — nothing sensitive to persist. + managedEnv: [{ key: ALCHEMY_META_KEY, type: "plain" }], + }); + + // Return fresh attributes (URL read back, never computed). + const fresh = yield* observeProject(project.id); + const observed = fresh ?? project; + return { + projectId: observed.id, + projectName: observed.name, + url: readProductionUrl(observed), + nodeVersion: observed.nodeVersion, + } satisfies ProjectAttributes; + }), + delete: Effect.fn(function* ({ output }) { + yield* deleteProjectByIdOrName(output.projectId); + }), + }); diff --git a/packages/alchemy/src/Vercel/Projects/RollingRelease.ts b/packages/alchemy/src/Vercel/Projects/RollingRelease.ts new file mode 100644 index 0000000000..cc703ef007 --- /dev/null +++ b/packages/alchemy/src/Vercel/Projects/RollingRelease.ts @@ -0,0 +1,279 @@ +import * as rolling_release from "@distilled.cloud/vercel/rolling_release"; +import * as Effect from "effect/Effect"; +import { isResolved } from "../../Diff.ts"; +import * as Provider from "../../Provider.ts"; +import { Resource } from "../../Resource.ts"; +import type { Providers } from "../Providers.ts"; +import { VercelEnvironment } from "../VercelEnvironment.ts"; + +/** How a rollout advances between stages. */ +export type RollingReleaseAdvancementType = "automatic" | "manual-approval"; + +/** One stage of a rolling release. The final stage must be 100%. */ +export interface RollingReleaseStage { + /** The percentage of traffic to serve to the canary deployment (0-100). */ + targetPercentage: number; + /** + * Duration in minutes before automatically advancing to the next stage. + * Only meaningful with `advancementType: "automatic"`. + */ + duration?: number; + /** + * Whether this stage requires manual approval to proceed. Only + * meaningful with `advancementType: "manual-approval"`. + */ + requireApproval?: boolean; +} + +export interface RollingReleaseProps { + /** + * The project the rolling release configuration is attached to: a + * project id (`prj_…`) or project name. A project has at most one + * rolling release configuration; changing the project replaces the + * resource (the old project's configuration is deleted). + */ + project: string; + /** + * How the rollout advances between stages: `automatic` (each stage + * advances after its `duration`) or `manual-approval` (each stage waits + * for an approve call). Not readable back from the API — drift on this + * property alone cannot be detected from cloud state. + */ + advancementType: RollingReleaseAdvancementType; + /** + * The traffic stages of a rollout, in order. The final stage must have + * `targetPercentage: 100` (the API rejects the config otherwise — + * live-verified `invalid_request`). + */ + stages: RollingReleaseStage[]; + /** + * Whether requests served by the canary deployment return a header + * indicating a canary was served. + * @default false + */ + canaryResponseHeader?: boolean; +} + +export type RollingRelease = Resource< + "Vercel.RollingRelease", + RollingReleaseProps, + { + /** The project id or name the configuration is attached to (from props). */ + projectId: string; + /** The environment the release targets (currently always `production`). */ + target: string; + /** How the rollout advances between stages (from props; not readable). */ + advancementType: RollingReleaseAdvancementType; + /** The configured stages as reported by the API. */ + stages: RollingReleaseStage[]; + /** Whether canary responses carry the canary header. */ + canaryResponseHeader: boolean; + }, + never, + Providers +>; + +type RollingReleaseAttributes = RollingRelease["Attributes"]; + +/** + * The rolling release configuration of a Vercel project: new production + * deployments are gradually rolled out through traffic stages instead of + * receiving 100% of traffic at once. + * + * The configuration is a template for FUTURE rollouts — changing or + * deleting it never alters a rollout already in flight, only the next + * production deployment. Deleting the resource disables rolling releases + * for the project. Note: enabling rolling releases automatically enables + * skew protection on the project if it wasn't configured already. + * + * Requires a plan with rolling release slots (Pro and up); the + * `getRollingReleaseBillingStatus` API reports the team's entitlement. + * + * @resource + * @section Creating a rolling release configuration + * @example Manual approval stages + * ```typescript + * const project = yield* Vercel.Project("App", {}); + * yield* Vercel.RollingRelease("Rollout", { + * project: project.projectId, + * advancementType: "manual-approval", + * stages: [ + * { targetPercentage: 10, requireApproval: true }, + * { targetPercentage: 100 }, + * ], + * }); + * ``` + * + * @example Automatic advancement + * ```typescript + * // Each stage serves its traffic share for `duration` minutes, then + * // advances automatically; the canary header marks canary responses. + * yield* Vercel.RollingRelease("Rollout", { + * project: project.projectId, + * advancementType: "automatic", + * stages: [ + * { targetPercentage: 5, duration: 10 }, + * { targetPercentage: 50, duration: 10 }, + * { targetPercentage: 100 }, + * ], + * canaryResponseHeader: true, + * }); + * ``` + * + * @see https://vercel.com/docs/rolling-releases + */ +export const RollingRelease = Resource("Vercel.RollingRelease"); + +/** + * Structural view of the observed config (the GET response's + * `rollingRelease`), used for diffing and attribute mapping. + */ +interface ObservedConfig { + readonly target: string; + readonly stages?: ReadonlyArray<{ + readonly targetPercentage: number; + readonly requireApproval?: boolean; + readonly duration?: number; + readonly linearShift?: boolean; + }> | null; + readonly canaryResponseHeader?: boolean; +} + +const canonicalStages = ( + stages: ReadonlyArray<{ + readonly targetPercentage: number; + readonly requireApproval?: boolean; + readonly duration?: number; + }>, +): RollingReleaseStage[] => + stages.map((stage) => ({ + targetPercentage: stage.targetPercentage, + ...(stage.duration !== undefined ? { duration: stage.duration } : {}), + ...(stage.requireApproval === true ? { requireApproval: true } : {}), + })); + +const stagesEqual = ( + a: ReadonlyArray, + b: ReadonlyArray, +): boolean => + a.length === b.length && + a.every( + (stage, i) => + stage.targetPercentage === b[i]!.targetPercentage && + stage.duration === b[i]!.duration && + (stage.requireApproval ?? false) === (b[i]!.requireApproval ?? false), + ); + +const toAttributes = ( + project: string, + observed: ObservedConfig, + advancementType: RollingReleaseAdvancementType, +): RollingReleaseAttributes => ({ + projectId: project, + target: observed.target, + advancementType, + stages: canonicalStages(observed.stages ?? []), + canaryResponseHeader: observed.canaryResponseHeader ?? false, +}); + +/** + * `advancementType` is not echoed by the GET config API (live-verified); + * infer it from observed stages when no persisted value survives. + */ +const inferAdvancementType = ( + observed: ObservedConfig, +): RollingReleaseAdvancementType => + (observed.stages ?? []).some((stage) => stage.duration !== undefined) + ? "automatic" + : "manual-approval"; + +const getObserved = (idOrName: string) => + Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + const response = yield* rolling_release.getRollingReleaseConfig({ + idOrName, + teamId, + }); + return (response.rollingRelease as ObservedConfig | null) ?? undefined; + }); + +export const RollingReleaseProvider = () => + Provider.succeed(RollingRelease, { + stables: ["projectId", "target"], + diff: Effect.fn(function* ({ olds, news, output }) { + if (!isResolved(news)) return undefined; + const oldProject = output?.projectId ?? olds?.project; + if (oldProject !== undefined && news.project !== oldProject) { + return { action: "replace" } as const; + } + return undefined; + }), + read: Effect.fn(function* ({ olds, output }) { + const project = output?.projectId ?? olds?.project; + if (project === undefined) return undefined; + return yield* getObserved(project).pipe( + Effect.map((observed) => + observed !== undefined + ? toAttributes( + project, + observed, + output?.advancementType ?? + olds?.advancementType ?? + inferAdvancementType(observed), + ) + : undefined, + ), + // Project gone → configuration gone. + Effect.catchTag("NotFound", () => Effect.succeed(undefined)), + ); + }), + reconcile: Effect.fn(function* ({ news, olds, output }) { + const { teamId } = yield* VercelEnvironment.current; + const desiredStages = canonicalStages(news.stages); + const desiredCanary = news.canaryResponseHeader ?? false; + + // Observe — the config document is the source of truth for stages and + // the canary header. `advancementType` is write-only (not echoed by + // the GET), so `olds` is the no-op hint for it: an observed match + // still PATCHes when the declared advancementType changed. + const observed = yield* getObserved(news.project); + if ( + observed !== undefined && + stagesEqual(canonicalStages(observed.stages ?? []), desiredStages) && + (observed.canaryResponseHeader ?? false) === desiredCanary && + olds?.advancementType === news.advancementType + ) { + return toAttributes(news.project, observed, news.advancementType); + } + + yield* rolling_release.updateRollingReleaseConfig({ + idOrName: news.project, + teamId, + enabled: true, + advancementType: news.advancementType, + stages: desiredStages, + canaryResponseHeader: desiredCanary, + }); + // The PATCH echo is partial (live-verified: stages only) — re-read + // for authoritative attributes. + const after = yield* getObserved(news.project); + if (after === undefined) { + return yield* Effect.die( + `Vercel accepted the rolling release config for ${news.project} but the configuration is not visible`, + ); + } + return toAttributes(news.project, after, news.advancementType); + }), + delete: Effect.fn(function* ({ output }) { + const { teamId } = yield* VercelEnvironment.current; + // Disables rolling releases for the project. Idempotent: deleting an + // absent config returns `{rollingRelease: null}` (live-verified), and + // a deleted host project surfaces as NotFound — not an error. + yield* rolling_release + .deleteRollingReleaseConfig({ idOrName: output.projectId, teamId }) + .pipe( + Effect.asVoid, + Effect.catchTag("NotFound", () => Effect.void), + ); + }), + }); diff --git a/packages/alchemy/src/Vercel/Providers.ts b/packages/alchemy/src/Vercel/Providers.ts new file mode 100644 index 0000000000..393e30a295 --- /dev/null +++ b/packages/alchemy/src/Vercel/Providers.ts @@ -0,0 +1,244 @@ +import * as Layer from "effect/Layer"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { CredentialsStoreLive } from "../Auth/Credentials.ts"; +import { ProfileLive } from "../Auth/Profile.ts"; +import * as Command from "../Command/index.ts"; +import * as Provider from "../Provider.ts"; +import { + AccessGroup, + AccessGroupProvider, +} from "./AccessGroups/AccessGroup.ts"; +import { + AccessGroupProject, + AccessGroupProjectProvider, +} from "./AccessGroups/AccessGroupProject.ts"; +import { Alias, AliasProvider } from "./Aliases/Alias.ts"; +import { + WebAnalytics, + WebAnalyticsProvider, +} from "./Analytics/WebAnalytics.ts"; +import { VercelAuth } from "./AuthProvider.ts"; +import { BlobStore, BlobStoreProvider } from "./Blob/BlobStore.ts"; +import { LocalBlobStoreProvider } from "./Blob/LocalBlobStoreProvider.ts"; +import { Check, CheckProvider } from "./Checks/Check.ts"; +import * as Credentials from "./Credentials.ts"; +import { Cert, CertProvider } from "./Domains/Cert.ts"; +import { DnsRecord, DnsRecordProvider } from "./Domains/DnsRecord.ts"; +import { Domain, DomainProvider } from "./Domains/Domain.ts"; +import { + ProjectDomain, + ProjectDomainProvider, +} from "./Domains/ProjectDomain.ts"; +import { Drain, DrainProvider } from "./Drains/Drain.ts"; +import { EdgeConfig, EdgeConfigProvider } from "./EdgeConfig/EdgeConfig.ts"; +import { + EdgeConfigToken, + EdgeConfigTokenProvider, +} from "./EdgeConfig/EdgeConfigToken.ts"; +import { + LocalEdgeConfigProvider, + LocalEdgeConfigTokenProvider, +} from "./EdgeConfig/LocalEdgeConfigProvider.ts"; +import { SharedEnv, SharedEnvProvider } from "./Environments/SharedEnv.ts"; +import { + FeatureFlag, + FeatureFlagProvider, +} from "./FeatureFlags/FeatureFlag.ts"; +import * as ProviderLayer from "../Local/ProviderLayer.ts"; +import { Function, FunctionProvider } from "./Functions/Function.ts"; +import { LocalFunctionProvider } from "./Functions/LocalFunctionProvider.ts"; +import { localVercelServices } from "./LocalRuntime.ts"; +import { + MicrofrontendsGroup, + MicrofrontendsGroupProvider, +} from "./Microfrontends/MicrofrontendsGroup.ts"; +import { + ProjectMember, + ProjectMemberProvider, +} from "./ProjectMembers/ProjectMember.ts"; +import { ProjectEnv, ProjectEnvProvider } from "./Projects/Env.ts"; +import { Project, ProjectProvider } from "./Projects/Project.ts"; +import { + RollingRelease, + RollingReleaseProvider, +} from "./Projects/RollingRelease.ts"; +import { + BulkRedirects, + BulkRedirectsProvider, +} from "./Routes/BulkRedirects.ts"; +import { + ProjectRoutes, + ProjectRoutesProvider, +} from "./Routes/ProjectRoutes.ts"; +import { + SandboxDrive, + SandboxDriveProvider, +} from "./Sandboxes/SandboxDrive.ts"; +import { + SandboxSnapshot, + SandboxSnapshotProvider, +} from "./Sandboxes/SandboxSnapshot.ts"; +import { + FirewallConfig, + FirewallConfigProvider, +} from "./Security/FirewallConfig.ts"; +import { Team, TeamProvider } from "./Teams/Team.ts"; +import { TeamMember, TeamMemberProvider } from "./Teams/TeamMember.ts"; +import * as VercelEnvironment from "./VercelEnvironment.ts"; +import { Webhook, WebhookProvider } from "./Webhooks/Webhook.ts"; + +export class Providers extends Provider.ProviderCollection()( + "Vercel", +) {} + +/** + * Build a layer that registers all Vercel resource providers, the Vercel + * `AuthProvider`, the resolved `Credentials`, the `VercelEnvironment` + * (team scope), and an `HttpClient`. Include this from your stack alongside + * other cloud `providers()` layers. + * + * @example + * ```typescript + * import * as Alchemy from "alchemy"; + * import * as Vercel from "alchemy/Vercel"; + * import * as Effect from "effect/Effect"; + * + * export default Alchemy.Stack( + * "MyStack", + * { + * providers: Vercel.providers(), + * state: Alchemy.localState(), + * }, + * Effect.gen(function* () { + * return {}; + * }), + * ); + * ``` + */ +/** + * The Vercel API foundation every effect tree that talks to the Vercel + * management API shares — credentials resolved through the Alchemy auth + * provider, team environment, profile + credential store, and an HTTP + * client. Used by {@link providers} consumers and the Vercel state store + * ({@link ./StateStore/State.ts state}) so out-of-band probes run with + * the same wiring as provider lifecycle operations. + */ +export const VercelApiLive = () => + Credentials.fromAuthProvider().pipe( + Layer.provideMerge(VercelEnvironment.fromProfile()), + Layer.provideMerge(VercelAuth), + Layer.provideMerge(ProfileLive), + Layer.provideMerge(CredentialsStoreLive), + Layer.provideMerge(FetchHttpClient.layer), + ); + +export const providers = () => + Layer.effect( + Providers, + Provider.collection([ + Project, + Function, + Webhook, + Drain, + EdgeConfig, + EdgeConfigToken, + AccessGroup, + AccessGroupProject, + FirewallConfig, + Domain, + DnsRecord, + ProjectDomain, + Cert, + BlobStore, + SharedEnv, + Alias, + ProjectEnv, + RollingRelease, + ProjectMember, + ProjectRoutes, + BulkRedirects, + FeatureFlag, + MicrofrontendsGroup, + Check, + Team, + TeamMember, + WebAnalytics, + SandboxDrive, + SandboxSnapshot, + ]), + ).pipe( + Layer.provide( + Layer.mergeAll( + ProjectProvider(), + // Function registers BOTH provider modes: live deploys, local (dev) + // emulates via a dev-server child. Mode-specific deps compose + // inside the local thunk (built lazily — a plain deploy never + // constructs local machinery unless a local-mode row needs it). + ProviderLayer.dual(Function, { + live: () => FunctionProvider(), + local: () => + LocalFunctionProvider().pipe(Layer.provide(localVercelServices())), + }), + WebhookProvider(), + DrainProvider(), + // EdgeConfig(+Token) register BOTH provider modes: live converges + // the real config, local (dev) seeds an in-memory registry served + // by a sidecar data-plane endpoint. The local token provider still + // delegates to the live lifecycle for `Alchemy.remote()` configs. + ProviderLayer.dual(EdgeConfig, { + live: () => EdgeConfigProvider(), + local: () => + LocalEdgeConfigProvider().pipe( + Layer.provide(localVercelServices()), + ), + }), + ProviderLayer.dual(EdgeConfigToken, { + live: () => EdgeConfigTokenProvider(), + local: () => + LocalEdgeConfigTokenProvider().pipe( + Layer.provide(localVercelServices()), + ), + }), + AccessGroupProvider(), + AccessGroupProjectProvider(), + FirewallConfigProvider(), + DomainProvider(), + DnsRecordProvider(), + ProjectDomainProvider(), + CertProvider(), + // BlobStore registers BOTH provider modes: live creates the store on + // Vercel, local (dev) emulates the data plane in the sidecar. + ProviderLayer.dual(BlobStore, { + live: () => BlobStoreProvider(), + local: () => + LocalBlobStoreProvider().pipe(Layer.provide(localVercelServices())), + }), + SharedEnvProvider(), + AliasProvider(), + ProjectEnvProvider(), + RollingReleaseProvider(), + ProjectMemberProvider(), + ProjectRoutesProvider(), + BulkRedirectsProvider(), + FeatureFlagProvider(), + MicrofrontendsGroupProvider(), + CheckProvider(), + TeamProvider(), + TeamMemberProvider(), + WebAnalyticsProvider(), + SandboxDriveProvider(), + SandboxSnapshotProvider(), + ), + ), + // `Website.*` transformers declare `Command.Build` resources (the + // framework/static builds) — their providers ride along, mirroring + // Cloudflare's barrel. + Layer.provideMerge(Command.providers()), + Layer.provideMerge(Credentials.fromAuthProvider()), + Layer.provideMerge(VercelEnvironment.fromProfile()), + Layer.provideMerge(VercelAuth), + Layer.provideMerge(ProfileLive), + Layer.provideMerge(CredentialsStoreLive), + Layer.provideMerge(FetchHttpClient.layer), + Layer.orDie, + ); diff --git a/packages/alchemy/src/Vercel/Queues/LocalQueueBroker.ts b/packages/alchemy/src/Vercel/Queues/LocalQueueBroker.ts new file mode 100644 index 0000000000..c9124a744c --- /dev/null +++ b/packages/alchemy/src/Vercel/Queues/LocalQueueBroker.ts @@ -0,0 +1,914 @@ +/** + * The local (dev-mode) Vercel Queues broker — an in-memory emulation of the + * queue data plane for `alchemy dev`, mirroring the official CLI's dev + * broker (`vercel/vercel` `packages/cli/src/util/dev/queue-broker.ts`). + * + * Two halves: + * + * - **Data-plane HTTP server** (started lazily by {@link serve}, owned by + * the sidecar): implements the Queues v3 protocol + * (`/api/v3/topic/{topic}[/consumer/{group}[/id/{id} | /lease/{handle}]]`) + * so the UNMODIFIED queue clients (`SendMessage`/`ReceiveMessages`, the + * consumer bridge's fetch-by-id/ack/extend) work in dev — the + * `LocalFunctionProvider` injects `VERCEL_QUEUE_BASE_URL` (and a dev + * `VERCEL_QUEUE_TOKEN`) into every dev child, the same contract the + * official `vercel dev` uses to redirect `@vercel/queue`. + * - **Push dispatcher**: a 1s tick loop delivers pending messages to + * subscribed Functions by POSTing the platform's CloudEvents v2beta + * binary-mode delivery (full-message mode, all `ce-vqs*` headers) to the + * Function's {@link LocalFunctionInstance.queueEndpoint}. Consumer groups + * derive from the trigger entries each running Function registered in + * {@link LocalFunctionState}; handler success acks back through the data + * plane (this server), failure leaves the lease to expire and redeliver — + * byte-for-byte the deployed contract. + * + * Semantics mirrored from the CLI broker: topic wildcards (`*` over + * `[A-Za-z0-9_-]`), idempotency keys with duplicate markers + * (receive-by-id on a duplicate id → 409 + `originalMessageId`), delivery + * counts, visibility timeouts / lease expiry redelivery, `maxDeliveries` + * drop (32), per-send retention (default 1h) and delay, `retryAfterSeconds` + * / `initialDelaySeconds` from the trigger. + * + * Extended beyond the CLI (platform-faithful, live-verified semantics): + * + * - **Dynamic consumer groups** — the platform lets ANY group name receive + * (a fresh group replays the topic); the CLI broker only serves + * configured groups. Explicit receives here lazily materialize unknown + * groups, seeding them with every retained message of the topic. + * - **Per-deployment partition emulation** — sends record their + * `Vqs-Deployment-Id` pin; explicit receives only see messages whose pin + * matches theirs, and pinned messages are push-delivered only to the + * Function whose current dev deployment id matches (unpinned/`"shared"` + * messages deliver to every matching group, keeping cross-function local + * queueing possible — declare `partition: "shared"` for that shape, which + * is also what the real platform's per-project namespace would require). + * + * INTERNAL — NOT exported from the Vercel `index.ts`; lives in the dev + * sidecar via `localVercelServices` (see `Vercel/LocalRuntime.ts`). + */ +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as MutableHashMap from "effect/MutableHashMap"; +import type * as Scope from "effect/Scope"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as HttpServer from "effect/unstable/http/HttpServer"; +import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import * as crypto from "node:crypto"; +import { httpServer } from "../../Util/PlatformServices.ts"; +import { + LocalFunctionState, + type LocalFunctionInstance, +} from "../LocalRuntime.ts"; + +/** The bearer value dev children authenticate with (never validated). */ +export const DEV_QUEUE_TOKEN = "vc-dev-token"; + +// Defaults mirrored from the official CLI dev broker. +const DEFAULT_RETRY_AFTER_MS = 60_000; +const DEFAULT_MAX_DELIVERIES = 32; +const DEFAULT_INITIAL_DELAY_MS = 0; +const DEFAULT_VISIBILITY_TIMEOUT_MS = 60_000; +const DEFAULT_RETENTION_MS = 3_600_000; +const TICK_INTERVAL = "1 second"; + +/** + * Topic patterns may only contain `[A-Za-z0-9_-]`; `*` expands to zero or + * more valid topic characters (same rule as the CLI broker). + */ +const topicPatternToRegex = (pattern: string): RegExp => { + const parts = pattern.split("*").map((s) => s.replaceAll("-", "\\-")); + return new RegExp(`^${parts.join("[A-Za-z0-9_\\-]*")}$`); +}; + +interface StoredMessage { + readonly messageId: string; + readonly payload: Uint8Array; + readonly contentType: string; + readonly queueName: string; + readonly createdAt: string; + readonly retentionMs: number; + /** Epoch ms before which the message is not visible (send delay). */ + readonly availableAt: number; + /** The sender's `Vqs-Deployment-Id` pin (`undefined` = shared partition). */ + readonly deploymentId: string | undefined; +} + +type DeliveryStatus = "pending" | "in-flight" | "acked"; + +interface DeliveryState { + status: DeliveryStatus; + deliveryCount: number; + receiptHandle: string; + visibleAt: number; + leaseExpiresAt: number; +} + +/** A consumer group derived from a running Function's trigger entries. */ +interface TriggerGroup { + readonly id: string; + readonly name: string; + readonly topicPattern: string; + readonly topicRegex: RegExp; + readonly functionId: string; + readonly retryAfterMs: number; + readonly initialDelayMs: number; + readonly maxDeliveries: number; +} + +interface IdempotencyRecord { + readonly messageId: string; + readonly expiresAt: number; +} + +interface DuplicateMessageRecord { + readonly queueName: string; + readonly originalMessageId: string; + readonly expiresAt: number; +} + +export interface ReceivedBrokerMessage { + readonly messageId: string; + readonly payload: Uint8Array; + readonly contentType: string; + readonly deliveryCount: number; + readonly createdAt: string; + readonly expiresAt: string; + readonly receiptHandle: string; +} + +/** One CloudEvent push delivery the tick/enqueue decided to perform. */ +interface DispatchWork { + readonly endpoint: string; + readonly groupId: string; + readonly groupName: string; + readonly message: StoredMessage; + readonly receiptHandle: string; + readonly deliveryCount: number; +} + +const randomId = () => crypto.randomBytes(16).toString("hex"); + +const expiresAtOf = (message: StoredMessage): string => + new Date( + new Date(message.createdAt).getTime() + message.retentionMs, + ).toISOString(); + +/** + * The pure in-memory broker core. All methods are synchronous state + * transitions (call them inside `Effect.sync`); push dispatch I/O is + * returned as {@link DispatchWork} for the effectful shell to perform. + */ +class BrokerCore { + private readonly messages = new Map(); + private readonly idempotencyRecords = new Map(); + private readonly duplicateMessages = new Map< + string, + DuplicateMessageRecord + >(); + /** + * Delivery state per group id. Trigger-group ids are + * `${consumer}::${topicPattern}`; dynamic (explicit-receive) groups use + * `${consumer}::${topicName}`. Entries persist as `acked` tombstones + * until the message expires, so lazy dynamic-group seeding never + * resurrects a completed delivery. + */ + private readonly deliveryState = new Map< + string, + Map + >(); + + constructor( + private readonly functions: MutableHashMap.MutableHashMap< + string, + LocalFunctionInstance + >, + ) {} + + /** Trigger groups derived from the currently registered Functions. */ + private triggerGroups(): TriggerGroup[] { + const groups = new Map(); + for (const fn of MutableHashMap.values(this.functions)) { + if (fn.queueEndpoint === undefined) continue; + for (const entry of fn.queues) { + const id = `${entry.consumer}::${entry.topic}`; + // First registration wins on a (consumer, topic) collision. + if (groups.has(id)) continue; + groups.set(id, { + id, + name: entry.consumer, + topicPattern: entry.topic, + topicRegex: topicPatternToRegex(entry.topic), + functionId: fn.functionId, + retryAfterMs: + entry.retryAfterSeconds !== undefined + ? entry.retryAfterSeconds * 1000 + : DEFAULT_RETRY_AFTER_MS, + initialDelayMs: + entry.initialDelaySeconds !== undefined + ? entry.initialDelaySeconds * 1000 + : DEFAULT_INITIAL_DELAY_MS, + maxDeliveries: DEFAULT_MAX_DELIVERIES, + }); + } + } + return [...groups.values()]; + } + + private groupDeliveries(groupId: string): Map { + let deliveries = this.deliveryState.get(groupId); + if (deliveries === undefined) { + deliveries = new Map(); + this.deliveryState.set(groupId, deliveries); + } + return deliveries; + } + + enqueue(input: { + readonly queueName: string; + readonly payload: Uint8Array; + readonly contentType: string; + readonly deploymentId: string | undefined; + readonly retentionSeconds: number | undefined; + readonly delaySeconds: number | undefined; + readonly idempotencyKey: string | undefined; + }): { readonly messageId: string; readonly dispatches: DispatchWork[] } { + const now = Date.now(); + const messageId = randomId(); + const retentionMs = + (input.retentionSeconds ?? 0) > 0 + ? input.retentionSeconds! * 1000 + : DEFAULT_RETENTION_MS; + const idempotencyRecordKey = + input.idempotencyKey !== undefined + ? `${input.queueName}:${input.idempotencyKey}` + : undefined; + + if (idempotencyRecordKey !== undefined) { + const record = this.idempotencyRecords.get(idempotencyRecordKey); + if (record !== undefined && record.expiresAt > now) { + // Duplicate: return a fresh id that marks the duplicate; receiving + // it by id reports 409 + originalMessageId (CLI-broker semantics). + this.duplicateMessages.set(messageId, { + queueName: input.queueName, + originalMessageId: record.messageId, + expiresAt: now + retentionMs, + }); + return { messageId, dispatches: [] }; + } + this.idempotencyRecords.set(idempotencyRecordKey, { + messageId, + expiresAt: now + retentionMs, + }); + } + + const delayMs = (input.delaySeconds ?? 0) * 1000; + const message: StoredMessage = { + messageId, + payload: input.payload, + contentType: input.contentType, + queueName: input.queueName, + createdAt: new Date(now).toISOString(), + retentionMs, + availableAt: delayMs > 0 ? now + delayMs : 0, + deploymentId: input.deploymentId, + }; + this.messages.set(messageId, message); + + const dispatches: DispatchWork[] = []; + for (const group of this.triggerGroups()) { + if (!group.topicRegex.test(input.queueName)) continue; + const deliveries = this.groupDeliveries(group.id); + const visibleAt = Math.max( + message.availableAt, + group.initialDelayMs > 0 ? now + group.initialDelayMs : 0, + ); + deliveries.set(messageId, { + status: "pending", + deliveryCount: 0, + receiptHandle: "", + visibleAt, + leaseExpiresAt: 0, + }); + if (visibleAt === 0) { + const work = this.beginDispatch(message, group); + if (work !== undefined) dispatches.push(work); + } + } + return { messageId, dispatches }; + } + + /** + * Transition a pending delivery to in-flight and describe the CloudEvent + * POST to perform — or `undefined` when the group's Function is not + * currently deliverable (unregistered, no endpoint, or pinned to a + * different deployment partition), in which case the delivery backs off + * by the group's retry interval. + */ + private beginDispatch( + message: StoredMessage, + group: TriggerGroup, + ): DispatchWork | undefined { + const deliveries = this.deliveryState.get(group.id); + const state = deliveries?.get(message.messageId); + if (deliveries === undefined || state === undefined) return undefined; + if (state.status !== "pending") return undefined; + + if (state.deliveryCount >= group.maxDeliveries) { + deliveries.delete(message.messageId); + return undefined; + } + + const fn = MutableHashMap.get(this.functions, group.functionId); + const endpoint = fn._tag === "Some" ? fn.value.queueEndpoint : undefined; + const partitioned = + message.deploymentId !== undefined && + (fn._tag === "None" || fn.value.deploymentId !== message.deploymentId); + if (endpoint === undefined || partitioned) { + // Not deliverable right now — back off instead of busy-retrying. + state.visibleAt = Date.now() + group.retryAfterMs; + return undefined; + } + + const receiptHandle = randomId(); + state.status = "in-flight"; + state.receiptHandle = receiptHandle; + state.deliveryCount++; + state.leaseExpiresAt = Date.now() + DEFAULT_VISIBILITY_TIMEOUT_MS; + return { + endpoint, + groupId: group.id, + groupName: group.name, + message, + receiptHandle, + deliveryCount: state.deliveryCount, + }; + } + + /** A failed (non-2xx / transport-error) push delivery: back to pending. */ + deliveryFailed(groupId: string, messageId: string): void { + const state = this.deliveryState.get(groupId)?.get(messageId); + if (state === undefined || state.status !== "in-flight") return; + const retryAfterMs = + this.triggerGroups().find((g) => g.id === groupId)?.retryAfterMs ?? + DEFAULT_RETRY_AFTER_MS; + state.status = "pending"; + state.visibleAt = Date.now() + retryAfterMs; + state.leaseExpiresAt = 0; + } + + /** + * Ensure the (possibly dynamic) receive group exists and is seeded with + * every retained message of the topic it has not yet tracked — the + * platform's "a fresh consumer group replays the topic" semantics. + */ + private seedReceiveGroup( + queueName: string, + consumerGroup: string, + ): Map { + // An explicit receive on a topic a trigger group covers shares that + // group's delivery state (same cursor/leases as the push consumer). + const trigger = this.triggerGroups().find( + (g) => g.name === consumerGroup && g.topicRegex.test(queueName), + ); + const deliveries = this.groupDeliveries( + trigger?.id ?? `${consumerGroup}::${queueName}`, + ); + for (const message of this.messages.values()) { + if (message.queueName !== queueName) continue; + if (deliveries.has(message.messageId)) continue; + deliveries.set(message.messageId, { + status: "pending", + deliveryCount: 0, + receiptHandle: "", + visibleAt: message.availableAt, + leaseExpiresAt: 0, + }); + } + return deliveries; + } + + receiveMessages(input: { + readonly queueName: string; + readonly consumerGroup: string; + readonly deploymentId: string | undefined; + readonly limit: number | undefined; + readonly visibilityTimeoutSeconds: number | undefined; + }): ReceivedBrokerMessage[] { + const deliveries = this.seedReceiveGroup( + input.queueName, + input.consumerGroup, + ); + const now = Date.now(); + const limit = Math.min(Math.max(input.limit ?? 1, 1), 10); + const visibilityTimeoutMs = + (input.visibilityTimeoutSeconds ?? DEFAULT_VISIBILITY_TIMEOUT_MS / 1000) * + 1000; + const results: ReceivedBrokerMessage[] = []; + for (const [messageId, state] of deliveries) { + const message = this.messages.get(messageId); + if (message === undefined) { + deliveries.delete(messageId); + continue; + } + if ( + message.queueName !== input.queueName || + state.status !== "pending" || + state.visibleAt > now || + // Partition emulation: a receive only sees its own partition. + message.deploymentId !== input.deploymentId + ) { + continue; + } + const receiptHandle = randomId(); + state.status = "in-flight"; + state.receiptHandle = receiptHandle; + state.deliveryCount++; + state.leaseExpiresAt = now + visibilityTimeoutMs; + results.push({ + messageId, + payload: message.payload, + contentType: message.contentType, + deliveryCount: state.deliveryCount, + createdAt: message.createdAt, + expiresAt: expiresAtOf(message), + receiptHandle, + }); + if (results.length >= limit) break; + } + return results; + } + + receiveById(input: { + readonly queueName: string; + readonly consumerGroup: string; + readonly messageId: string; + readonly deploymentId: string | undefined; + readonly visibilityTimeoutSeconds: number | undefined; + }): + | { readonly _tag: "NotFound" } + | { readonly _tag: "Duplicate"; readonly originalMessageId: string } + | { readonly _tag: "AlreadyProcessed" } + | { readonly _tag: "Message"; readonly message: ReceivedBrokerMessage } { + const duplicate = this.duplicateMessages.get(input.messageId); + if ( + duplicate !== undefined && + duplicate.queueName === input.queueName && + duplicate.expiresAt > Date.now() + ) { + return { + _tag: "Duplicate", + originalMessageId: duplicate.originalMessageId, + }; + } + const message = this.messages.get(input.messageId); + if ( + message === undefined || + message.queueName !== input.queueName || + message.deploymentId !== input.deploymentId + ) { + return { _tag: "NotFound" }; + } + const deliveries = this.seedReceiveGroup( + input.queueName, + input.consumerGroup, + ); + const state = deliveries.get(input.messageId); + if (state === undefined) return { _tag: "NotFound" }; + if (state.status === "acked") return { _tag: "AlreadyProcessed" }; + if (state.status === "pending") { + // Lease it, exactly like a batch receive would. + state.status = "in-flight"; + state.receiptHandle = randomId(); + state.deliveryCount++; + state.leaseExpiresAt = + Date.now() + + (input.visibilityTimeoutSeconds !== undefined + ? input.visibilityTimeoutSeconds * 1000 + : DEFAULT_VISIBILITY_TIMEOUT_MS); + } + return { + _tag: "Message", + message: { + messageId: message.messageId, + payload: message.payload, + contentType: message.contentType, + deliveryCount: state.deliveryCount, + createdAt: message.createdAt, + expiresAt: expiresAtOf(message), + receiptHandle: state.receiptHandle, + }, + }; + } + + private findByReceiptHandle( + consumerGroup: string, + receiptHandle: string, + ): { deliveries: Map; messageId: string } | undefined { + for (const [groupId, deliveries] of this.deliveryState) { + if (!groupId.startsWith(`${consumerGroup}::`)) continue; + for (const [messageId, state] of deliveries) { + if ( + state.receiptHandle === receiptHandle && + state.status === "in-flight" + ) { + return { deliveries, messageId }; + } + } + } + return undefined; + } + + /** Complete a lease. `false` = unknown/expired receipt handle. */ + acknowledge(consumerGroup: string, receiptHandle: string): boolean { + const found = this.findByReceiptHandle(consumerGroup, receiptHandle); + if (found === undefined) return false; + const state = found.deliveries.get(found.messageId)!; + state.status = "acked"; + state.leaseExpiresAt = 0; + return true; + } + + /** Extend (or shrink) an in-flight lease. `false` = unknown handle. */ + extendLease( + consumerGroup: string, + receiptHandle: string, + visibilityTimeoutSeconds: number, + ): boolean { + const found = this.findByReceiptHandle(consumerGroup, receiptHandle); + if (found === undefined) return false; + const state = found.deliveries.get(found.messageId)!; + state.leaseExpiresAt = Date.now() + visibilityTimeoutSeconds * 1000; + return true; + } + + /** + * One broker tick: expire records and messages, return expired leases to + * pending (or drop at maxDeliveries), and collect the push dispatches + * now due. + */ + tick(): DispatchWork[] { + const now = Date.now(); + for (const [key, record] of this.idempotencyRecords) { + if (record.expiresAt <= now) this.idempotencyRecords.delete(key); + } + for (const [messageId, record] of this.duplicateMessages) { + if (record.expiresAt <= now) this.duplicateMessages.delete(messageId); + } + for (const [messageId, message] of this.messages) { + if (new Date(message.createdAt).getTime() + message.retentionMs <= now) { + this.messages.delete(messageId); + for (const deliveries of this.deliveryState.values()) { + deliveries.delete(messageId); + } + } + } + + const dispatches: DispatchWork[] = []; + const groups = this.triggerGroups(); + for (const group of groups) { + const deliveries = this.deliveryState.get(group.id); + if (deliveries === undefined) continue; + for (const [messageId, state] of deliveries) { + const message = this.messages.get(messageId); + if (message === undefined) { + deliveries.delete(messageId); + continue; + } + if (state.status === "in-flight" && state.leaseExpiresAt < now) { + if (state.deliveryCount >= group.maxDeliveries) { + deliveries.delete(messageId); + continue; + } + state.status = "pending"; + state.visibleAt = now + group.retryAfterMs; + state.leaseExpiresAt = 0; + continue; + } + if (state.status === "pending" && state.visibleAt <= now) { + const work = this.beginDispatch(message, group); + if (work !== undefined) dispatches.push(work); + } + } + } + return dispatches; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// HTTP data plane + push dispatch (the effectful shell) +// ───────────────────────────────────────────────────────────────────────────── + +const TOPIC_PATH = /^\/api\/v3\/topic\/([A-Za-z0-9_-]+)(?:\/(.*))?$/; +const CONSUMER_PATH = /^consumer\/([A-Za-z0-9_-]+)(?:\/(.*))?$/; + +const intHeader = (value: string | undefined): number | undefined => { + if (value === undefined || value === "") return undefined; + const parsed = Number.parseInt(value, 10); + return Number.isNaN(parsed) ? undefined : parsed; +}; + +const multipartResponse = ( + messages: ReadonlyArray, +): HttpServerResponse.HttpServerResponse => { + const boundary = `----alchemydevboundary${randomId()}`; + const encoder = new TextEncoder(); + const parts: Uint8Array[] = []; + for (const message of messages) { + const headers = [ + `Vqs-Message-Id: ${message.messageId}`, + `Vqs-Delivery-Count: ${message.deliveryCount}`, + `Vqs-Timestamp: ${message.createdAt}`, + `Vqs-Expires-At: ${message.expiresAt}`, + `Vqs-Receipt-Handle: ${message.receiptHandle}`, + `Content-Type: ${message.contentType}`, + ].join("\r\n"); + parts.push( + encoder.encode(`--${boundary}\r\n${headers}\r\n\r\n`), + message.payload, + encoder.encode("\r\n"), + ); + } + parts.push(encoder.encode(`--${boundary}--\r\n`)); + const size = parts.reduce((n, part) => n + part.length, 0); + const body = new Uint8Array(size); + let offset = 0; + for (const part of parts) { + body.set(part, offset); + offset += part.length; + } + return HttpServerResponse.uint8Array(body, { + status: 200, + contentType: `multipart/mixed; boundary=${boundary}`, + }); +}; + +/** POST one CloudEvents v2beta binary-mode delivery (full-message mode). */ +const dispatchOne = ( + client: HttpClient.HttpClient, + core: BrokerCore, + work: DispatchWork, +) => + client + .execute( + HttpClientRequest.post(work.endpoint).pipe( + HttpClientRequest.setHeaders({ + "content-type": work.message.contentType, + "ce-type": "com.vercel.queue.v2beta", + "ce-specversion": "1.0", + "ce-source": `/topic/${work.message.queueName}/consumer/${work.groupName}`, + "ce-id": work.message.messageId, + "ce-time": new Date().toISOString(), + "ce-vqsmessageid": work.message.messageId, + "ce-vqsqueuename": work.message.queueName, + "ce-vqsconsumergroup": work.groupName, + "ce-vqsreceipthandle": work.receiptHandle, + "ce-vqsdeliverycount": String(work.deliveryCount), + "ce-vqscreatedat": work.message.createdAt, + "ce-vqsexpiresat": expiresAtOf(work.message), + "ce-vqsregion": "dev1", + }), + HttpClientRequest.bodyUint8Array( + work.message.payload, + work.message.contentType, + ), + ), + ) + .pipe( + Effect.flatMap((response) => + response.status >= 200 && response.status < 300 + ? Effect.asVoid(response.text) + : Effect.andThen( + response.text, + Effect.sync(() => + core.deliveryFailed(work.groupId, work.message.messageId), + ), + ), + ), + Effect.catchCause((cause) => + Effect.andThen( + Effect.logDebug( + `[vercel dev queues] delivery of ${work.message.messageId} to '${work.groupName}' failed`, + cause, + ), + Effect.sync(() => + core.deliveryFailed(work.groupId, work.message.messageId), + ), + ), + ), + ); + +const makeHandler = ( + core: BrokerCore, + /** Run a push dispatch OUTSIDE the request fiber (its scope outlives it). */ + dispatchInBackground: (work: DispatchWork) => Effect.Effect, +) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const pathname = request.url.split("?")[0]; + const topicMatch = TOPIC_PATH.exec(pathname); + if (topicMatch === null) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + const [, queueName, rest = ""] = topicMatch; + const deploymentId = request.headers["vqs-deployment-id"]; + + // POST /api/v3/topic/{topic} — send. + if (rest === "" && request.method === "POST") { + const payload = new Uint8Array(yield* request.arrayBuffer); + const { messageId, dispatches } = yield* Effect.sync(() => + core.enqueue({ + queueName, + payload, + contentType: + request.headers["content-type"] ?? "application/octet-stream", + deploymentId, + retentionSeconds: intHeader(request.headers["vqs-retention-seconds"]), + delaySeconds: intHeader(request.headers["vqs-delay-seconds"]), + idempotencyKey: request.headers["vqs-idempotency-key"], + }), + ); + // No-delay deliveries dispatch immediately, off the request fiber. + for (const work of dispatches) { + yield* dispatchInBackground(work); + } + return yield* HttpServerResponse.json( + { messageId }, + { status: 201, headers: { "vqs-message-id": messageId } }, + ); + } + + const consumerMatch = CONSUMER_PATH.exec(rest); + if (consumerMatch === null) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + const [, consumerGroup, action = ""] = consumerMatch; + + // POST .../consumer/{group} — batch receive. + if (action === "" && request.method === "POST") { + const messages = yield* Effect.sync(() => + core.receiveMessages({ + queueName, + consumerGroup, + deploymentId, + limit: intHeader(request.headers["vqs-max-messages"]), + visibilityTimeoutSeconds: intHeader( + request.headers["vqs-visibility-timeout-seconds"], + ), + }), + ); + return messages.length === 0 + ? HttpServerResponse.empty({ status: 204 }) + : multipartResponse(messages); + } + + // POST .../consumer/{group}/id/{messageId} — receive by id. + const idMatch = /^id\/([^/]+)$/.exec(action); + if (idMatch !== null && request.method === "POST") { + const result = yield* Effect.sync(() => + core.receiveById({ + queueName, + consumerGroup, + messageId: decodeURIComponent(idMatch[1]), + deploymentId, + visibilityTimeoutSeconds: intHeader( + request.headers["vqs-visibility-timeout-seconds"], + ), + }), + ); + switch (result._tag) { + case "Duplicate": + return yield* HttpServerResponse.json( + { + error: + "This messageId was a duplicate - use originalMessageId instead", + originalMessageId: result.originalMessageId, + }, + { status: 409 }, + ); + case "NotFound": + return HttpServerResponse.text("Message not found", { status: 404 }); + case "AlreadyProcessed": + return HttpServerResponse.text("Message already processed", { + status: 410, + }); + case "Message": + return multipartResponse([result.message]); + } + } + + // DELETE/PATCH .../consumer/{group}/lease/{receiptHandle}[/visibility] + const leaseMatch = /^lease\/([^/]+)(?:\/visibility)?$/.exec(action); + if (leaseMatch !== null) { + const receiptHandle = decodeURIComponent(leaseMatch[1]); + if (request.method === "DELETE") { + const acked = yield* Effect.sync(() => + core.acknowledge(consumerGroup, receiptHandle), + ); + return acked + ? HttpServerResponse.empty({ status: 204 }) + : yield* HttpServerResponse.json( + { error: "Message not found" }, + { status: 404 }, + ); + } + if (request.method === "PATCH") { + const body = yield* request.arrayBuffer; + const timeoutSeconds = yield* Effect.sync(() => { + try { + const parsed = JSON.parse(new TextDecoder().decode(body)) as { + visibilityTimeoutSeconds?: unknown; + }; + return typeof parsed.visibilityTimeoutSeconds === "number" && + parsed.visibilityTimeoutSeconds >= 0 + ? parsed.visibilityTimeoutSeconds + : DEFAULT_VISIBILITY_TIMEOUT_MS / 1000; + } catch { + return DEFAULT_VISIBILITY_TIMEOUT_MS / 1000; + } + }); + const extended = yield* Effect.sync(() => + core.extendLease(consumerGroup, receiptHandle, timeoutSeconds), + ); + return extended + ? yield* HttpServerResponse.json({ success: true }) + : yield* HttpServerResponse.json( + { error: "Message not found" }, + { status: 404 }, + ); + } + } + + return HttpServerResponse.text("Not Found", { status: 404 }); + }); + +/** + * The dev-sidecar queue broker service. `serve` starts the data-plane HTTP + * server and the dispatch tick loop ONCE in the caller's `Scope` (later + * callers get the memoized base URL), returning the base URL to inject as + * `VERCEL_QUEUE_BASE_URL`. + */ +export class LocalQueueBroker extends Context.Service< + LocalQueueBroker, + { + readonly serve: Effect.Effect< + string, + never, + Scope.Scope | HttpClient.HttpClient + >; + } +>()("alchemy/vercel/LocalQueueBroker") {} + +export const LocalQueueBrokerLive: Layer.Layer< + LocalQueueBroker, + never, + LocalFunctionState +> = Layer.effect( + LocalQueueBroker, + Effect.gen(function* () { + const { functions } = yield* LocalFunctionState; + const core = new BrokerCore(functions); + const started = yield* Deferred.make(); + let starting = false; + + const start = Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const scope = yield* Effect.scope; + const dispatchInBackground = (work: DispatchWork) => + Effect.asVoid(Effect.forkIn(dispatchOne(client, core, work), scope)); + const context = yield* Layer.build(httpServer(0, "127.0.0.1")); + const server = Context.get(context, HttpServer.HttpServer); + yield* server.serve(makeHandler(core, dispatchInBackground)); + const url = HttpServer.formatAddress(server.address) + .replace("127.0.0.1", "localhost") + .replace(/\/$/, ""); + // The 1s broker tick: expiry, lease returns, pending dispatch. + yield* Effect.sync(() => core.tick()).pipe( + Effect.flatMap((work) => + Effect.forEach(work, (item) => dispatchOne(client, core, item), { + discard: true, + }), + ), + Effect.andThen(Effect.sleep(TICK_INTERVAL)), + Effect.forever, + Effect.forkScoped, + ); + yield* Deferred.succeed(started, url); + }); + + return LocalQueueBroker.of({ + serve: Effect.suspend(() => { + if (!starting) { + starting = true; + // A failure to bind an ephemeral loopback port is an environment + // defect, not a typed lifecycle error. + return Effect.andThen(Effect.orDie(start), Deferred.await(started)); + } + return Deferred.await(started); + }), + }); + }), +); diff --git a/packages/alchemy/src/Vercel/Queues/OidcToken.ts b/packages/alchemy/src/Vercel/Queues/OidcToken.ts new file mode 100644 index 0000000000..7c7bd2fed6 --- /dev/null +++ b/packages/alchemy/src/Vercel/Queues/OidcToken.ts @@ -0,0 +1,156 @@ +import type { Credentials } from "@distilled.cloud/vercel/Credentials"; +import * as projects from "@distilled.cloud/vercel/projects"; +import type * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import { VercelEnvironment } from "../VercelEnvironment.ts"; +import { MissingOidcToken, OidcTokenMintFailed } from "./QueueTypes.ts"; + +/** + * Vercel OIDC token acquisition. + * + * Two worlds: + * - **Inside a deployed Vercel Function** the platform provisions a token + * ambiently — {@link ambientOidcToken} mirrors `@vercel/oidc`'s resolution + * order (request-context header `x-vercel-oidc-token`, then the + * `VERCEL_OIDC_TOKEN` env var). + * - **Outside Vercel** (tests, poll-mode consumers, CLI-side infra) a token + * is minted from the management API via `projects.getProjectToken` — + * {@link mintProjectOidcToken} for a one-shot mint, + * {@link projectOidcToken} for an expiry-buffered auto-refreshing accessor. + */ + +/** The request-context global `@vercel/oidc` reads inside a Function. */ +const REQUEST_CONTEXT = Symbol.for("@vercel/request-context"); + +/** + * Synchronously resolve the ambient OIDC token (or `undefined`), mirroring + * `@vercel/oidc`'s order: request-context `x-vercel-oidc-token` header, then + * the `VERCEL_QUEUE_TOKEN` env var (the queue-specific override the official + * `vercel dev` — and alchemy's local dev broker — injects alongside + * `VERCEL_QUEUE_BASE_URL`), then the `VERCEL_OIDC_TOKEN` env var. Plain + * function so the promise-based async-mode client can share it; Effect code + * uses {@link ambientOidcToken}. + */ +export const ambientOidcTokenUnsafe = (): string | undefined => { + const holder = (globalThis as Record)[ + REQUEST_CONTEXT + ] as + | { + get?: () => + | { headers?: Record } + | undefined; + } + | undefined; + const token = + holder?.get?.()?.headers?.["x-vercel-oidc-token"] ?? + process.env.VERCEL_QUEUE_TOKEN ?? + process.env.VERCEL_OIDC_TOKEN; + return token !== undefined && token !== "" ? token : undefined; +}; + +/** + * Resolve the ambient Vercel OIDC token, mirroring `@vercel/oidc`: + * the request-context `x-vercel-oidc-token` header first, then the + * `VERCEL_OIDC_TOKEN` environment variable. Fails with a typed + * {@link MissingOidcToken} when neither is present (i.e. we are not running + * inside a Vercel Function and no token was pulled via `vercel env pull`). + */ +export const ambientOidcToken: Effect.Effect< + Redacted.Redacted, + MissingOidcToken +> = Effect.suspend(() => { + const token = ambientOidcTokenUnsafe(); + return token !== undefined + ? Effect.succeed(Redacted.make(token)) + : Effect.fail( + new MissingOidcToken({ + message: + "No ambient Vercel OIDC token (request-context `x-vercel-oidc-token` header or VERCEL_OIDC_TOKEN env var). " + + "Outside a Vercel Function, mint one with `mintProjectOidcToken`/`projectOidcToken` or pass `token` explicitly.", + }), + ); +}); + +/** + * Mint a fresh project OIDC token via the management API + * (`POST /v1/projects/{idOrName}/token`). One-shot — for long-running + * consumers prefer {@link projectOidcToken}, which refreshes before expiry. + * + * **Scope caveat (live-verified)**: minted tokens are ALWAYS + * `environment: development`-scoped — there is no parameter that changes + * this — and the queue namespace is per (project, environment). A minted + * token therefore only interoperates with other dev-scoped clients; it can + * NEVER observe queue traffic produced by deployed Functions (their ambient + * token is production-scoped, a disjoint namespace even with an identical + * deployment pin). Verify deployed-function queue behavior via in-scope + * HTTP readback instead (see `test/Vercel/Queues/Subscribe.test.ts`). + */ +export const mintProjectOidcToken = ( + projectIdOrName: string, +): Effect.Effect< + Redacted.Redacted, + OidcTokenMintFailed, + Credentials | HttpClient.HttpClient | VercelEnvironment +> => + Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + const { token } = yield* projects + .getProjectToken({ + idOrName: projectIdOrName, + teamId, + source: "vercel-oidc-refresh", + }) + .pipe( + Effect.mapError( + (cause) => + new OidcTokenMintFailed({ + message: `Failed to mint a Vercel OIDC token for project '${projectIdOrName}': ${ + (cause as { message?: string }).message ?? String(cause) + }`, + projectIdOrName, + cause, + }), + ), + ); + return Redacted.make(token); + }); + +/** + * An expiry-buffered, auto-refreshing project OIDC token accessor for + * infrastructure running OUTSIDE Vercel (poll-mode consumers, tests). + * + * The outer Effect captures the management-API services (credentials, HTTP + * client, team scope) once; the returned inner Effect is dependency-free and + * re-mints at most once per TTL. Vercel OIDC tokens live ~1 hour, so the + * default 45-minute TTL refreshes comfortably before expiry. + * + * @example Poll consumer with a refreshing token + * ```typescript + * const token = yield* Vercel.projectOidcToken(project.projectId); + * const consumer = yield* Vercel.makeReceiveMessagesClient(Orders, { + * consumerGroup: "worker", + * token, + * }); + * ``` + */ +export const projectOidcToken = ( + projectIdOrName: string, + options?: { readonly ttl?: Duration.Input }, +): Effect.Effect< + Effect.Effect, OidcTokenMintFailed>, + never, + Credentials | HttpClient.HttpClient | VercelEnvironment +> => + Effect.gen(function* () { + const context = yield* Effect.context< + Credentials | HttpClient.HttpClient | VercelEnvironment + >(); + return yield* Effect.cachedWithTTL( + mintProjectOidcToken(projectIdOrName).pipe( + Effect.provideContext(context), + ), + options?.ttl ?? "45 minutes", + ); + }); diff --git a/packages/alchemy/src/Vercel/Queues/QueueCallback.ts b/packages/alchemy/src/Vercel/Queues/QueueCallback.ts new file mode 100644 index 0000000000..0f2dfaa4da --- /dev/null +++ b/packages/alchemy/src/Vercel/Queues/QueueCallback.ts @@ -0,0 +1,257 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { + acknowledgeMessageRaw, + receiveMessageByIdRaw, + type RawQueueMessage, +} from "./QueueData.ts"; +import { resolveQueueDeploymentId, resolveQueueToken } from "./QueueClient.ts"; +import { QueueMessageCorrupted, type QueueDeliveryMeta } from "./QueueTypes.ts"; +import type { Topic } from "./Topic.ts"; + +/** + * Push-delivery (CloudEvent callback) runtime for the `subscribe` event + * source: parses the platform's queue-trigger POST, decodes the payload with + * the topic's schema, runs the registered handler, and completes the lease. + * + * Wire contract (cross-checked against `@vercel/queue`'s `parseCallback` and + * the Wave-0 trigger probes): a `queue/v2beta` trigger delivers messages as + * an HTTP POST in CloudEvents *binary* mode — metadata in `ce-*` headers, + * payload in the body: + * + * - `ce-type: com.vercel.queue.v2beta` (anything else is a 400) + * - `ce-vqsqueuename` / `ce-vqsconsumergroup` / `ce-vqsmessageid` (required) + * - `ce-vqsreceipthandle` present ⇒ **full-message mode**: the body IS the + * payload, plus `ce-vqsdeliverycount` / `ce-vqscreatedat` / + * `ce-vqsexpiresat` / `content-type` + * - `ce-vqsreceipthandle` absent ⇒ **routing-only mode** (large payloads): + * the consumer fetches the message by id from the data plane, which also + * leases it + * + * Completion semantics mirror `@vercel/queue`: handler success ⇒ explicit + * data-plane ack (idempotent — an already-completed lease is tolerated) and + * a 200; handler failure ⇒ NO ack and a 500, so the message becomes visible + * again after the trigger's visibility timeout and is redelivered + * (at-least-once). + * + * INTERNAL scaffolding — NOT exported from the Vercel `index.ts`. The public + * surface is the `subscribe` event source. + */ + +/** The CloudEvent type a `queue/v2beta` trigger delivers. */ +const CLOUD_EVENT_TYPE_V2BETA = "com.vercel.queue.v2beta"; + +/** A registered push subscription (one per topic per Function). */ +export interface QueueSubscriptionEntry { + readonly topic: Topic; + readonly consumerGroup: string; + readonly handler: ( + payload: any, + meta: QueueDeliveryMeta, + ) => Effect.Effect; +} + +interface ParsedDelivery { + readonly queueName: string; + readonly consumerGroup: string; + readonly messageId: string; + readonly region: string | undefined; + readonly receiptHandle: string | undefined; + readonly deliveryCount: number | undefined; + readonly createdAt: string | undefined; + readonly expiresAt: string | undefined; + readonly contentType: string | undefined; +} + +/** Parse the CloudEvents binary-mode delivery headers (v2beta only). */ +const parseDeliveryHeaders = ( + headers: Headers, +): ParsedDelivery | { readonly parseError: string } => { + const ceType = headers.get("ce-type"); + if (ceType !== CLOUD_EVENT_TYPE_V2BETA) { + return { + parseError: `expected CloudEvent type '${CLOUD_EVENT_TYPE_V2BETA}', got '${String(ceType)}'`, + }; + } + const queueName = headers.get("ce-vqsqueuename"); + const consumerGroup = headers.get("ce-vqsconsumergroup"); + const messageId = headers.get("ce-vqsmessageid"); + if (queueName === null || consumerGroup === null || messageId === null) { + return { + parseError: + "missing required CloudEvent headers (ce-vqsqueuename / ce-vqsconsumergroup / ce-vqsmessageid)", + }; + } + const deliveryCountRaw = headers.get("ce-vqsdeliverycount"); + const deliveryCount = + deliveryCountRaw !== null + ? Number.parseInt(deliveryCountRaw, 10) + : Number.NaN; + return { + queueName, + consumerGroup, + messageId, + region: headers.get("ce-vqsregion") ?? undefined, + receiptHandle: headers.get("ce-vqsreceipthandle") ?? undefined, + deliveryCount: Number.isNaN(deliveryCount) ? undefined : deliveryCount, + createdAt: headers.get("ce-vqscreatedat") ?? undefined, + expiresAt: headers.get("ce-vqsexpiresat") ?? undefined, + contentType: headers.get("content-type") ?? undefined, + }; +}; + +const textDecoder = new TextDecoder(); + +/** Decode a raw message's JSON payload with the subscription topic's schema. */ +const decodePayload = (topic: Topic, raw: RawQueueMessage) => + Effect.gen(function* () { + const json = yield* Effect.try({ + try: () => JSON.parse(textDecoder.decode(raw.payload)) as unknown, + catch: (cause) => + new QueueMessageCorrupted({ + message: `Message ${raw.messageId}: payload is not valid JSON (${String(cause)})`, + topic: topic.topicName, + id: raw.messageId, + }), + }); + return yield* Schema.decodeUnknownEffect(topic.schema)(json); + }); + +const processDelivery = ( + entry: QueueSubscriptionEntry, + parsed: ParsedDelivery, + bodyBytes: Uint8Array | undefined, +): Effect.Effect => + Effect.gen(function* () { + const topic = entry.topic; + // Ambient identity: the OIDC token the platform provisions and the + // deployment pin — receives/acks are partitioned by `Vqs-Deployment-Id` + // exactly like sends (live-verified). + const token = yield* resolveQueueToken(undefined); + const deploymentId = yield* resolveQueueDeploymentId(topic, undefined); + const common = { + // The delivery names its region; fall back to the topic's pin. + region: parsed.region ?? topic.region, + topic: topic.topicName, + token, + deploymentId, + // Lease operations are scoped to the DELIVERY's consumer group. + consumerGroup: parsed.consumerGroup, + }; + + let raw: RawQueueMessage; + if (parsed.receiptHandle !== undefined && bodyBytes !== undefined) { + // Full-message mode: the POST body is the payload. + raw = { + messageId: parsed.messageId, + deliveryCount: parsed.deliveryCount ?? 1, + createdAt: + parsed.createdAt !== undefined + ? new Date(parsed.createdAt) + : new Date(), + expiresAt: + parsed.expiresAt !== undefined + ? new Date(parsed.expiresAt) + : undefined, + contentType: parsed.contentType ?? "application/json", + receiptHandle: parsed.receiptHandle, + payload: bodyBytes, + }; + } else { + // Routing-only mode: fetch (and thereby lease) the message by id. + const fetched = yield* receiveMessageByIdRaw({ + ...common, + messageId: parsed.messageId, + }).pipe( + Effect.catchTag( + [ + "Vercel.Queues.MessageAlreadyProcessed", + "Vercel.Queues.MessageNotFound", + ], + (error) => + // Already completed by an earlier delivery, or expired — nothing + // left to process; report success so the platform stops retrying. + Effect.logDebug( + `Vercel queue delivery for message ${parsed.messageId} is already settled (${error._tag})`, + ).pipe(Effect.as(undefined)), + ), + ); + if (fetched === undefined) { + return Response.json({ status: "already-processed" }); + } + raw = fetched; + } + + const payload = yield* decodePayload(topic, raw); + const meta: QueueDeliveryMeta = { + messageId: raw.messageId, + deliveryCount: raw.deliveryCount, + createdAt: raw.createdAt, + expiresAt: raw.expiresAt, + contentType: raw.contentType, + receiptHandle: raw.receiptHandle, + topicName: parsed.queueName, + consumerGroup: parsed.consumerGroup, + }; + yield* entry.handler(payload, meta); + + // Handler succeeded — complete the lease. A lease already completed + // elsewhere (races, platform-side ack on 200) is success, not failure. + yield* acknowledgeMessageRaw({ + ...common, + receiptHandle: raw.receiptHandle, + }).pipe( + Effect.catchTag( + ["Vercel.Queues.MessageNotFound", "Vercel.Queues.MessageNotAvailable"], + () => Effect.void, + ), + ); + return Response.json({ status: "success" }); + }); + +/** + * Handle one queue-trigger POST against the Function's subscription + * registry. Never fails: every outcome is a Response — 200 on success (or + * an already-settled delivery), 400 on a malformed CloudEvent, 404 for a + * topic with no registered subscription, 500 on handler/decoding failure + * (NO ack ⇒ the platform redelivers after the visibility timeout). + */ +export const runQueueCallback = ( + registry: ReadonlyMap, + webRequest: Request, +): Effect.Effect => + Effect.gen(function* () { + if (webRequest.method !== "POST") { + return Response.json({ ok: true }); + } + const parsed = parseDeliveryHeaders(webRequest.headers); + if ("parseError" in parsed) { + return Response.json({ error: parsed.parseError }, { status: 400 }); + } + const entry = registry.get(parsed.queueName); + if (entry === undefined) { + yield* Effect.logWarning( + `Vercel queue delivery for topic '${parsed.queueName}' has no registered subscription`, + ); + return Response.json( + { error: `no subscription for topic '${parsed.queueName}'` }, + { status: 404 }, + ); + } + const bodyBytes = + parsed.receiptHandle !== undefined + ? new Uint8Array(yield* Effect.promise(() => webRequest.arrayBuffer())) + : undefined; + return yield* processDelivery(entry, parsed, bodyBytes).pipe( + Effect.catchCause((cause) => + Effect.logError( + `Vercel queue delivery failed (topic '${parsed.queueName}', message ${parsed.messageId})`, + cause, + ).pipe( + Effect.as( + Response.json({ error: "queue delivery failed" }, { status: 500 }), + ), + ), + ), + ); + }); diff --git a/packages/alchemy/src/Vercel/Queues/QueueClient.ts b/packages/alchemy/src/Vercel/Queues/QueueClient.ts new file mode 100644 index 0000000000..a2c152d5b4 --- /dev/null +++ b/packages/alchemy/src/Vercel/Queues/QueueClient.ts @@ -0,0 +1,85 @@ +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import { sanitizeKey } from "../../RuntimeContext.ts"; +import { ambientOidcToken } from "./OidcToken.ts"; +import { MissingDeploymentId, type OidcTokenError } from "./QueueTypes.ts"; +import type { Topic } from "./Topic.ts"; + +/** + * Shared client scaffolding for the queue capabilities: token and + * deployment-pin resolution, plus the env refs the deploy-time binding + * halves inject. + * + * INTERNAL — NOT exported from the Vercel `index.ts` (see the + * Read/Write/ReadWrite binding convention on shared scaffolding). + */ + +export interface QueueClientOptions { + /** + * OIDC bearer token. Defaults to the ambient token (request-context + * header / `VERCEL_OIDC_TOKEN`). Pass a plain `Redacted` for a one-shot + * token (e.g. from `mintProjectOidcToken`) or an Effect for a refreshing + * accessor (e.g. from `projectOidcToken`). + */ + readonly token?: + | Redacted.Redacted + | Effect.Effect, OidcTokenError>; + /** + * Deployment partition pin. `undefined` follows the topic's `partition` + * mode (pinning from the ambient `VERCEL_DEPLOYMENT_ID` when + * `"deployment"`); a string pins explicitly; `null` opts out (the shared + * partition), overriding the topic. + */ + readonly deploymentId?: string | null; +} + +/** Resolve the effective bearer token per {@link QueueClientOptions.token}. */ +export const resolveQueueToken = ( + token: QueueClientOptions["token"], +): Effect.Effect, OidcTokenError> => + token === undefined + ? ambientOidcToken + : Redacted.isRedacted(token) + ? Effect.succeed(token) + : token; + +/** + * Resolve the effective `Vqs-Deployment-Id` pin. Deployment pinning is + * load-bearing (live-verified): unpinned sends are invisible to pinned + * receivers and vice versa, so a `"deployment"`-partitioned topic with no + * resolvable deployment id is a typed failure, not a silent unpin. + */ +export const resolveQueueDeploymentId = ( + topic: Topic, + deploymentId: QueueClientOptions["deploymentId"], +): Effect.Effect => + Effect.suspend(() => { + if (deploymentId === null) return Effect.succeed(undefined); + if (deploymentId !== undefined) return Effect.succeed(deploymentId); + if (topic.partition === "shared") return Effect.succeed(undefined); + const ambient = process.env.VERCEL_DEPLOYMENT_ID; + if (ambient !== undefined && ambient !== "") { + return Effect.succeed(ambient); + } + return Effect.fail( + new MissingDeploymentId({ + message: + `Topic '${topic.topicName}' partitions by deployment but no deployment id is available ` + + "(VERCEL_DEPLOYMENT_ID is not set). Pass `deploymentId` explicitly, or `deploymentId: null` " + + '(or declare the topic with `partition: "shared"`) to use the shared partition.', + topic: topic.topicName, + }), + ); + }); + +/** Env var name a queue binding injects for a topic (async-mode visibility). */ +export const topicEnvKey = (topic: Topic): string => + `VERCEL_QUEUE_${sanitizeKey(topic.topicName).toUpperCase()}`; + +/** Env var value: the topic's static config (never tokens — OIDC is ambient). */ +export const topicEnvValue = (topic: Topic): string => + JSON.stringify({ + topic: topic.topicName, + region: topic.region, + partition: topic.partition, + }); diff --git a/packages/alchemy/src/Vercel/Queues/QueueData.ts b/packages/alchemy/src/Vercel/Queues/QueueData.ts new file mode 100644 index 0000000000..613fa202f5 --- /dev/null +++ b/packages/alchemy/src/Vercel/Queues/QueueData.ts @@ -0,0 +1,383 @@ +import * as QueuesData from "@distilled.cloud/vercel/queues_data"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import type * as Redacted from "effect/Redacted"; +import { + ConsumerDiscoveryFailed, + ConsumerRegistryNotConfigured, + DuplicateMessage, + MessageAlreadyProcessed, + MessageNotAvailable, + MessageNotFound, + QueueBadRequest, + QueueForbidden, + QueueInternalError, + QueueMessageCorrupted, + QueueRateLimited, + QueueUnauthorized, + type QueueCommonError, + type QueueMessageMeta, +} from "./QueueTypes.ts"; + +/** + * Boundary between the queue capabilities and the DISTILLED Vercel Queues + * data plane (`@distilled.cloud/vercel/queues_data`, generated from + * `manual-specs/`). All HTTP — hosts, `Vqs-*` headers, OIDC bearer, the + * `VERCEL_QUEUE_BASE_URL` local-broker override, multipart/mixed parsing — + * lives in the generated operations; this module only adapts shapes: + * + * - timestamps: generated ops return ISO strings → the public + * `QueueMessageMeta` carries `Date`s + * - errors: generated tags (`QueueBadRequest`, `MessageNotFound`, core + * status errors, …) → the public `Vercel.Queues.*` taxonomy, which carries + * `operation`/`topic`/`id` context the generated classes don't + * - `sendMessage`'s absent `messageId` (202) → the public `null` + * - `receiveMessageById`'s empty batch (no valid part) → the public + * `QueueMessageCorrupted` + * + * INTERNAL scaffolding — NOT exported from the Vercel `index.ts`. The public + * surface is the `SendMessage`/`ReceiveMessages` capabilities. + */ + +// ───────────────────────────────────────────────────────────────────────────── +// Request/response shapes (unchanged public-internal contract) +// ───────────────────────────────────────────────────────────────────────────── + +export interface QueueRequestBase { + /** Region host to hit (`https://{region}.vercel-queue.com`). */ + readonly region: string; + /** Topic (queue) name. */ + readonly topic: string; + /** OIDC bearer token. */ + readonly token: Redacted.Redacted; + /** Deployment partition pin (`Vqs-Deployment-Id`); omitted when undefined. */ + readonly deploymentId?: string | undefined; +} + +/** A received message with its raw (not yet schema-decoded) payload bytes. */ +export interface RawQueueMessage extends QueueMessageMeta { + readonly payload: Uint8Array; +} + +/** Generated message record (ISO timestamps) → the Date-carrying meta shape. */ +const toRawMessage = (message: QueuesData.QueueMessage): RawQueueMessage => ({ + messageId: message.messageId, + deliveryCount: message.deliveryCount, + createdAt: new Date(message.createdAt), + expiresAt: + message.expiresAt !== undefined ? new Date(message.expiresAt) : undefined, + contentType: message.contentType, + receiptHandle: message.receiptHandle, + payload: message.payload, +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Error mapping (generated taxonomy → public Vercel.Queues.* taxonomy) +// ───────────────────────────────────────────────────────────────────────────── + +/** + * The error channel every generated queues_data op shares: its typed + * status errors plus the distilled default/client errors. + */ +type DataPlaneCommonError = + | QueuesData.QueueBadRequest + | QueuesData.QueueUnauthorized + | QueuesData.QueueForbidden + | QueuesData.VercelDataOpError; + +/** Statuses of the core/default error tags QueueInternalError absorbs. */ +const STATUS_BY_TAG: Record = { + InternalServerError: 500, + BadGateway: 502, + ServiceUnavailable: 503, + GatewayTimeout: 504, + Gone: 410, + PaymentRequired: 402, +}; + +/** Recover a status from the protocol's `HTTP ` fallback message. */ +const statusFromMessage = (message: string | undefined): number | undefined => { + if (message === undefined) return undefined; + const match = /^HTTP (\d{3})\b/.exec(message); + return match !== null ? Number(match[1]) : undefined; +}; + +/** + * Map the errors shared by every data-plane op onto the public taxonomy, + * stamping the `operation`/`topic` context the public classes carry. + * Op-specific tags (DuplicateMessage, MessageNotFound, …) must be handled + * BEFORE falling through to this. + */ +const mapCommonError = ( + operation: string, + topic: string, + error: DataPlaneCommonError, +): QueueCommonError => { + switch (error._tag) { + // Transport failures pass through — they are part of the public union. + case "HttpClientError": + return error; + case "QueueBadRequest": + return new QueueBadRequest({ message: error.message, operation, topic }); + case "QueueUnauthorized": + case "Unauthorized": + return new QueueUnauthorized({ + message: error.message, + operation, + topic, + }); + case "QueueForbidden": + return new QueueForbidden({ message: error.message, operation, topic }); + case "TooManyRequests": + return new QueueRateLimited({ + message: error.message, + operation, + topic, + retryAfterSeconds: + error.retryAfter !== undefined + ? Duration.toSeconds(error.retryAfter) + : undefined, + }); + case "VercelParseError": + return new QueueInternalError({ + message: `response decode failed: ${String(error.cause)}`, + operation, + topic, + status: 0, + }); + default: { + const message = error.message ?? `Vercel queue error (${error._tag})`; + return new QueueInternalError({ + message, + operation, + topic, + status: STATUS_BY_TAG[error._tag] ?? statusFromMessage(message) ?? 0, + }); + } + } +}; + +/** + * Best-effort recovery of `originalMessageId` from a 409 body — the + * generated `MessageNotAvailable` doesn't model it as a field, and the + * broker's `{ message, originalMessageId }` JSON body only survives into the + * error message when it isn't envelope-shaped, so this is advisory only. + */ +const parseOriginalMessageId = (message: string): string | undefined => { + try { + const parsed = JSON.parse(message) as { originalMessageId?: string }; + return typeof parsed.originalMessageId === "string" + ? parsed.originalMessageId + : undefined; + } catch { + return undefined; + } +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Operations +// ───────────────────────────────────────────────────────────────────────────── + +export interface SendMessageRawOptions extends QueueRequestBase { + readonly body: Uint8Array; + readonly contentType: string; + readonly idempotencyKey?: string | undefined; + readonly retentionSeconds?: number | undefined; + readonly delaySeconds?: number | undefined; +} + +/** Enqueue one message (POST /api/v3/topic/{topic}). */ +export const sendMessageRaw = Effect.fn("Vercel.Queues.sendMessage")(function* ( + options: SendMessageRawOptions, +) { + const response = yield* QueuesData.sendMessage({ + region: options.region, + token: options.token, + topic: options.topic, + deploymentId: options.deploymentId, + contentType: options.contentType, + idempotencyKey: options.idempotencyKey, + retentionSeconds: options.retentionSeconds, + delaySeconds: options.delaySeconds, + payload: options.body, + }).pipe( + Effect.mapError((error) => { + switch (error._tag) { + case "DuplicateMessage": + return new DuplicateMessage({ + message: error.message, + topic: options.topic, + idempotencyKey: options.idempotencyKey, + }); + case "ConsumerDiscoveryFailed": + return new ConsumerDiscoveryFailed({ + message: error.message, + topic: options.topic, + deploymentId: options.deploymentId, + }); + case "ConsumerRegistryNotConfigured": + return new ConsumerRegistryNotConfigured({ + message: error.message, + topic: options.topic, + }); + default: + return mapCommonError("send message", options.topic, error); + } + }), + ); + // A 202 carries no id — the public receipt models that as null. + return { messageId: response.messageId ?? null } as const; +}); + +export interface ReceiveMessagesRawOptions extends QueueRequestBase { + readonly consumerGroup: string; + readonly visibilityTimeoutSeconds?: number | undefined; + /** 1–10 (`Vqs-Max-Messages`). */ + readonly maxMessages?: number | undefined; +} + +/** Lease a batch (POST /api/v3/topic/{topic}/consumer/{consumerGroup}). */ +export const receiveMessagesRaw = Effect.fn("Vercel.Queues.receiveMessages")( + function* (options: ReceiveMessagesRawOptions) { + // Parts missing required Vqs-* headers are skipped inside the generated + // op (they stay leased and expire back), mirroring @vercel/queue. + const response = yield* QueuesData.receiveMessages({ + region: options.region, + token: options.token, + topic: options.topic, + consumerGroup: options.consumerGroup, + deploymentId: options.deploymentId, + visibilityTimeoutSeconds: options.visibilityTimeoutSeconds, + maxMessages: options.maxMessages, + }).pipe( + Effect.mapError((error) => + mapCommonError("receive messages", options.topic, error), + ), + ); + return response.messages.map(toRawMessage); + }, +); + +export interface ReceiveMessageByIdRawOptions extends QueueRequestBase { + readonly consumerGroup: string; + readonly messageId: string; + readonly visibilityTimeoutSeconds?: number | undefined; +} + +/** Lease one message by id (POST .../consumer/{consumerGroup}/id/{messageId}). */ +export const receiveMessageByIdRaw = Effect.fn( + "Vercel.Queues.receiveMessageById", +)(function* (options: ReceiveMessageByIdRawOptions) { + const response = yield* QueuesData.receiveMessageById({ + region: options.region, + token: options.token, + topic: options.topic, + consumerGroup: options.consumerGroup, + messageId: options.messageId, + deploymentId: options.deploymentId, + visibilityTimeoutSeconds: options.visibilityTimeoutSeconds, + }).pipe( + Effect.mapError((error) => { + switch (error._tag) { + case "MessageNotFound": + return new MessageNotFound({ + message: error.message, + topic: options.topic, + id: options.messageId, + }); + case "MessageNotAvailable": + return new MessageNotAvailable({ + message: error.message, + topic: options.topic, + id: options.messageId, + originalMessageId: parseOriginalMessageId(error.message), + }); + case "MessageAlreadyProcessed": + return new MessageAlreadyProcessed({ + message: error.message, + topic: options.topic, + id: options.messageId, + }); + default: + return mapCommonError("receive message by id", options.topic, error); + } + }), + ); + const first = response.messages[0]; + if (first === undefined) { + // A 2xx whose parts all lacked the required Vqs-* headers — the old + // "no valid message part" corruption case. + return yield* new QueueMessageCorrupted({ + message: `Message ${options.messageId}: response carried no valid message part`, + topic: options.topic, + id: options.messageId, + }); + } + return toRawMessage(first); +}); + +export interface LeaseRawOptions extends QueueRequestBase { + readonly consumerGroup: string; + readonly receiptHandle: string; +} + +type LeaseError = QueuesData.AcknowledgeMessageError; + +const mapLeaseError = + ( + operation: string, + options: LeaseRawOptions, + ): (( + error: LeaseError, + ) => QueueCommonError | MessageNotFound | MessageNotAvailable) => + (error) => { + switch (error._tag) { + case "MessageNotFound": + return new MessageNotFound({ + message: error.message, + topic: options.topic, + id: options.receiptHandle, + }); + case "MessageNotAvailable": + return new MessageNotAvailable({ + message: error.message, + topic: options.topic, + id: options.receiptHandle, + }); + default: + return mapCommonError(operation, options.topic, error); + } + }; + +/** Acknowledge (complete) a delivery (DELETE .../lease/{receiptHandle}). */ +export const acknowledgeMessageRaw = Effect.fn( + "Vercel.Queues.acknowledgeMessage", +)(function* (options: LeaseRawOptions) { + yield* QueuesData.acknowledgeMessage({ + region: options.region, + token: options.token, + topic: options.topic, + consumerGroup: options.consumerGroup, + receiptHandle: options.receiptHandle, + deploymentId: options.deploymentId, + }).pipe(Effect.mapError(mapLeaseError("acknowledge message", options))); +}); + +export interface ExtendLeaseRawOptions extends LeaseRawOptions { + readonly visibilityTimeoutSeconds: number; +} + +/** Extend a delivery's visibility timeout (PATCH .../lease/{receiptHandle}). */ +export const extendLeaseRaw = Effect.fn("Vercel.Queues.extendLease")(function* ( + options: ExtendLeaseRawOptions, +) { + yield* QueuesData.extendLease({ + region: options.region, + token: options.token, + topic: options.topic, + consumerGroup: options.consumerGroup, + receiptHandle: options.receiptHandle, + deploymentId: options.deploymentId, + visibilityTimeoutSeconds: options.visibilityTimeoutSeconds, + }).pipe(Effect.mapError(mapLeaseError("extend lease", options))); +}); diff --git a/packages/alchemy/src/Vercel/Queues/QueueTypes.ts b/packages/alchemy/src/Vercel/Queues/QueueTypes.ts new file mode 100644 index 0000000000..ed0081851c --- /dev/null +++ b/packages/alchemy/src/Vercel/Queues/QueueTypes.ts @@ -0,0 +1,257 @@ +import * as Data from "effect/Data"; +import type * as HttpClientError from "effect/unstable/http/HttpClientError"; + +/** + * Shared types and tagged errors for the Vercel Queues data plane + * (`https://{region}.vercel-queue.com/api/v3`). + * + * The HTTP surface behind these lives in distilled's generated + * `queues_data` service (manual-spec data plane); `QueueData.ts` adapts the + * generated error taxonomy onto these public classes, which carry the + * `operation`/`topic`/`id` context the generated ones don't. + */ + +/** Metadata delivered with every received message (the `Vqs-*` part headers). */ +export interface QueueMessageMeta { + /** Unique message id (`Vqs-Message-Id`). */ + readonly messageId: string; + /** Delivery attempt count for this consumer group (`Vqs-Delivery-Count`). */ + readonly deliveryCount: number; + /** When the message was enqueued (`Vqs-Timestamp`). */ + readonly createdAt: Date; + /** When the message expires (`Vqs-Expires-At`), if reported. */ + readonly expiresAt?: Date | undefined; + /** Payload content type (`Content-Type` of the multipart part). */ + readonly contentType: string; + /** + * Lease handle for this delivery (`Vqs-Receipt-Handle`) — pass to + * `ack`/`extendLease`. Scoped to the consumer group and delivery. + */ + readonly receiptHandle: string; +} + +/** A received message: delivery metadata plus the schema-decoded payload. */ +export interface ReceivedMessage extends QueueMessageMeta { + readonly payload: A; +} + +/** + * Metadata handed to a `subscribe` handler alongside the schema-decoded + * payload — the per-message `Vqs-*` metadata plus the routing identity of + * the push delivery (which topic, under which consumer group). + */ +export interface QueueDeliveryMeta extends QueueMessageMeta { + /** The topic the message was delivered from. */ + readonly topicName: string; + /** The consumer group this delivery is leased under. */ + readonly consumerGroup: string; +} + +/** Receipt returned by a successful send (202 responses carry no id). */ +export interface SendReceipt { + readonly messageId: string | null; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Auth errors +// ───────────────────────────────────────────────────────────────────────────── + +/** + * No ambient OIDC token was found. Inside a Vercel Function the platform + * provisions one (request-context header `x-vercel-oidc-token`, or the + * `VERCEL_OIDC_TOKEN` env var); outside Vercel, mint one with + * `mintProjectOidcToken`/`projectOidcToken` or pass `token` explicitly. + */ +export class MissingOidcToken extends Data.TaggedError( + "Vercel.Queues.MissingOidcToken", +)<{ + readonly message: string; +}> {} + +/** Minting a project OIDC token via the management API failed. */ +export class OidcTokenMintFailed extends Data.TaggedError( + "Vercel.Queues.OidcTokenMintFailed", +)<{ + readonly message: string; + readonly projectIdOrName: string; + readonly cause: unknown; +}> {} + +/** + * The topic partitions by deployment but no deployment id could be resolved + * (no `VERCEL_DEPLOYMENT_ID` in the environment and none passed explicitly). + * Deployment pinning is load-bearing on Vercel queues: unpinned sends are + * invisible to pinned receivers and vice versa. + */ +export class MissingDeploymentId extends Data.TaggedError( + "Vercel.Queues.MissingDeploymentId", +)<{ + readonly message: string; + readonly topic: string; +}> {} + +// ───────────────────────────────────────────────────────────────────────────── +// Data-plane errors (status-mapped, mirroring the documented protocol) +// ───────────────────────────────────────────────────────────────────────────── + +/** 400 — invalid parameters (bad visibility timeout, malformed request, …). */ +export class QueueBadRequest extends Data.TaggedError( + "Vercel.Queues.QueueBadRequest", +)<{ + readonly message: string; + readonly operation: string; + readonly topic: string; +}> {} + +/** 401 — the OIDC bearer token is missing, expired, or invalid. */ +export class QueueUnauthorized extends Data.TaggedError( + "Vercel.Queues.QueueUnauthorized", +)<{ + readonly message: string; + readonly operation: string; + readonly topic: string; +}> {} + +/** 403 — the token is valid but not allowed to access this topic. */ +export class QueueForbidden extends Data.TaggedError( + "Vercel.Queues.QueueForbidden", +)<{ + readonly message: string; + readonly operation: string; + readonly topic: string; +}> {} + +/** 429 — rate limited; `retryAfterSeconds` is parsed from `Retry-After`. */ +export class QueueRateLimited extends Data.TaggedError( + "Vercel.Queues.QueueRateLimited", +)<{ + readonly message: string; + readonly operation: string; + readonly topic: string; + readonly retryAfterSeconds?: number | undefined; +}> {} + +/** 5xx (or an unexpected status) from the queue data plane. */ +export class QueueInternalError extends Data.TaggedError( + "Vercel.Queues.QueueInternalError", +)<{ + readonly message: string; + readonly operation: string; + readonly topic: string; + readonly status: number; +}> {} + +/** 409 on send — the `Vqs-Idempotency-Key` was already used. */ +export class DuplicateMessage extends Data.TaggedError( + "Vercel.Queues.DuplicateMessage", +)<{ + readonly message: string; + readonly topic: string; + readonly idempotencyKey?: string | undefined; +}> {} + +/** 502 on send — the platform could not discover a consumer for the topic. */ +export class ConsumerDiscoveryFailed extends Data.TaggedError( + "Vercel.Queues.ConsumerDiscoveryFailed", +)<{ + readonly message: string; + readonly topic: string; + readonly deploymentId?: string | undefined; +}> {} + +/** + * 503 on send — the project has no consumer registry configured (no + * deployment carries a queue trigger for this topic yet). + */ +export class ConsumerRegistryNotConfigured extends Data.TaggedError( + "Vercel.Queues.ConsumerRegistryNotConfigured", +)<{ + readonly message: string; + readonly topic: string; +}> {} + +/** 404 — no such message (or receipt handle) for this consumer group. */ +export class MessageNotFound extends Data.TaggedError( + "Vercel.Queues.MessageNotFound", +)<{ + readonly message: string; + readonly topic: string; + readonly id: string; +}> {} + +/** + * 409 — the message exists but is not in a receivable/ackable state for this + * consumer group (already leased, wrong receipt handle, or a duplicate whose + * `originalMessageId` should be used instead). + */ +export class MessageNotAvailable extends Data.TaggedError( + "Vercel.Queues.MessageNotAvailable", +)<{ + readonly message: string; + readonly topic: string; + readonly id: string; + readonly originalMessageId?: string | undefined; +}> {} + +/** 410 — the message was already processed by this consumer group. */ +export class MessageAlreadyProcessed extends Data.TaggedError( + "Vercel.Queues.MessageAlreadyProcessed", +)<{ + readonly message: string; + readonly topic: string; + readonly id: string; +}> {} + +/** A multipart part (or payload) that could not be parsed as a message. */ +export class QueueMessageCorrupted extends Data.TaggedError( + "Vercel.Queues.QueueMessageCorrupted", +)<{ + readonly message: string; + readonly topic: string; + readonly id?: string | undefined; +}> {} + +// ───────────────────────────────────────────────────────────────────────────── +// Error unions per operation +// ───────────────────────────────────────────────────────────────────────────── + +/** Token resolution errors (ambient lookup or management-API mint). */ +export type OidcTokenError = MissingOidcToken | OidcTokenMintFailed; + +/** Errors shared by every data-plane operation. */ +export type QueueCommonError = + | QueueBadRequest + | QueueUnauthorized + | QueueForbidden + | QueueRateLimited + | QueueInternalError + | HttpClientError.HttpClientError; + +export type SendMessageError = + | OidcTokenError + | MissingDeploymentId + | DuplicateMessage + | ConsumerDiscoveryFailed + | ConsumerRegistryNotConfigured + | QueueCommonError; + +export type ReceiveMessagesError = + | OidcTokenError + | MissingDeploymentId + | QueueMessageCorrupted + | QueueCommonError; + +export type ReceiveMessageByIdError = + | ReceiveMessagesError + | MessageNotFound + | MessageNotAvailable + | MessageAlreadyProcessed; + +export type AcknowledgeMessageError = + | OidcTokenError + | MissingDeploymentId + | MessageNotFound + | MessageNotAvailable + | QueueCommonError; + +export type ExtendLeaseError = AcknowledgeMessageError; diff --git a/packages/alchemy/src/Vercel/Queues/ReceiveMessages.ts b/packages/alchemy/src/Vercel/Queues/ReceiveMessages.ts new file mode 100644 index 0000000000..1a00a16c0d --- /dev/null +++ b/packages/alchemy/src/Vercel/Queues/ReceiveMessages.ts @@ -0,0 +1,260 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import * as Binding from "../../Binding.ts"; +import type { RuntimeContext } from "../../RuntimeContext.ts"; +import { + acknowledgeMessageRaw, + extendLeaseRaw, + receiveMessageByIdRaw, + receiveMessagesRaw, + type RawQueueMessage, +} from "./QueueData.ts"; +import { + resolveQueueDeploymentId, + resolveQueueToken, + type QueueClientOptions, +} from "./QueueClient.ts"; +import { + QueueMessageCorrupted, + type AcknowledgeMessageError, + type ExtendLeaseError, + type ReceiveMessageByIdError, + type ReceiveMessagesError, + type ReceivedMessage, +} from "./QueueTypes.ts"; +import type { Topic } from "./Topic.ts"; + +const textDecoder = new TextDecoder(); + +/** Consumer-group identity (and shared receive defaults) for a client. */ +export interface ReceiveMessagesOptions { + /** + * Consumer group to receive under. Groups are dynamic — a fresh group + * replays the topic from the beginning (live-verified); each group has its + * own cursor, leases, and acks. + */ + readonly consumerGroup: string; + /** Default visibility timeout (seconds) applied to every receive. */ + readonly visibilityTimeoutSeconds?: number; +} + +/** Per-receive overrides. */ +export interface ReceiveOptions { + /** Max messages per receive, 1–10 (`Vqs-Max-Messages`). */ + readonly maxMessages?: number; + /** Visibility timeout (seconds) for the returned leases. */ + readonly visibilityTimeoutSeconds?: number; +} + +/** + * Poll-mode consumer client for a {@link Topic}: bounded receives under a + * consumer group, plus lease management (`ack` / `extendLease`). Payloads + * are decoded with the topic's schema. + */ +export interface ReceiveMessagesClient< + S extends Schema.Top = Schema.Top, + R = never, +> { + readonly topic: Topic; + readonly consumerGroup: string; + /** One bounded receive (single request, ≤10 messages) — never a long poll. */ + readonly receive: ( + options?: ReceiveOptions, + ) => Effect.Effect< + ReadonlyArray>, + ReceiveMessagesError | Schema.SchemaError, + R | S["DecodingServices"] + >; + /** Receive one specific message by id (leases it like `receive`). */ + readonly receiveById: ( + messageId: string, + options?: Pick, + ) => Effect.Effect< + ReceivedMessage, + ReceiveMessageByIdError | Schema.SchemaError, + R | S["DecodingServices"] + >; + /** Acknowledge (complete) a delivery — the message is not redelivered to this group. */ + readonly ack: ( + receiptHandle: string, + ) => Effect.Effect; + /** Extend (or shrink) the current lease's visibility timeout. */ + readonly extendLease: ( + receiptHandle: string, + visibilityTimeoutSeconds: number, + ) => Effect.Effect; +} + +/** + * Receive typed messages from a Vercel queue {@link Topic} in poll mode — + * consumer-group receive + ack + lease extension, for infrastructure that + * runs OUTSIDE Vercel's push delivery (external workers, tests, other + * clouds' runtimes). Push consumption inside a deployed Function is the + * `subscribe` event source (separate wave). + * + * Provide the implementation with + * `Effect.provide(Vercel.ReceiveMessagesHttp)`; outside a Function build the + * client directly with {@link makeReceiveMessagesClient}. + * + * @binding + * @section Receiving Messages + * @example Poll, process, acknowledge + * ```typescript + * const token = yield* Vercel.projectOidcToken(project.projectId); + * const consumer = yield* Vercel.makeReceiveMessagesClient(Orders, { + * consumerGroup: "worker", + * token, + * deploymentId: deployment.deploymentId, + * }); + * const messages = yield* consumer.receive({ maxMessages: 10 }); + * for (const message of messages) { + * yield* process(message.payload); + * yield* consumer.ack(message.receiptHandle); + * } + * ``` + * + * @example Holding a lease across slow work + * ```typescript + * const [message] = yield* consumer.receive({ visibilityTimeoutSeconds: 30 }); + * yield* consumer.extendLease(message.receiptHandle, 300); + * ``` + */ +export interface ReceiveMessages extends Binding.Service< + ReceiveMessages, + "Vercel.ReceiveMessages", + ( + topic: Topic, + options: ReceiveMessagesOptions, + ) => Effect.Effect> +> { + ( + topic: Topic, + options: ReceiveMessagesOptions, + ): Effect.Effect< + ReceiveMessagesClient, + never, + ReceiveMessages + >; +} + +export const ReceiveMessages = Binding.Service( + "Vercel.ReceiveMessages", +); + +/** Decode one raw multipart message's payload with the topic's schema. */ +const decodeMessage = ( + topic: Topic, + raw: RawQueueMessage, +) => + Effect.gen(function* () { + const json = yield* Effect.try({ + try: () => JSON.parse(textDecoder.decode(raw.payload)) as unknown, + catch: (cause) => + new QueueMessageCorrupted({ + message: `Message ${raw.messageId}: payload is not valid JSON (${String(cause)})`, + topic: topic.topicName, + id: raw.messageId, + }), + }); + const payload = yield* Schema.decodeUnknownEffect(topic.schema)(json); + return { + messageId: raw.messageId, + deliveryCount: raw.deliveryCount, + createdAt: raw.createdAt, + expiresAt: raw.expiresAt, + contentType: raw.contentType, + receiptHandle: raw.receiptHandle, + payload, + } as ReceivedMessage; + }); + +/** + * Build a {@link ReceiveMessagesClient} directly — the constructor for code + * running OUTSIDE a deployed Function (tests, external poll workers), where + * the OIDC token is minted (`mintProjectOidcToken` / `projectOidcToken`) + * rather than ambient. + */ +export const makeReceiveMessagesClient = ( + topic: Topic, + options: ReceiveMessagesOptions & QueueClientOptions, +): Effect.Effect, never, HttpClient.HttpClient> => + Effect.gen(function* () { + const context = yield* Effect.context(); + const base = Effect.gen(function* () { + const token = yield* resolveQueueToken(options.token); + const deploymentId = yield* resolveQueueDeploymentId( + topic, + options.deploymentId, + ); + return { + region: topic.region, + topic: topic.topicName, + consumerGroup: options.consumerGroup, + token, + deploymentId, + }; + }); + + const receive: ReceiveMessagesClient["receive"] = Effect.fn( + `Vercel.ReceiveMessages(${topic.topicName})`, + )(function* (receiveOptions?: ReceiveOptions) { + const raw = yield* Effect.flatMap(base, (common) => + receiveMessagesRaw({ + ...common, + visibilityTimeoutSeconds: + receiveOptions?.visibilityTimeoutSeconds ?? + options.visibilityTimeoutSeconds, + maxMessages: receiveOptions?.maxMessages, + }), + ).pipe(Effect.provideContext(context)); + const messages: ReceivedMessage[] = []; + for (const message of raw) { + messages.push(yield* decodeMessage(topic, message)); + } + return messages; + }) as ReceiveMessagesClient["receive"]; + + const receiveById: ReceiveMessagesClient["receiveById"] = Effect.fn( + `Vercel.ReceiveMessages(${topic.topicName}).receiveById`, + )(function* ( + messageId: string, + receiveOptions?: Pick, + ) { + const raw = yield* Effect.flatMap(base, (common) => + receiveMessageByIdRaw({ + ...common, + messageId, + visibilityTimeoutSeconds: + receiveOptions?.visibilityTimeoutSeconds ?? + options.visibilityTimeoutSeconds, + }), + ).pipe(Effect.provideContext(context)); + return yield* decodeMessage(topic, raw); + }) as ReceiveMessagesClient["receiveById"]; + + const ack: ReceiveMessagesClient["ack"] = Effect.fn( + `Vercel.ReceiveMessages(${topic.topicName}).ack`, + )(function* (receiptHandle: string) { + yield* Effect.flatMap(base, (common) => + acknowledgeMessageRaw({ ...common, receiptHandle }), + ).pipe(Effect.provideContext(context)); + }); + + const extendLease: ReceiveMessagesClient["extendLease"] = Effect.fn( + `Vercel.ReceiveMessages(${topic.topicName}).extendLease`, + )(function* (receiptHandle: string, visibilityTimeoutSeconds: number) { + yield* Effect.flatMap(base, (common) => + extendLeaseRaw({ ...common, receiptHandle, visibilityTimeoutSeconds }), + ).pipe(Effect.provideContext(context)); + }); + + return { + topic, + consumerGroup: options.consumerGroup, + receive, + receiveById, + ack, + extendLease, + } satisfies ReceiveMessagesClient; + }); diff --git a/packages/alchemy/src/Vercel/Queues/ReceiveMessagesHttp.ts b/packages/alchemy/src/Vercel/Queues/ReceiveMessagesHttp.ts new file mode 100644 index 0000000000..543aa0d5c6 --- /dev/null +++ b/packages/alchemy/src/Vercel/Queues/ReceiveMessagesHttp.ts @@ -0,0 +1,69 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as Schema from "effect/Schema"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import * as Binding from "../../Binding.ts"; +import type { RuntimeContext } from "../../RuntimeContext.ts"; +import { isFunction } from "../Functions/Function.ts"; +import { topicEnvKey, topicEnvValue } from "./QueueClient.ts"; +import { + makeReceiveMessagesClient, + ReceiveMessages, + type ReceiveMessagesClient, + type ReceiveMessagesOptions, +} from "./ReceiveMessages.ts"; +import type { Topic } from "./Topic.ts"; + +/** + * HTTP (OIDC data-plane) implementation of {@link ReceiveMessages}. + * + * Deploy half (guarded by `__ALCHEMY_RUNTIME__`): registers the topic's env + * refs on the host Function. Runtime half: the poll-mode consumer client, + * authenticated with the ambient OIDC token and pinned to + * `VERCEL_DEPLOYMENT_ID` unless the topic is `partition: "shared"`. + * + * ## Runtime authorization + * + * Same story as `SendMessageHttp`: the **ambient per-deployment OIDC + * token** (request-context `x-vercel-oidc-token`, then + * `VERCEL_OIDC_TOKEN`) authorizes the consumer — nothing is minted or + * synced by alchemy, and the deploy half binds only non-secret topic + * metadata. Consumers running OUTSIDE Vercel mint a development-scoped + * project OIDC token via `projects.getProjectToken` + * (`mintProjectOidcToken` / `projectOidcToken` in `OidcToken.ts`); minted + * tokens are always `environment: development`-scoped, a namespace + * disjoint from deployed production traffic. + * + * Provide on the Function's init Effect: + * `Effect.provide(Vercel.ReceiveMessagesHttp)`. + */ +export const ReceiveMessagesHttp: Layer.Layer< + ReceiveMessages, + never, + HttpClient.HttpClient +> = Layer.effect( + ReceiveMessages, + Effect.gen(function* () { + const context = yield* Effect.context(); + return Effect.fn(function* ( + topic: Topic, + options: ReceiveMessagesOptions, + ) { + if (!globalThis.__ALCHEMY_RUNTIME__) { + const host = yield* Binding.Host; + if (isFunction(host)) { + yield* host.bind`ReceiveMessages(${host}, ${topic.topicName})`({ + env: { [topicEnvKey(topic)]: topicEnvValue(topic) }, + }); + } + } + const client = yield* makeReceiveMessagesClient(topic, options).pipe( + Effect.provideContext(context), + ); + // Widening to the RuntimeContext-colored interface is safe (R is + // contravariant); the ambient-token client only functions inside a + // deployed Vercel Function anyway. + return client as ReceiveMessagesClient; + }); + }), +); diff --git a/packages/alchemy/src/Vercel/Queues/SendMessage.ts b/packages/alchemy/src/Vercel/Queues/SendMessage.ts new file mode 100644 index 0000000000..af00137b2c --- /dev/null +++ b/packages/alchemy/src/Vercel/Queues/SendMessage.ts @@ -0,0 +1,246 @@ +import * as QueuesData from "@distilled.cloud/vercel/queues_data"; +import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import * as Binding from "../../Binding.ts"; +import type { RuntimeContext } from "../../RuntimeContext.ts"; +import { ambientOidcTokenUnsafe } from "./OidcToken.ts"; +import { sendMessageRaw } from "./QueueData.ts"; +import { + resolveQueueDeploymentId, + resolveQueueToken, + type QueueClientOptions, +} from "./QueueClient.ts"; +import type { SendMessageError, SendReceipt } from "./QueueTypes.ts"; +import type { Topic } from "./Topic.ts"; + +const textEncoder = new TextEncoder(); + +/** Per-send overrides of the topic's send defaults. */ +export interface SendOptions { + /** + * Idempotency key (`Vqs-Idempotency-Key`) — a duplicate key fails with the + * typed `DuplicateMessage` error. + */ + readonly idempotencyKey?: string; + /** Delivery delay in seconds; defaults to the topic's `delaySeconds`. */ + readonly delaySeconds?: number; + /** Retention in seconds; defaults to the topic's `retentionSeconds`. */ + readonly retentionSeconds?: number; +} + +/** + * Typed producer client for a {@link Topic}. Messages are encoded with the + * topic's schema and sent as JSON to the topic's region endpoint, pinned to + * the resolved deployment partition. + */ +export interface SendMessageClient< + S extends Schema.Top = Schema.Top, + R = never, +> { + readonly topic: Topic; + readonly send: ( + message: S["Type"], + options?: SendOptions, + ) => Effect.Effect< + SendReceipt, + SendMessageError | Schema.SchemaError, + R | S["EncodingServices"] + >; +} + +/** + * Send typed messages to a Vercel queue {@link Topic}. + * + * The init (bind) half registers the topic's env refs on the host Function + * (region, topic name, partition mode — never tokens: the runtime OIDC token + * is ambient inside a deployed Function); the runtime client schema-encodes + * each message and pins `Vqs-Deployment-Id` from `VERCEL_DEPLOYMENT_ID` + * unless the topic is declared `partition: "shared"`. Provide the + * implementation with `Effect.provide(Vercel.SendMessageHttp)`. + * + * @binding + * @section Sending Messages + * @example Producer inside an Effect-native Function + * ```typescript + * export default class Api extends Vercel.Function()( + * "Api", + * { main: import.meta.url }, + * Effect.gen(function* () { + * const orders = yield* Vercel.SendMessage(Orders); + * return { + * fetch: Effect.gen(function* () { + * yield* orders + * .send({ orderId: crypto.randomUUID(), amountCents: 4200 }) + * .pipe(Effect.orDie); + * return yield* HttpServerResponse.json({ queued: true }); + * }), + * }; + * }).pipe(Effect.provide(Vercel.SendMessageHttp)), + * ) {} + * ``` + * + * @example Sending from outside Vercel (tests, scripts) + * ```typescript + * const token = yield* Vercel.mintProjectOidcToken(fn.projectId); + * const orders = yield* Vercel.makeSendMessageClient(Orders, { + * token, + * deploymentId: fn.deploymentId, + * }); + * yield* orders.send({ orderId: "o-1", amountCents: 100 }); + * ``` + */ +export interface SendMessage extends Binding.Service< + SendMessage, + "Vercel.SendMessage", + ( + topic: Topic, + ) => Effect.Effect> +> { + ( + topic: Topic, + ): Effect.Effect, never, SendMessage>; +} + +export const SendMessage = Binding.Service("Vercel.SendMessage"); + +/** + * Build a {@link SendMessageClient} directly — the constructor for code + * running OUTSIDE a deployed Function (tests, scripts, other clouds' + * runtimes), where the OIDC token is minted rather than ambient. + * + * The `HttpClient` is captured once at construction; the returned client's + * `send` needs no further context (beyond the schema's own services). + */ +export const makeSendMessageClient = ( + topic: Topic, + options?: QueueClientOptions, +): Effect.Effect, never, HttpClient.HttpClient> => + Effect.gen(function* () { + const context = yield* Effect.context(); + const encode = Schema.encodeEffect(topic.schema); + const send: SendMessageClient["send"] = Effect.fn( + `Vercel.SendMessage(${topic.topicName})`, + )(function* (message: S["Type"], sendOptions?: SendOptions) { + const token = yield* resolveQueueToken(options?.token); + const deploymentId = yield* resolveQueueDeploymentId( + topic, + options?.deploymentId, + ); + const encoded = yield* encode(message); + const body = yield* Effect.sync(() => + textEncoder.encode(JSON.stringify(encoded)), + ); + return yield* sendMessageRaw({ + region: topic.region, + topic: topic.topicName, + token, + deploymentId, + body, + contentType: "application/json", + idempotencyKey: sendOptions?.idempotencyKey, + retentionSeconds: + sendOptions?.retentionSeconds ?? topic.retentionSeconds, + delaySeconds: sendOptions?.delaySeconds ?? topic.delaySeconds, + }).pipe(Effect.provideContext(context)); + }) as SendMessageClient["send"]; + return { topic, send } satisfies SendMessageClient; + }); + +// ───────────────────────────────────────────────────────────────────────────── +// Async mode (no Effect runtime) +// ───────────────────────────────────────────────────────────────────────────── + +/** Promise-based producer for plain async Functions. */ +export interface AsyncSendMessageClient { + readonly topic: Topic; + send(message: S["Type"], options?: SendOptions): Promise; +} + +/** + * `fromEnv`-style constructor for **plain async Functions** (no Effect + * runtime shipped): resolves the ambient OIDC token and deployment id from + * the environment at call time, encodes with the topic's schema, and throws + * plain `Error`s. For Effect code use {@link SendMessage} (bound) or + * {@link makeSendMessageClient}. + * + * @example Async handler + * ```typescript + * import { Orders } from "../resources.ts"; + * const orders = Vercel.sendMessageFromEnv(Orders); + * + * export default { + * async fetch(request: Request): Promise { + * await orders.send({ orderId: crypto.randomUUID(), amountCents: 4200 }); + * return Response.json({ queued: true }); + * }, + * }; + * ``` + */ +export const sendMessageFromEnv = < + // Sync encoding cannot provide services — constrain to service-free + // schemas (`EncodingServices: never`), which every plain data schema is. + S extends Schema.Top & { readonly EncodingServices: never }, +>( + topic: Topic, + options?: { + /** Explicit OIDC bearer token; defaults to the ambient token. */ + readonly token?: string; + /** Explicit deployment pin; `null` opts out of pinning. */ + readonly deploymentId?: string | null; + }, +): AsyncSendMessageClient => { + const encodeSync = Schema.encodeSync(topic.schema); + return { + topic, + async send(message, sendOptions) { + const token = options?.token ?? ambientOidcTokenUnsafe(); + if (token === undefined || token === "") { + throw new Error( + `Vercel.sendMessageFromEnv(${topic.topicName}): no ambient OIDC token (x-vercel-oidc-token / VERCEL_OIDC_TOKEN)`, + ); + } + let deploymentId: string | undefined; + if (options?.deploymentId === null || topic.partition === "shared") { + deploymentId = undefined; + } else { + deploymentId = + options?.deploymentId ?? process.env.VERCEL_DEPLOYMENT_ID; + if (deploymentId === undefined || deploymentId === "") { + throw new Error( + `Vercel.sendMessageFromEnv(${topic.topicName}): topic partitions by deployment but VERCEL_DEPLOYMENT_ID is not set`, + ); + } + } + // Same distilled data-plane op as the Effect clients — hosts, Vqs-* + // headers, and the VERCEL_QUEUE_BASE_URL override all live there. Run + // one-shot on a fetch-backed client; failures become plain Errors. + const result = await Effect.runPromise( + QueuesData.sendMessage({ + region: topic.region, + token, + topic: topic.topicName, + deploymentId, + contentType: "application/json", + idempotencyKey: sendOptions?.idempotencyKey, + retentionSeconds: + sendOptions?.retentionSeconds ?? topic.retentionSeconds, + delaySeconds: sendOptions?.delaySeconds ?? topic.delaySeconds, + payload: JSON.stringify(encodeSync(message)), + }).pipe(Effect.result, Effect.provide(FetchHttpClient.layer)), + ); + if (Result.isFailure(result)) { + const error = result.failure; + throw new Error( + `Vercel.sendMessageFromEnv(${topic.topicName}): send failed (${error._tag})` + + ("message" in error && error.message !== undefined + ? `: ${String(error.message)}` + : ""), + ); + } + return { messageId: result.success.messageId ?? null }; + }, + }; +}; diff --git a/packages/alchemy/src/Vercel/Queues/SendMessageHttp.ts b/packages/alchemy/src/Vercel/Queues/SendMessageHttp.ts new file mode 100644 index 0000000000..e4fe53b174 --- /dev/null +++ b/packages/alchemy/src/Vercel/Queues/SendMessageHttp.ts @@ -0,0 +1,67 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as Schema from "effect/Schema"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import * as Binding from "../../Binding.ts"; +import type { RuntimeContext } from "../../RuntimeContext.ts"; +import { isFunction } from "../Functions/Function.ts"; +import { topicEnvKey, topicEnvValue } from "./QueueClient.ts"; +import { + makeSendMessageClient, + SendMessage, + type SendMessageClient, +} from "./SendMessage.ts"; +import type { Topic } from "./Topic.ts"; + +/** + * HTTP (OIDC data-plane) implementation of {@link SendMessage}. + * + * Deploy half (guarded by `__ALCHEMY_RUNTIME__`): registers the topic's env + * refs on the host Function — region, topic name, partition mode; never + * tokens (the runtime OIDC token is platform-ambient). Runtime half: the + * schema-typed producer client over `https://{region}.vercel-queue.com`, + * authenticated with the ambient OIDC token and pinned to + * `VERCEL_DEPLOYMENT_ID` unless the topic is `partition: "shared"`. + * + * ## Runtime authorization + * + * Deployed compute is authorized by the **ambient per-deployment OIDC + * token** the platform provisions on every invocation (request-context + * `x-vercel-oidc-token` header, falling back to `VERCEL_OIDC_TOKEN`) — no + * credential is ever minted, bound, or synced by alchemy; the deploy half + * registers only non-secret topic metadata (name, region, partition mode). + * The token is scoped to the (project, environment) queue namespace and + * expires on its own. External (non-Vercel) producers mint a + * development-scoped token via `projects.getProjectToken` + * (`mintProjectOidcToken` / `projectOidcToken` in `OidcToken.ts`). + * + * Provide on the Function's init Effect: + * `Effect.provide(Vercel.SendMessageHttp)`. + */ +export const SendMessageHttp: Layer.Layer< + SendMessage, + never, + HttpClient.HttpClient +> = Layer.effect( + SendMessage, + Effect.gen(function* () { + const context = yield* Effect.context(); + return Effect.fn(function* (topic: Topic) { + if (!globalThis.__ALCHEMY_RUNTIME__) { + const host = yield* Binding.Host; + if (isFunction(host)) { + yield* host.bind`SendMessage(${host}, ${topic.topicName})`({ + env: { [topicEnvKey(topic)]: topicEnvValue(topic) }, + }); + } + } + const client = yield* makeSendMessageClient(topic).pipe( + Effect.provideContext(context), + ); + // Widening to the RuntimeContext-colored interface is safe: R is + // contravariant, and the ambient-token client only functions inside a + // deployed Vercel Function anyway. + return client as SendMessageClient; + }); + }), +); diff --git a/packages/alchemy/src/Vercel/Queues/Subscribe.ts b/packages/alchemy/src/Vercel/Queues/Subscribe.ts new file mode 100644 index 0000000000..61e3ba880e --- /dev/null +++ b/packages/alchemy/src/Vercel/Queues/Subscribe.ts @@ -0,0 +1,191 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as Schema from "effect/Schema"; +import * as Namespace from "../../Namespace.ts"; +import { RuntimeContext } from "../../RuntimeContext.ts"; +import type { VercelFunctionContext } from "../Functions/FunctionBridge.ts"; +import { Function } from "../Functions/Function.ts"; +import type { QueueDeliveryMeta } from "./QueueTypes.ts"; +import type { Topic } from "./Topic.ts"; + +/** Consumer-group names on the platform: keep to a safe charset. */ +const sanitizeConsumerGroup = (value: string): string => + value.replaceAll(/[^A-Za-z0-9_-]/g, "-"); + +export interface SubscribeOptions { + /** + * Consumer group the platform consumes under. Groups have independent + * cursors/acks, so keep it stable across deploys — a fresh group replays + * the topic from the beginning. + * @default "alchemy-{FunctionLogicalId}" + */ + readonly consumerGroup?: string; + /** Redelivery backoff after a failed delivery, in seconds. */ + readonly retryAfterSeconds?: number; + /** Delay before the first delivery attempt, in seconds. */ + readonly initialDelaySeconds?: number; +} + +/** + * Subscribe a Vercel Function to a queue {@link Topic} with an Effect + * handler — Vercel's push-based queue consumption. + * + * A single call wires both halves of the event source: + * + * - **Deploy-time**: contributes a `queue/v2beta` trigger through the + * Function's binding contract. Because a function carrying a queue trigger + * loses ALL public HTTP routing (live-verified, D9a), the provider lowers + * the trigger into a SEPARATE consumer function + * (`functions/_alchemy-queue.func`) that shares the Function's module — + * the public function keeps serving HTTP, and the consumer function is + * never publicly routed. + * - **Runtime**: registers the handler in the Function's subscription + * registry; the consumer bridge decodes each platform delivery with the + * topic's schema, runs the handler, and completes the lease. A failing + * handler responds 500 without acking, so the message is redelivered after + * the trigger's visibility window (at-least-once — make handlers + * idempotent). + * + * Sends into the topic must be deployment-pinned (the default `partition: + * "deployment"`): the platform's push delivery only consumes the sending + * deployment's partition (live-verified). + * + * Requires `QueueEventSourceLive` provided on the Function's init Effect. + * + * @binding + * @product Queues + * + * @section Subscribing to a Topic + * @example Producer and consumer in one Function + * ```typescript + * import * as Vercel from "alchemy/Vercel"; + * import * as Effect from "effect/Effect"; + * import * as Schema from "effect/Schema"; + * import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; + * + * class Orders extends Vercel.Topic()("orders", { + * schema: Schema.Struct({ orderId: Schema.String, amountCents: Schema.Int }), + * region: "iad1", + * }) {} + * + * export default class Api extends Vercel.Function()( + * "Api", + * { main: import.meta.url }, + * Effect.gen(function* () { + * const orders = yield* Vercel.SendMessage(Orders); + * + * yield* Vercel.subscribe(Orders, (order) => + * Effect.log(`processing order ${order.orderId}`), + * ); + * + * return { + * fetch: Effect.gen(function* () { + * yield* orders + * .send({ orderId: crypto.randomUUID(), amountCents: 4200 }) + * .pipe(Effect.orDie); + * return yield* HttpServerResponse.json({ queued: true }); + * }), + * }; + * }).pipe( + * Effect.provide([Vercel.SendMessageHttp, Vercel.QueueEventSourceLive]), + * ), + * ) {} + * ``` + * + * @example Delivery metadata and retry tuning + * ```typescript + * yield* Vercel.subscribe( + * Orders, + * (order, meta) => + * Effect.log( + * `order ${order.orderId} (message ${meta.messageId}, attempt ${meta.deliveryCount})`, + * ), + * { retryAfterSeconds: 30, initialDelaySeconds: 5 }, + * ); + * ``` + * + * @see https://vercel.com/docs/queues + */ +export const subscribe = ( + topic: Topic, + handler: ( + payload: S["Type"], + meta: QueueDeliveryMeta, + ) => Effect.Effect, + options?: SubscribeOptions, +): Effect.Effect< + void, + never, + QueueEventSource | Exclude | S["DecodingServices"] +> => QueueEventSource.use((source) => source(topic, handler, options)); + +export type QueueEventSourceService = ( + topic: Topic, + handler: ( + payload: S["Type"], + meta: QueueDeliveryMeta, + ) => Effect.Effect, + options?: SubscribeOptions, +) => Effect.Effect< + void, + never, + Exclude | S["DecodingServices"] +>; + +export class QueueEventSource extends Context.Service< + QueueEventSource, + QueueEventSourceService +>()("Vercel.Queues.QueueEventSource") {} + +export const QueueEventSourceLive = Layer.effect( + QueueEventSource, + Effect.gen(function* () { + const host = yield* Function; + // Stable default consumer group: per-Function, deterministic across + // deploys (a fresh group would replay the topic from the beginning). + const defaultConsumerGroup = `alchemy-${sanitizeConsumerGroup(host.LogicalId)}`; + return Effect.fn(function* ( + topic: Topic, + handler: ( + payload: S["Type"], + meta: QueueDeliveryMeta, + ) => Effect.Effect, + options?: SubscribeOptions, + ) { + const consumer = options?.consumerGroup ?? defaultConsumerGroup; + // Deploy-time: contribute the trigger to the host Function's binding + // channel — the provider lowers it into the separate consumer + // function's `.vc-config.json`. Skipped inside the deployed bundle. + if (!globalThis.__ALCHEMY_RUNTIME__) { + yield* Namespace.push( + host.LogicalId, + host.bind(`Subscribe(${topic.topicName})`, { + queues: [ + { + topic: topic.topicName, + consumer, + ...(options?.retryAfterSeconds !== undefined + ? { retryAfterSeconds: options.retryAfterSeconds } + : {}), + ...(options?.initialDelaySeconds !== undefined + ? { initialDelaySeconds: options.initialDelaySeconds } + : {}), + }, + ], + }), + ); + } + + const ctx = (yield* RuntimeContext) as unknown as VercelFunctionContext; + yield* ctx.registerQueueSubscription({ + topic, + consumerGroup: consumer, + handler: handler as ( + payload: any, + meta: QueueDeliveryMeta, + ) => Effect.Effect, + }); + }) as QueueEventSourceService; + }), +); diff --git a/packages/alchemy/src/Vercel/Queues/Topic.ts b/packages/alchemy/src/Vercel/Queues/Topic.ts new file mode 100644 index 0000000000..352d8814b5 --- /dev/null +++ b/packages/alchemy/src/Vercel/Queues/Topic.ts @@ -0,0 +1,148 @@ +import type * as Schema from "effect/Schema"; + +export const TopicTypeId = "Vercel.Topic" as const; +export type TopicTypeId = typeof TopicTypeId; + +/** + * How sends and receives on a topic are partitioned across deployments. + * + * Vercel partitions queue visibility by `Vqs-Deployment-Id` (live-verified): + * a message sent pinned to one deployment is invisible to receivers pinned to + * another (or to unpinned receivers), and vice versa. + * + * - `"deployment"` (Vercel's default behavior): sends and receives pin + * `Vqs-Deployment-Id` from the ambient `VERCEL_DEPLOYMENT_ID` (or an + * explicit `deploymentId` client option). + * - `"shared"`: opt out of pinning — messages live in the topic's shared + * (unpinned) partition, visible to any unpinned consumer. + */ +export type TopicPartition = "deployment" | "shared"; + +/** Vercel region codes look like `iad1`, `lhr1`, `fra1`. */ +const REGION_PATTERN = /^[a-z]{2,5}[0-9]{1,2}$/; + +export interface TopicOptions { + /** + * Payload schema (`effect/Schema`). Types the producer's `send` and the + * consumer's decoded payload from one declaration; values are encoded on + * send and decoded on receive at the queue boundary. + */ + readonly schema: S; + /** + * Region the topic is pinned to (e.g. `"iad1"`). Topics are region-scoped + * on the platform; the region picks the data-plane endpoint + * (`https://{region}.vercel-queue.com`), so producer and consumer can + * never disagree. + */ + readonly region: string; + /** + * Default retention in seconds applied to every send (`Vqs-Retention-Seconds`). + * Messages expire on their own — there is no delete API. Max 7 days. + */ + readonly retentionSeconds?: number; + /** + * Default delivery delay in seconds applied to every send + * (`Vqs-Delay-Seconds`). + */ + readonly delaySeconds?: number; + /** + * Deployment-partitioning mode for sends/receives on this topic. + * @default "deployment" + */ + readonly partition?: TopicPartition; +} + +/** + * The static shape of a Topic value — what `SendMessage`/`ReceiveMessages` + * accept. The class produced by {@link Topic} satisfies this on its static + * side, so the class itself is the value you pass around. + */ +export interface Topic { + readonly [TopicTypeId]: TopicTypeId; + /** The topic name on the platform (implicit — created on first send). */ + readonly topicName: string; + /** Payload schema; see {@link TopicOptions.schema}. */ + readonly schema: S; + /** Region the topic is pinned to; see {@link TopicOptions.region}. */ + readonly region: string; + /** Default send retention in seconds. */ + readonly retentionSeconds?: number | undefined; + /** Default send delay in seconds. */ + readonly delaySeconds?: number | undefined; + /** Deployment-partitioning mode. */ + readonly partition: TopicPartition; +} + +export interface TopicClass< + Name extends string, + S extends Schema.Top, +> extends Topic { + new (_: never): Topic; + readonly topicName: Name; +} + +export const isTopic = (value: unknown): value is Topic => + (typeof value === "object" || typeof value === "function") && + value !== null && + (value as { [TopicTypeId]?: unknown })[TopicTypeId] === TopicTypeId; + +/** + * A typed Vercel queue topic — a config **value**, not a lifecycle Resource. + * + * Vercel topics have no lifecycle: nothing to create, list, or delete — + * a topic springs into existence on first send, is scoped to one project and + * region, and its messages expire on their own (≤7 days). So `Topic` has no + * provider, no state row, and never appears in a plan. Only its *uses* + * materialize: the env refs a producer binding injects, and (in a later + * wave) the consumer trigger `subscribe` lowers into deployment config. + * + * The value is the single home for the payload schema, the region pin, and + * send defaults (retention, delay, deployment partitioning) — declared once, + * inherited by both ends. + * + * @section Declaring a Topic + * @example Class form (importable from function files) + * ```typescript + * import * as Vercel from "alchemy/Vercel"; + * import * as Schema from "effect/Schema"; + * + * export class Orders extends Vercel.Topic()("orders", { + * schema: Schema.Struct({ orderId: Schema.String, amountCents: Schema.Int }), + * region: "iad1", + * }) {} + * ``` + * + * @example Value form + * ```typescript + * const Orders = Vercel.Topic()("orders", { + * schema: Schema.Struct({ orderId: Schema.String }), + * region: "iad1", + * retentionSeconds: 3600, + * }); + * ``` + */ +export const Topic = + () => + ( + topicName: Name, + options: TopicOptions, + ): TopicClass => { + if (!REGION_PATTERN.test(options.region)) { + throw new Error( + `Vercel.Topic(${JSON.stringify(topicName)}): invalid region ${JSON.stringify( + options.region, + )} — expected a Vercel region code like "iad1" or "lhr1"`, + ); + } + class Base { + static readonly [TopicTypeId]: TopicTypeId = TopicTypeId; + static readonly topicName = topicName; + static readonly schema = options.schema; + static readonly region = options.region; + static readonly retentionSeconds = options.retentionSeconds; + static readonly delaySeconds = options.delaySeconds; + static readonly partition: TopicPartition = + options.partition ?? "deployment"; + } + return Base as unknown as TopicClass; + }; diff --git a/packages/alchemy/src/Vercel/Queues/index.ts b/packages/alchemy/src/Vercel/Queues/index.ts new file mode 100644 index 0000000000..8d14cee11a --- /dev/null +++ b/packages/alchemy/src/Vercel/Queues/index.ts @@ -0,0 +1,12 @@ +export * from "./OidcToken.ts"; +export * from "./QueueTypes.ts"; +export * from "./ReceiveMessages.ts"; +export * from "./ReceiveMessagesHttp.ts"; +export * from "./SendMessage.ts"; +export * from "./SendMessageHttp.ts"; +export * from "./Subscribe.ts"; +export * from "./Topic.ts"; +// NOTE: QueueData.ts (distilled data-plane boundary), QueueClient.ts (shared +// token/pin scaffolding), and QueueCallback.ts (push-delivery runtime) are +// deliberately NOT exported — internal scaffolding per the +// shared-scaffolding convention. diff --git a/packages/alchemy/src/Vercel/Routes/BulkRedirects.ts b/packages/alchemy/src/Vercel/Routes/BulkRedirects.ts new file mode 100644 index 0000000000..9c63d4e91f --- /dev/null +++ b/packages/alchemy/src/Vercel/Routes/BulkRedirects.ts @@ -0,0 +1,353 @@ +import * as bulkRedirects from "@distilled.cloud/vercel/bulk_redirects"; +import * as projects from "@distilled.cloud/vercel/projects"; +import * as Effect from "effect/Effect"; +import { isResolved } from "../../Diff.ts"; +import * as Provider from "../../Provider.ts"; +import { Resource } from "../../Resource.ts"; +import type { Providers } from "../Providers.ts"; +import { VercelEnvironment } from "../VercelEnvironment.ts"; + +/** A single redirect entry. */ +export interface BulkRedirect { + /** Source path to match (e.g. `/old-path`). */ + source: string; + /** Destination path or URL. */ + destination: string; + /** + * HTTP redirect status. Takes precedence over `permanent`. + * @default 307 + */ + statusCode?: number; + /** + * Shorthand for the status: `true` = 308, `false` = 307. Ignored when + * `statusCode` is set. + * @default false + */ + permanent?: boolean; + /** + * Whether the source is matched case-sensitively. + * @default false + */ + caseSensitive?: boolean; + /** Whether query parameters participate in matching. */ + query?: boolean; + /** Whether incoming query parameters are preserved on the redirect. */ + preserveQueryParams?: boolean; +} + +export interface BulkRedirectsProps { + /** + * ID of the Vercel project the redirects apply to. A project has exactly + * one bulk-redirects document; changing this property replaces the + * resource (the old project's document is reset to empty). + */ + projectId: string; + /** + * The full list of redirects. The document is versioned and replaced + * wholesale on every change — redirects not listed here are removed. + */ + redirects: BulkRedirect[]; + /** + * Optional label for versions written by this resource. When omitted, + * Vercel names versions with an ISO timestamp. + */ + name?: string; +} + +export type BulkRedirects = Resource< + "Vercel.BulkRedirects", + BulkRedirectsProps, + { + /** ID of the project the document is attached to. */ + projectId: string; + /** ID of the promoted (production) redirects version. */ + versionId: string; + /** Content key of the promoted version (same content ⇒ same key). */ + versionKey: string; + /** Label of the promoted version. */ + versionName: string | undefined; + /** Number of redirects in the promoted version. */ + redirectCount: number; + }, + never, + Providers +>; + +type BulkRedirectsAttributes = BulkRedirects["Attributes"]; + +/** + * Project-level bulk redirects for a Vercel project, managed through + * Vercel's staged+promote versioning model. + * + * The bulk redirects of a project form a versioned singleton document: + * edits accumulate in a single staging version, and a promote publishes + * that version to production. The provider reconciles by comparing the + * observed production redirects against the desired list and, on any delta, + * staging the full desired document (`overwrite`) and promoting it in one + * step — so the resource always describes the complete production document. + * + * An unpromoted staging draft created out-of-band (e.g. in the dashboard) + * is replaced the next time the resource reconciles a delta: staging is a + * single slot, and this resource owns the document. + * + * Deleting the resource resets the project's redirects by staging and + * promoting an empty document. + * + * @resource + * @section Creating Bulk Redirects + * @example Redirect legacy paths + * ```typescript + * const redirects = yield* Vercel.BulkRedirects("Redirects", { + * projectId: project.projectId, + * redirects: [ + * { source: "/old-home", destination: "/" }, + * { source: "/old-blog", destination: "/blog", statusCode: 308 }, + * ], + * }); + * ``` + * + * @example Temporary (307) redirects + * ```typescript + * const redirects = yield* Vercel.BulkRedirects("Redirects", { + * projectId: project.projectId, + * redirects: [ + * { source: "/campaign", destination: "/spring-sale", permanent: false }, + * ], + * }); + * ``` + * + * @section Versioning + * @example Label the promoted version + * ```typescript + * const redirects = yield* Vercel.BulkRedirects("Redirects", { + * projectId: project.projectId, + * name: "migration-2026-08", + * redirects: [{ source: "/a", destination: "/b" }], + * }); + * ``` + * + * @see https://vercel.com/docs/edge-network/redirects + */ +export const BulkRedirects = Resource("Vercel.BulkRedirects"); + +export const BulkRedirectsProvider = () => + Provider.succeed(BulkRedirects, { + stables: ["projectId"], + diff: Effect.fn(function* ({ olds, news, output }) { + if (!isResolved(news)) return undefined; + const oldProjectId = output?.projectId ?? olds?.projectId; + if (oldProjectId !== undefined && news.projectId !== oldProjectId) { + return { action: "replace" } as const; + } + return undefined; + }), + read: Effect.fn(function* ({ olds, output }) { + const projectId = output?.projectId ?? olds?.projectId; + if (projectId === undefined) return undefined; + const { teamId } = yield* VercelEnvironment.current; + return yield* bulkRedirects + .getRedirects({ projectId, teamId, per_page: 100 }) + .pipe( + Effect.map((doc) => + // `version: null` = no redirects have ever been promoted for + // this project — the resource does not exist yet. + doc.version == null + ? undefined + : toAttributes(projectId, doc.version, doc.redirects.length), + ), + // NotFound = the host project itself is gone. + Effect.catchTag("NotFound", () => Effect.succeed(undefined)), + ); + }), + reconcile: Effect.fn(function* ({ news }) { + const env = yield* VercelEnvironment.current; + const projectId = news.projectId; + // The stage API requires an explicit owner `teamId` in the request + // body (live-verified: omitting it fails 400 `validation_error` even + // when the token rides a default team) — derive it from the host + // project when the profile carries no teamId. + const teamId = + env.teamId ?? + (yield* projects.getProject({ idOrName: projectId })).accountId; + // Observe — `getRedirects` (without versionId) always reflects the + // promoted production document; staging drafts do not shadow it. + const observed = yield* listAllRedirects(projectId, teamId); + const versions = yield* bulkRedirects.getVersions({ projectId, teamId }); + const live = versions.versions.find((v) => v.isLive === true); + if (live !== undefined && matchesDesired(news.redirects, observed)) { + return toAttributes(projectId, live, observed.length); + } + // Sync — stage the full desired document and promote it. A crash + // between the two calls leaves a staging draft; the next reconcile + // still observes production, restages, and promotes — converging. + const staged = yield* bulkRedirects.stageRedirects({ + projectId, + teamId, + overwrite: true, + name: news.name, + redirects: news.redirects.map(toRequestRedirect), + }); + const promoted = yield* bulkRedirects.updateVersion({ + projectId, + teamId, + id: staged.version.id, + action: "promote", + }); + return toAttributes( + projectId, + promoted.version, + promoted.version.redirectCount ?? news.redirects.length, + ); + }), + delete: Effect.fn(function* ({ output }) { + const env = yield* VercelEnvironment.current; + // Reset = stage an empty document and promote it. Idempotent: an + // already-empty document just gains an empty version; a deleted host + // project surfaces as NotFound (from getProject or the stage itself) + // and is not an error. + yield* Effect.gen(function* () { + const teamId = + env.teamId ?? + (yield* projects.getProject({ idOrName: output.projectId })) + .accountId; + const staged = yield* bulkRedirects.stageRedirects({ + projectId: output.projectId, + teamId, + overwrite: true, + redirects: [], + }); + yield* bulkRedirects.updateVersion({ + projectId: output.projectId, + teamId, + id: staged.version.id, + action: "promote", + }); + }).pipe( + Effect.asVoid, + Effect.catchTag("NotFound", () => Effect.void), + ); + }), + }); + +// ───────────────────────────────────────────────────────────────────────────── +// Document mapping & comparison helpers +// ───────────────────────────────────────────────────────────────────────────── + +/** Page through the promoted production redirects (bounded at 25 pages). */ +const listAllRedirects = Effect.fn(function* ( + projectId: string, + teamId: string, +) { + const first = yield* bulkRedirects.getRedirects({ + projectId, + teamId, + per_page: 100, + }); + const all = [...first.redirects]; + const numPages = first.pagination?.numPages ?? 1; + for (let page = 2; page <= Math.min(numPages, 25); page++) { + const next = yield* bulkRedirects.getRedirects({ + projectId, + teamId, + per_page: 100, + page, + }); + all.push(...next.redirects); + } + return all; +}); + +const toRequestRedirect = ( + redirect: BulkRedirect, +): bulkRedirects.StageRedirectsRequestRedirectsItem => ({ + source: redirect.source, + destination: redirect.destination, + statusCode: redirect.statusCode, + permanent: redirect.permanent, + caseSensitive: redirect.caseSensitive, + query: redirect.query, + preserveQueryParams: redirect.preserveQueryParams, +}); + +const toAttributes = ( + projectId: string, + version: bulkRedirects.DeleteRedirectsResponseBodyCase0Version, + redirectCount: number, +): BulkRedirectsAttributes => ({ + projectId, + versionId: version.id, + versionKey: version.key, + versionName: version.name, + redirectCount, +}); + +interface CanonicalRedirect { + source: string; + destination: string; + statusCode: number; + caseSensitive?: boolean; + query?: boolean; + preserveQueryParams?: boolean; +} + +/** + * Materialized status: explicit `statusCode` wins, else `permanent` + * (platform default is temporary — 307, live-verified). + */ +const desiredStatus = (redirect: BulkRedirect): number => + redirect.statusCode ?? (redirect.permanent === true ? 308 : 307); + +const canonicalFromProps = (redirect: BulkRedirect): CanonicalRedirect => ({ + source: redirect.source, + destination: redirect.destination, + statusCode: desiredStatus(redirect), + // Only deviations from the platform defaults participate in the diff — + // the API omits default-valued flags when echoing rows back. + caseSensitive: redirect.caseSensitive === true ? true : undefined, + query: redirect.query === false ? false : undefined, + preserveQueryParams: redirect.preserveQueryParams === true ? true : undefined, +}); + +const canonicalFromObserved = ( + item: bulkRedirects.GetRedirectsResponseRedirectsItem, +): CanonicalRedirect => ({ + source: item.source, + destination: item.destination, + statusCode: item.statusCode ?? (item.permanent === true ? 308 : 307), + caseSensitive: item.caseSensitive === true ? true : undefined, + query: item.query === false ? false : undefined, + preserveQueryParams: item.preserveQueryParams === true ? true : undefined, +}); + +/** + * Deterministic stringify: object keys sorted, `undefined`/`null` members + * dropped, arrays kept in order. + */ +const stableStringify = (value: unknown): string => { + if (value === null || typeof value !== "object") { + return JSON.stringify(value) ?? "null"; + } + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(",")}]`; + } + const entries = Object.entries(value as Record) + .filter(([, v]) => v !== undefined && v !== null) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`); + return `{${entries.join(",")}}`; +}; + +const matchesDesired = ( + desired: BulkRedirect[], + observed: ReadonlyArray, +): boolean => { + if (desired.length !== observed.length) return false; + // Redirect order is not semantic (sources are exact paths) and the API + // may return rows sorted — compare as source-keyed sets. + const bySource = (a: CanonicalRedirect, b: CanonicalRedirect) => + a.source < b.source ? -1 : a.source > b.source ? 1 : 0; + return ( + stableStringify([...desired.map(canonicalFromProps)].sort(bySource)) === + stableStringify([...observed.map(canonicalFromObserved)].sort(bySource)) + ); +}; diff --git a/packages/alchemy/src/Vercel/Routes/ProjectRoutes.ts b/packages/alchemy/src/Vercel/Routes/ProjectRoutes.ts new file mode 100644 index 0000000000..b62f059a3e --- /dev/null +++ b/packages/alchemy/src/Vercel/Routes/ProjectRoutes.ts @@ -0,0 +1,435 @@ +import * as projectRoutes from "@distilled.cloud/vercel/project_routes"; +import * as Effect from "effect/Effect"; +import { isResolved } from "../../Diff.ts"; +import * as Provider from "../../Provider.ts"; +import { Resource } from "../../Resource.ts"; +import type { Providers } from "../Providers.ts"; +import { VercelEnvironment } from "../VercelEnvironment.ts"; + +/** Request parameter a route condition matches against. */ +export type RouteConditionType = "host" | "header" | "cookie" | "query"; + +/** A `has`/`missing` condition on a routing rule. */ +export interface RouteCondition { + /** Request parameter to match on. */ + type: RouteConditionType; + /** Sub-key for keyed parameters (the header, cookie, or query-param name). */ + key?: string; + /** Value to compare against. Omit to match on presence alone. */ + value?: string; +} + +/** Part of the request/response a transform applies to. */ +export type RouteTransformType = + | "request.headers" + | "request.query" + | "response.headers"; + +/** Operation a transform applies. */ +export type RouteTransformOp = "append" | "set" | "delete"; + +/** A request/response transform applied when the rule matches. */ +export interface RouteTransform { + /** Part of the request/response the transform applies to. */ + type: RouteTransformType; + /** Operation to apply. */ + op: RouteTransformOp; + /** Target key selector (e.g. `{ key: "x-header" }`). */ + target?: unknown; + /** Value argument(s) for `append`/`set`. */ + args?: unknown; + /** Environment variable names interpolated into `args`. */ + env?: string[]; +} + +/** The route definition of a rule (from `@vercel/routing-utils`). */ +export interface ProjectRouteDefinition { + /** Source pattern (path-to-regexp, regex, or exact — see `srcSyntax`). */ + src: string; + /** Destination path or URL. Omit for status-only or transform rules. */ + dest?: string; + /** Response headers set when the rule matches. */ + headers?: Record; + /** + * Whether the source pattern is matched case-sensitively. + * @default false + */ + caseSensitive?: boolean; + /** HTTP status (e.g. `308` makes a `dest` rule a redirect). */ + status?: number; + /** Conditions that must all be present for the rule to match. */ + has?: RouteCondition[]; + /** Conditions that must all be absent for the rule to match. */ + missing?: RouteCondition[]; + /** Request/response transforms applied when the rule matches. */ + transforms?: RouteTransform[]; + /** Respect the origin's `Cache-Control` on rewritten responses. */ + respectOriginCacheControl?: boolean; +} + +/** A single routing rule. Rules are evaluated in array order. */ +export interface ProjectRouteRule { + /** + * Stable client-chosen identifier for the rule (e.g. `"legacy-blog"`). + * Keep it constant across deploys — it is how a rule is tracked through + * the document's versions. + */ + id: string; + /** Human-readable name of the rule. */ + name: string; + /** Optional description of what the rule does. */ + description?: string; + /** + * Whether the rule is active. + * @default true + */ + enabled?: boolean; + /** The route definition. */ + route: ProjectRouteDefinition; +} + +export interface ProjectRoutesProps { + /** + * ID of the Vercel project the routing rules apply to. A project has + * exactly one routing-rules document; changing this property replaces the + * resource (the old project's document is reset to empty). + */ + projectId: string; + /** + * The full ordered list of routing rules. The document is versioned and + * replaced wholesale on every change — rules not listed here are removed. + */ + routes: ProjectRouteRule[]; +} + +/** Summary of a deployed routing rule. */ +export interface ProjectRouteAttribute { + id: string; + name: string; + enabled: boolean; + /** Computed route type reported by the API (`redirect`, `rewrite`, …). */ + routeType?: string; +} + +export type ProjectRoutes = Resource< + "Vercel.ProjectRoutes", + ProjectRoutesProps, + { + /** ID of the project the document is attached to. */ + projectId: string; + /** ID of the promoted (production) routes version. */ + versionId: string; + /** Number of rules in the promoted version. */ + ruleCount: number; + /** Deployed rules in evaluation order. */ + routes: ProjectRouteAttribute[]; + }, + never, + Providers +>; + +type ProjectRoutesAttributes = ProjectRoutes["Attributes"]; + +/** + * Project-level routing rules (redirects, rewrites, transforms) for a Vercel + * project, managed through Vercel's staged+promote versioning model. + * + * The routing rules of a project form a versioned singleton document: edits + * accumulate in a single staging version, and a promote publishes that + * version to production. The provider reconciles by comparing the observed + * production document against the desired rules and, on any delta, staging + * the full desired document (`overwrite`) and promoting it in one step — + * so the resource always describes the complete production document. + * + * An unpromoted staging draft created out-of-band (e.g. in the dashboard) is + * replaced the next time the resource reconciles a delta: staging is a + * single slot, and this resource owns the document. + * + * Deleting the resource resets the project's routing rules by staging and + * promoting an empty document. + * + * @resource + * @section Creating Routing Rules + * @example Redirect a legacy path + * ```typescript + * const routes = yield* Vercel.ProjectRoutes("Routes", { + * projectId: project.projectId, + * routes: [ + * { + * id: "legacy-blog", + * name: "Legacy blog redirect", + * route: { src: "/blog/old", dest: "/blog/new", status: 308 }, + * }, + * ], + * }); + * ``` + * + * @example Rewrite with a capture group + * ```typescript + * const routes = yield* Vercel.ProjectRoutes("Routes", { + * projectId: project.projectId, + * routes: [ + * { + * id: "docs-rewrite", + * name: "Serve docs from /documentation", + * route: { src: "/docs/(.*)", dest: "/documentation/$1" }, + * }, + * ], + * }); + * ``` + * + * @section Conditional Rules + * @example Match only when a header is present + * ```typescript + * const routes = yield* Vercel.ProjectRoutes("Routes", { + * projectId: project.projectId, + * routes: [ + * { + * id: "beta-gate", + * name: "Beta cookie gate", + * route: { + * src: "/beta/(.*)", + * dest: "/coming-soon", + * has: [{ type: "cookie", key: "beta", value: "off" }], + * }, + * }, + * ], + * }); + * ``` + * + * @example Disable a rule without deleting it + * ```typescript + * const routes = yield* Vercel.ProjectRoutes("Routes", { + * projectId: project.projectId, + * routes: [ + * { + * id: "maintenance", + * name: "Maintenance page", + * enabled: false, + * route: { src: "/(.*)", dest: "/maintenance", status: 307 }, + * }, + * ], + * }); + * ``` + * + * @see https://vercel.com/docs/edge-network/routing-rules + */ +export const ProjectRoutes = Resource("Vercel.ProjectRoutes"); + +export const ProjectRoutesProvider = () => + Provider.succeed(ProjectRoutes, { + stables: ["projectId"], + diff: Effect.fn(function* ({ olds, news, output }) { + if (!isResolved(news)) return undefined; + const oldProjectId = output?.projectId ?? olds?.projectId; + if (oldProjectId !== undefined && news.projectId !== oldProjectId) { + return { action: "replace" } as const; + } + return undefined; + }), + read: Effect.fn(function* ({ olds, output }) { + const projectId = output?.projectId ?? olds?.projectId; + if (projectId === undefined) return undefined; + const { teamId } = yield* VercelEnvironment.current; + return yield* projectRoutes.getRoutes({ projectId, teamId }).pipe( + Effect.map((doc) => + // `version: null` = no routes document has ever been staged for + // this project — the resource does not exist yet. + doc.version == null ? undefined : toAttributes(projectId, doc), + ), + // NotFound = the host project itself is gone. + Effect.catchTag("NotFound", () => Effect.succeed(undefined)), + ); + }), + reconcile: Effect.fn(function* ({ news }) { + const { teamId } = yield* VercelEnvironment.current; + const projectId = news.projectId; + // Observe — the staging slot shadows `getRoutes`, so only trust the + // read as production truth when no staging version exists. + const versions = yield* projectRoutes.getRouteVersions({ + projectId, + teamId, + }); + const hasStaging = versions.versions.some((v) => v.isStaging === true); + if (!hasStaging) { + const observed = yield* projectRoutes.getRoutes({ projectId, teamId }); + if ( + observed.version != null && + matchesDesired(news.routes, observed.routes) + ) { + return toAttributes(projectId, observed); + } + } + // Sync — stage the full desired document and promote it. A crash + // between the two calls leaves a staging version; the next reconcile + // observes `hasStaging`, restages, and promotes — converging. + const staged = yield* projectRoutes.stageRoutes({ + projectId, + teamId, + overwrite: true, + routes: news.routes.map(toRequestRoute), + }); + yield* projectRoutes.updateRouteVersions({ + projectId, + teamId, + id: staged.version.id, + action: "promote", + }); + const final = yield* projectRoutes.getRoutes({ projectId, teamId }); + return toAttributes(projectId, final, staged.version.id); + }), + delete: Effect.fn(function* ({ output }) { + const { teamId } = yield* VercelEnvironment.current; + // Reset = stage an empty document and promote it. Idempotent: an + // already-empty document just gains an empty version; a deleted host + // project surfaces as NotFound and is not an error. + yield* projectRoutes + .stageRoutes({ + projectId: output.projectId, + teamId, + overwrite: true, + routes: [], + }) + .pipe( + Effect.flatMap((staged) => + projectRoutes.updateRouteVersions({ + projectId: output.projectId, + teamId, + id: staged.version.id, + action: "promote", + }), + ), + Effect.asVoid, + Effect.catchTag("NotFound", () => Effect.void), + ); + }), + }); + +// ───────────────────────────────────────────────────────────────────────────── +// Document mapping & comparison helpers +// ───────────────────────────────────────────────────────────────────────────── + +const toRequestRoute = ( + rule: ProjectRouteRule, +): projectRoutes.StageRoutesRequestRoutesItem => ({ + id: rule.id, + name: rule.name, + description: rule.description, + enabled: rule.enabled, + route: { + src: rule.route.src, + dest: rule.route.dest, + headers: rule.route.headers, + caseSensitive: rule.route.caseSensitive, + status: rule.route.status, + has: rule.route.has, + missing: rule.route.missing, + transforms: rule.route.transforms, + respectOriginCacheControl: rule.route.respectOriginCacheControl, + }, +}); + +const toAttributes = ( + projectId: string, + doc: projectRoutes.GetRoutesResponse, + fallbackVersionId?: string, +): ProjectRoutesAttributes => ({ + projectId, + versionId: doc.version?.id ?? fallbackVersionId ?? "", + ruleCount: doc.routes.length, + routes: doc.routes.map((item) => ({ + id: item.id, + name: item.name, + enabled: item.enabled ?? true, + ...(item.routeType !== undefined ? { routeType: item.routeType } : {}), + })), +}); + +/** + * Deterministic stringify: object keys sorted, `undefined`/`null` members + * dropped, arrays kept in order — so a server-echoed document compares equal + * to our desired literal. + */ +const stableStringify = (value: unknown): string => { + if (value === null || typeof value !== "object") { + return JSON.stringify(value) ?? "null"; + } + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(",")}]`; + } + const entries = Object.entries(value as Record) + .filter(([, v]) => v !== undefined && v !== null) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`); + return `{${entries.join(",")}}`; +}; + +const emptyToUndefined = (value: string | undefined): string | undefined => + value === undefined || value === "" ? undefined : value; + +interface CanonicalRoute { + id: string; + name: string; + description?: string; + enabled: boolean; + route: { + src: string; + dest?: string; + headers?: unknown; + caseSensitive?: boolean; + status?: number; + has?: unknown; + missing?: unknown; + transforms?: unknown; + respectOriginCacheControl?: boolean; + }; +} + +const canonicalFromProps = (rule: ProjectRouteRule): CanonicalRoute => ({ + id: rule.id, + name: rule.name, + description: emptyToUndefined(rule.description), + enabled: rule.enabled ?? true, + route: { + src: rule.route.src, + dest: rule.route.dest, + headers: rule.route.headers, + caseSensitive: rule.route.caseSensitive === true ? true : undefined, + status: rule.route.status, + has: rule.route.has, + missing: rule.route.missing, + transforms: rule.route.transforms, + respectOriginCacheControl: + rule.route.respectOriginCacheControl === true ? true : undefined, + }, +}); + +const canonicalFromObserved = ( + item: projectRoutes.GetRoutesResponseRoutesItem, +): CanonicalRoute => ({ + id: item.id, + name: item.name, + description: emptyToUndefined(item.description), + enabled: item.enabled ?? true, + route: { + // The API compiles the user's pattern; `rawSrc`/`rawDest` echo the + // original input and are the comparison baseline when present. + src: item.rawSrc ?? item.route.src, + dest: item.rawDest ?? item.route.dest, + headers: item.route.headers, + caseSensitive: item.route.caseSensitive === true ? true : undefined, + status: item.route.status, + has: item.route.has, + missing: item.route.missing, + transforms: item.route.transforms, + respectOriginCacheControl: + item.route.respectOriginCacheControl === true ? true : undefined, + }, +}); + +const matchesDesired = ( + desired: ProjectRouteRule[], + observed: ReadonlyArray, +): boolean => + stableStringify(desired.map(canonicalFromProps)) === + stableStringify(observed.map(canonicalFromObserved)); diff --git a/packages/alchemy/src/Vercel/Sandboxes/SandboxDrive.ts b/packages/alchemy/src/Vercel/Sandboxes/SandboxDrive.ts new file mode 100644 index 0000000000..ebd20564e1 --- /dev/null +++ b/packages/alchemy/src/Vercel/Sandboxes/SandboxDrive.ts @@ -0,0 +1,188 @@ +import * as sandboxes from "@distilled.cloud/vercel/sandboxes"; +import * as Effect from "effect/Effect"; +import { Unowned } from "../../AdoptPolicy.ts"; +import { isResolved } from "../../Diff.ts"; +import { createPhysicalName } from "../../PhysicalName.ts"; +import * as Provider from "../../Provider.ts"; +import { Resource } from "../../Resource.ts"; +import { VercelEnvironment } from "../VercelEnvironment.ts"; +import type { Providers } from "../Providers.ts"; + +export interface SandboxDriveProps { + /** + * The project (ID or name) the drive belongs to. Changing the project + * replaces the drive. + */ + project: string; + /** + * Name of the drive, unique per project and URL-safe (alphanumeric, + * hyphens, underscores). If omitted, a unique name is generated from + * `${app}-${stage}-${id}`. Changing the name replaces the drive. + */ + name?: string; + /** + * Maximum drive size in bytes. Immutable — changing it replaces the + * drive. + * + * @default 100 GiB (Vercel's default) + */ + maxSizeBytes?: number; + /** + * Region where the drive is stored. Immutable — changing it replaces the + * drive. + * + * @default "iad1" + */ + region?: string; +} + +export type SandboxDrive = Resource< + "Vercel.SandboxDrive", + SandboxDriveProps, + { + /** The unique drive name within the project. */ + name: string; + /** ID of the project that owns the drive. */ + projectId: string; + /** The maximum drive size in bytes. */ + maxSizeBytes: number; + /** The region where the drive is stored. */ + region: string; + /** Timestamp (ms) when the drive was created. */ + createdAt: number; + /** Timestamp (ms) when the drive was last updated. */ + updatedAt: number; + }, + never, + Providers +>; + +type SandboxDriveAttributes = SandboxDrive["Attributes"]; + +/** + * A persistent Vercel Sandbox Drive — durable storage that can be mounted + * into Vercel Sandbox sessions. + * + * Drives are in **private beta**: on accounts without access every drive + * API call (including reads) fails with a typed `Forbidden` error + * ("Drives are in private beta…"). Drives have no update API — every prop + * is immutable and changes replace the drive. + * + * @resource + * @section Creating a Drive + * @example Drive with a generated name + * ```typescript + * const project = yield* Vercel.Project("Sandboxes", {}); + * const drive = yield* Vercel.SandboxDrive("Scratch", { + * project: project.projectId, + * }); + * ``` + * + * @example Drive with an explicit name and size + * ```typescript + * const drive = yield* Vercel.SandboxDrive("Cache", { + * project: project.projectId, + * name: "build-cache", + * maxSizeBytes: 10 * 1024 * 1024 * 1024, + * }); + * ``` + * + * @see https://vercel.com/docs/vercel-sandbox + */ +export const SandboxDrive = Resource("Vercel.SandboxDrive"); + +const toAttributes = (drive: sandboxes.Drive): SandboxDriveAttributes => ({ + name: drive.name, + projectId: drive.projectId, + maxSizeBytes: drive.maxSizeBytes, + region: drive.region, + createdAt: drive.createdAt, + updatedAt: drive.updatedAt, +}); + +const createDriveName = (id: string) => + createPhysicalName({ id, lowercase: true }); + +/** + * Observe a drive by exact name via the paginated list (there is no pure + * GET — `getOrCreateDrive` is an upsert and must not run during reads). + */ +const observeDrive = (projectId: string, name: string) => + Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + let cursor: string | undefined; + do { + const page = yield* sandboxes.listDrives({ + projectId, + limit: 100, + ...(cursor !== undefined ? { cursor } : {}), + teamId, + }); + const match = page.drives.find((d) => d.name === name); + if (match !== undefined) return match; + cursor = page.pagination.next ?? undefined; + } while (cursor !== undefined); + return undefined; + }); + +export const SandboxDriveProvider = () => + Provider.succeed(SandboxDrive, { + stables: ["name", "projectId", "region", "createdAt"], + diff: Effect.fn(function* ({ olds, news, output }) { + if (!isResolved(news)) return undefined; + if (!output) return undefined; + // Drives have no update API — every declared prop is immutable. + if ( + news.project !== olds.project || + (news.name !== undefined && news.name !== output.name) || + (news.maxSizeBytes !== undefined && + news.maxSizeBytes !== output.maxSizeBytes) || + (news.region !== undefined && news.region !== output.region) + ) { + return { action: "replace" } as const; + } + return undefined; + }), + read: Effect.fn(function* ({ id, olds, output }) { + const project = output?.projectId ?? olds?.project; + if (project === undefined) return undefined; + const name = output?.name ?? olds?.name ?? (yield* createDriveName(id)); + const observed = yield* observeDrive(project, name); + if (observed === undefined) return undefined; + const attrs = toAttributes(observed); + // Drives carry no ownership channel — gate takeover of an existing + // drive behind `--adopt`. + return output !== undefined ? attrs : Unowned(attrs); + }), + reconcile: Effect.fn(function* ({ id, news, output }) { + const { teamId } = yield* VercelEnvironment.current; + const name = news.name ?? output?.name ?? (yield* createDriveName(id)); + + // Observe — the drive may already exist (crash recovery, adoption). + const observed = yield* observeDrive(news.project, name); + if (observed !== undefined) return toAttributes(observed); + + // Ensure — `getOrCreateDrive` is a true upsert, so a concurrent + // create is absorbed by the API itself. + const created = yield* sandboxes.getOrCreateDrive({ + name, + projectId: news.project, + ...(news.maxSizeBytes !== undefined + ? { maxSizeBytes: news.maxSizeBytes } + : {}), + ...(news.region !== undefined ? { region: news.region } : {}), + teamId, + }); + return toAttributes(created.drive); + }), + delete: Effect.fn(function* ({ output }) { + const { teamId } = yield* VercelEnvironment.current; + yield* sandboxes + .deleteDrive({ + name: output.name, + projectId: output.projectId, + teamId, + }) + .pipe(Effect.catchTag("NotFound", () => Effect.void)); + }), + }); diff --git a/packages/alchemy/src/Vercel/Sandboxes/SandboxSnapshot.ts b/packages/alchemy/src/Vercel/Sandboxes/SandboxSnapshot.ts new file mode 100644 index 0000000000..17e27d3fcc --- /dev/null +++ b/packages/alchemy/src/Vercel/Sandboxes/SandboxSnapshot.ts @@ -0,0 +1,175 @@ +import * as sandboxes from "@distilled.cloud/vercel/sandboxes"; +import * as Effect from "effect/Effect"; +import { isResolved } from "../../Diff.ts"; +import * as Provider from "../../Provider.ts"; +import { Resource } from "../../Resource.ts"; +import { VercelEnvironment } from "../VercelEnvironment.ts"; +import type { Providers } from "../Providers.ts"; + +export interface SandboxSnapshotProps { + /** + * ID of the (running) sandbox session to snapshot (`sbx_…`). Changing the + * session replaces the snapshot. + */ + sessionId: string; + /** + * Number of milliseconds after which the snapshot expires and is deleted + * by the platform. `0` disables expiration. Immutable — changing it + * replaces the snapshot. + */ + expiration?: number; +} + +export type SandboxSnapshot = Resource< + "Vercel.SandboxSnapshot", + SandboxSnapshotProps, + { + /** The unique identifier of the snapshot (`snap_…`). */ + snapshotId: string; + /** ID of the session the snapshot was created from. */ + sourceSessionId: string; + /** The region where the snapshot is stored. */ + region: string | undefined; + /** The status of the snapshot (`created` | `failed`). */ + status: string; + /** The size of the snapshot in bytes. */ + sizeBytes: number; + /** Timestamp (ms) when the snapshot expires, if it has an expiration. */ + expiresAt: number | undefined; + /** Timestamp (ms) when the snapshot was created. */ + createdAt: number; + }, + never, + Providers +>; + +type SandboxSnapshotAttributes = SandboxSnapshot["Attributes"]; + +/** + * A snapshot of a Vercel Sandbox session's filesystem, usable to create new + * sandboxes from a captured state. + * + * The source session must be **running** when the snapshot is taken. + * Snapshots are immutable — every prop change replaces the snapshot — and a + * snapshot deleted (or expired) out-of-band is simply re-created from the + * session on the next deploy. + * + * @resource + * @section Snapshotting a session + * @example Snapshot a running sandbox session + * ```typescript + * const snapshot = yield* Vercel.SandboxSnapshot("Baseline", { + * sessionId: "sbx_abc123", + * }); + * ``` + * + * @example Snapshot without expiration + * ```typescript + * const snapshot = yield* Vercel.SandboxSnapshot("Golden", { + * sessionId: "sbx_abc123", + * expiration: 0, + * }); + * ``` + * + * @see https://vercel.com/docs/vercel-sandbox + */ +export const SandboxSnapshot = Resource( + "Vercel.SandboxSnapshot", +); + +const toAttributes = ( + snapshot: sandboxes.Snapshot, +): SandboxSnapshotAttributes => ({ + snapshotId: snapshot.id, + sourceSessionId: snapshot.sourceSessionId, + region: snapshot.region, + status: snapshot.status, + sizeBytes: snapshot.sizeBytes, + expiresAt: snapshot.expiresAt, + createdAt: snapshot.createdAt, +}); + +/** + * Observe a snapshot by id. A `deleted` snapshot is reported as missing — + * the platform soft-deletes and keeps the row visible. + */ +const observeSnapshot = (snapshotId: string) => + Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + const observed = yield* sandboxes + .getSessionSnapshot({ snapshotId, teamId }) + .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))); + if (observed === undefined || observed.snapshot.status === "deleted") { + return undefined; + } + return observed.snapshot; + }); + +export const SandboxSnapshotProvider = () => + Provider.succeed(SandboxSnapshot, { + stables: [ + "snapshotId", + "sourceSessionId", + "region", + "sizeBytes", + "createdAt", + ], + diff: Effect.fn(function* ({ olds, news, output }) { + if (!isResolved(news)) return undefined; + if (!output) return undefined; + // Snapshots are immutable captures — new session or expiration means + // a new snapshot. + if ( + news.sessionId !== olds.sessionId || + news.expiration !== olds.expiration + ) { + return { action: "replace" } as const; + } + return undefined; + }), + read: Effect.fn(function* ({ output }) { + // Snapshot ids are opaque — without prior state there is nothing to + // look up (and nothing to adopt). + if (output === undefined) return undefined; + const observed = yield* observeSnapshot(output.snapshotId); + return observed === undefined ? undefined : toAttributes(observed); + }), + reconcile: Effect.fn(function* ({ news, output }) { + const { teamId } = yield* VercelEnvironment.current; + + // Observe — `output` only caches the stable snapshot id; the + // snapshot may have expired or been deleted out-of-band. + if (output !== undefined) { + const observed = yield* observeSnapshot(output.snapshotId); + if (observed !== undefined) return toAttributes(observed); + } + + // Ensure — take a fresh snapshot of the (running) session. + const created = yield* sandboxes.createSessionSnapshot({ + sessionId: news.sessionId, + ...(news.expiration !== undefined + ? { expiration: news.expiration } + : {}), + teamId, + }); + return toAttributes(created.snapshot); + }), + delete: Effect.fn(function* ({ output }) { + const { teamId } = yield* VercelEnvironment.current; + yield* sandboxes + .deleteSessionSnapshot({ snapshotId: output.snapshotId, teamId }) + .pipe( + Effect.catchTag("NotFound", () => Effect.void), + // The API answers 400 ("Snapshot expired or deleted.") for a + // soft-deleted snapshot — trust observation: only propagate when + // the snapshot is genuinely still live. + Effect.catchTag("BadRequest", (error) => + Effect.gen(function* () { + const observed = yield* observeSnapshot(output.snapshotId); + if (observed === undefined) return; + return yield* Effect.fail(error); + }), + ), + ); + }), + }); diff --git a/packages/alchemy/src/Vercel/Sandboxes/index.ts b/packages/alchemy/src/Vercel/Sandboxes/index.ts new file mode 100644 index 0000000000..1a319102c0 --- /dev/null +++ b/packages/alchemy/src/Vercel/Sandboxes/index.ts @@ -0,0 +1,2 @@ +export * from "./SandboxDrive.ts"; +export * from "./SandboxSnapshot.ts"; diff --git a/packages/alchemy/src/Vercel/Security/FirewallConfig.ts b/packages/alchemy/src/Vercel/Security/FirewallConfig.ts new file mode 100644 index 0000000000..861b9ced73 --- /dev/null +++ b/packages/alchemy/src/Vercel/Security/FirewallConfig.ts @@ -0,0 +1,708 @@ +import * as security from "@distilled.cloud/vercel/security"; +import * as Effect from "effect/Effect"; +import { isResolved } from "../../Diff.ts"; +import * as Provider from "../../Provider.ts"; +import { Resource } from "../../Resource.ts"; +import type { Providers } from "../Providers.ts"; +import { VercelEnvironment } from "../VercelEnvironment.ts"; + +/** + * Parameter from the incoming traffic a rule condition matches against. + * + * @see https://vercel.com/docs/security/vercel-waf/rule-configuration#parameters + */ +export type FirewallConditionType = + | "host" + | "path" + | "method" + | "header" + | "query" + | "cookie" + | "target_path" + | "route" + | "raw_path" + | "ip_address" + | "region" + | "protocol" + | "scheme" + | "environment" + | "domain_environment" + | "user_agent" + | "geo_continent" + | "geo_country" + | "geo_country_region" + | "geo_city" + | "geo_as_number" + | "ja4_digest" + | "ja3_digest" + | "rate_limit_api_id" + | "server_action" + | "bot_name" + | "bot_category" + | "bot_status" + | "bot_protection" + | "ruleset"; + +/** + * Comparison operator applied to a condition's parameter. + * + * @see https://vercel.com/docs/security/vercel-waf/rule-configuration#operators + */ +export type FirewallConditionOp = + | "re" + | "eq" + | "neq" + | "ex" + | "nex" + | "inc" + | "ninc" + | "pre" + | "suf" + | "sub" + | "gt" + | "gte" + | "lt" + | "lte" + | "list"; + +/** Mitigation applied when a custom rule matches. */ +export type FirewallRuleAction = + | "log" + | "challenge" + | "deny" + | "bypass" + | "rate_limit" + | "redirect"; + +/** Action applied to traffic matching an IP blocking rule. */ +export type FirewallIpAction = "deny" | "challenge" | "log" | "bypass"; + +/** A single match condition within a condition group. */ +export interface FirewallCondition { + /** Request parameter to match on (e.g. `path`, `header`, `ip_address`). */ + type: FirewallConditionType; + /** Comparison operator (e.g. `eq`, `pre` for prefix, `re` for regex). */ + op: FirewallConditionOp; + /** + * Negate the condition. + * @default false + */ + neg?: boolean; + /** Sub-key for keyed parameters (e.g. the header or query-param name). */ + key?: string; + /** Value to compare against. */ + value?: string | string[] | number; +} + +/** A group of conditions AND-ed together. Groups themselves are OR-ed. */ +export interface FirewallConditionGroup { + conditions: FirewallCondition[]; +} + +/** Rate-limit settings for `action: "rate_limit"` rules. */ +export interface FirewallRateLimit { + /** Rate-limiting algorithm. */ + algo: "fixed_window" | "token_bucket"; + /** Window size in seconds. */ + window: number; + /** Maximum number of requests per window. */ + limit: number; + /** Request properties the limit is keyed by (e.g. `["ip"]`). */ + keys: string[]; + /** Action once the limit is exceeded. */ + action?: "log" | "challenge" | "deny" | "rate_limit"; +} + +/** Redirect settings for `action: "redirect"` rules. */ +export interface FirewallRedirect { + location: string; + permanent: boolean; +} + +/** The mitigation a custom rule applies when its conditions match. */ +export interface FirewallMitigate { + /** Base action applied to matching traffic. */ + action: FirewallRuleAction; + /** Rate-limit configuration (required when `action` is `rate_limit`). */ + rateLimit?: FirewallRateLimit; + /** Redirect configuration (required when `action` is `redirect`). */ + redirect?: FirewallRedirect; + /** Persist the action for this duration (e.g. `"1h"`) after a match. */ + actionDuration?: string; + /** Also bypass Vercel system protections for matching traffic. */ + bypassSystem?: boolean; +} + +/** A custom WAF rule. Rules are evaluated in array order. */ +export interface FirewallRule { + /** Display name of the rule. */ + name: string; + /** Optional description. */ + description?: string; + /** + * Whether the rule is active. + * @default true + */ + active?: boolean; + /** Condition groups — groups are OR-ed, conditions within a group AND-ed. */ + conditionGroup: FirewallConditionGroup[]; + /** Mitigation applied when the rule matches. */ + action: FirewallMitigate; +} + +/** An IP blocking rule. */ +export interface FirewallIpRule { + /** Hostname the rule applies to (`*` for all hosts on the project). */ + hostname: string; + /** IP address or CIDR block. */ + ip: string; + /** Optional operator notes. */ + notes?: string; + /** Action applied to matching traffic. */ + action: FirewallIpAction; +} + +/** State of one OWASP Core Ruleset category. */ +export interface FirewallCrsRule { + active: boolean; + action: "deny" | "log"; +} + +/** + * OWASP Core Ruleset (managed rules) configuration. Keys are Vercel's + * category codes: `sd` scanner detection, `ma` multipart attack, `lfi` + * local file inclusion, `rfi` remote file inclusion, `rce` remote code + * execution, `php` PHP attack, `gen` generic attack, `xss` XSS, `sqli` + * SQL injection, `sf` session fixation, `java` Java attack. + */ +export interface FirewallCrs { + sd?: FirewallCrsRule; + ma?: FirewallCrsRule; + lfi?: FirewallCrsRule; + rfi?: FirewallCrsRule; + rce?: FirewallCrsRule; + php?: FirewallCrsRule; + gen?: FirewallCrsRule; + xss?: FirewallCrsRule; + sqli?: FirewallCrsRule; + sf?: FirewallCrsRule; + java?: FirewallCrsRule; +} + +export interface FirewallConfigProps { + /** + * ID of the Vercel project the firewall configuration applies to. A + * project has exactly one firewall configuration; changing this property + * replaces the resource (the old project's configuration is reset). + */ + projectId: string; + /** + * Whether the firewall is enabled. + * @default true + */ + enabled?: boolean; + /** + * Custom WAF rules, evaluated in order. The configuration is a versioned + * document replaced in full on every change — rules not listed here are + * removed. + */ + rules?: FirewallRule[]; + /** + * IP blocking rules. Replaced in full on every change. + */ + ips?: FirewallIpRule[]; + /** + * OWASP Core Ruleset (managed rules) configuration. When omitted, the + * project's existing Core Ruleset settings are left untouched. + */ + crs?: FirewallCrs; + /** + * Enable Vercel BotID protection. When omitted, the existing setting is + * left untouched. + */ + botIdEnabled?: boolean; +} + +/** Summary of a deployed custom rule (server-assigned ID included). */ +export interface FirewallRuleAttribute { + id: string; + name: string; + active: boolean; +} + +/** Summary of a deployed IP blocking rule (server-assigned ID included). */ +export interface FirewallIpAttribute { + id: string; + hostname: string; + ip: string; + notes?: string; + action: FirewallIpAction; +} + +export type FirewallConfig = Resource< + "Vercel.FirewallConfig", + FirewallConfigProps, + { + /** ID of the project the configuration is attached to. */ + projectId: string; + /** Owner (team/user) ID reported by the API. */ + ownerId: string; + /** Server-assigned ID of the active configuration document. */ + configId: string; + /** + * Version of the active configuration document. Increments on every + * change (including out-of-band edits in the dashboard). + */ + version: number; + /** ISO timestamp of the last configuration change. */ + updatedAt: string; + /** Whether the firewall is enabled. */ + firewallEnabled: boolean; + /** Deployed custom rules with their server-assigned IDs. */ + rules: FirewallRuleAttribute[]; + /** Deployed IP blocking rules with their server-assigned IDs. */ + ips: FirewallIpAttribute[]; + }, + never, + Providers +>; + +type FirewallConfigAttributes = FirewallConfig["Attributes"]; + +/** + * The Vercel WAF (firewall) configuration of a project. + * + * The firewall configuration is a versioned singleton document per project: + * every change PUTs the full desired document and the platform increments + * the `version`. The provider reconciles by diffing the observed active + * configuration against the desired document and only writes on a delta. + * + * Deleting the resource resets the project's firewall to an empty, disabled + * configuration (the API's DELETE endpoint targets draft config versions, + * not the active document, so a full PUT of the empty document is the reset + * primitive). + * + * @resource + * @section Creating a Firewall Configuration + * @example Deny traffic to a path + * ```typescript + * const waf = yield* Vercel.FirewallConfig("Waf", { + * projectId: project.projectId, + * rules: [ + * { + * name: "block-admin", + * conditionGroup: [ + * { conditions: [{ type: "path", op: "pre", value: "/admin" }] }, + * ], + * action: { action: "deny" }, + * }, + * ], + * }); + * ``` + * + * @example Challenge suspicious traffic + * ```typescript + * const waf = yield* Vercel.FirewallConfig("Waf", { + * projectId: project.projectId, + * rules: [ + * { + * name: "challenge-bots", + * description: "Interstitial challenge for unknown bots", + * conditionGroup: [ + * { conditions: [{ type: "bot_status", op: "eq", value: "unverified" }] }, + * ], + * action: { action: "challenge" }, + * }, + * ], + * }); + * ``` + * + * @section IP Blocking + * @example Deny a CIDR block on every host + * ```typescript + * const waf = yield* Vercel.FirewallConfig("Waf", { + * projectId: project.projectId, + * ips: [{ hostname: "*", ip: "203.0.113.0/24", action: "deny" }], + * }); + * ``` + * + * @section Managed Rulesets + * @example Enable OWASP Core Ruleset categories + * ```typescript + * const waf = yield* Vercel.FirewallConfig("Waf", { + * projectId: project.projectId, + * crs: { + * sqli: { active: true, action: "deny" }, + * xss: { active: true, action: "deny" }, + * }, + * }); + * ``` + * + * @section Rate Limiting + * @example Rate limit an API route by IP + * ```typescript + * const waf = yield* Vercel.FirewallConfig("Waf", { + * projectId: project.projectId, + * rules: [ + * { + * name: "api-rate-limit", + * conditionGroup: [ + * { conditions: [{ type: "path", op: "pre", value: "/api" }] }, + * ], + * action: { + * action: "rate_limit", + * rateLimit: { + * algo: "fixed_window", + * window: 60, + * limit: 100, + * keys: ["ip"], + * action: "deny", + * }, + * }, + * }, + * ], + * }); + * ``` + * + * @see https://vercel.com/docs/security/vercel-waf + */ +export const FirewallConfig = Resource("Vercel.FirewallConfig"); + +export const FirewallConfigProvider = () => + Provider.succeed(FirewallConfig, { + stables: ["projectId", "ownerId", "configId"], + diff: Effect.fn(function* ({ olds, news, output }) { + if (!isResolved(news)) return undefined; + const oldProjectId = output?.projectId ?? olds?.projectId; + if (oldProjectId !== undefined && news.projectId !== oldProjectId) { + return { action: "replace" } as const; + } + return undefined; + }), + read: Effect.fn(function* ({ olds, output }) { + const projectId = output?.projectId ?? olds?.projectId; + if (projectId === undefined) return undefined; + const { teamId } = yield* VercelEnvironment.current; + return yield* security + .getFirewallConfig({ configVersion: "active", projectId, teamId }) + .pipe( + Effect.map((doc) => toAttributes(projectId, doc)), + // NotFound covers both "project gone" and "no firewall + // configuration has ever been written for this project". + Effect.catchTag("NotFound", () => Effect.succeed(undefined)), + ); + }), + reconcile: Effect.fn(function* ({ news }) { + const { teamId } = yield* VercelEnvironment.current; + const projectId = news.projectId; + // Observe — the active configuration document is the source of truth. + const observed = yield* security + .getFirewallConfig({ configVersion: "active", projectId, teamId }) + .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))); + // Sync — the whole document is the reconcile unit: diff observed vs + // desired and PUT the full replacement only on a delta. + if (observed !== undefined && matchesDesired(news, observed)) { + return toAttributes(projectId, observed); + } + const result = yield* security.putFirewallConfig({ + projectId, + teamId, + firewallEnabled: news.enabled ?? true, + rules: (news.rules ?? []).map(toRequestRule), + ips: (news.ips ?? []).map((ip) => ({ + hostname: ip.hostname, + ip: ip.ip, + notes: ip.notes, + action: ip.action, + })), + crs: news.crs, + botIdEnabled: news.botIdEnabled, + }); + return toAttributes(projectId, result.active); + }), + delete: Effect.fn(function* ({ output }) { + const { teamId } = yield* VercelEnvironment.current; + // The DELETE /v1/security/firewall/config/{configVersion} endpoint + // targets draft config versions (and its spec carries no projectId), + // so resetting the active document to empty+disabled via a full PUT + // is the delete primitive. Idempotent: PUT of the empty document + // converges regardless of prior state; a deleted host project + // surfaces as NotFound and is not an error. + yield* security + .putFirewallConfig({ + projectId: output.projectId, + teamId, + firewallEnabled: false, + rules: [], + ips: [], + }) + .pipe( + Effect.asVoid, + Effect.catchTag("NotFound", () => Effect.void), + ); + }), + }); + +// ───────────────────────────────────────────────────────────────────────────── +// Document mapping & comparison helpers +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Structural view of the active firewall configuration document. Both + * `GetFirewallConfigResponse` and `PutFirewallConfigResponse["active"]` + * satisfy this shape, so every helper below works on either without + * union-typed member access. + */ +interface RawCondition { + readonly type: string; + readonly op: string; + readonly neg?: boolean; + readonly key?: string; + readonly value?: unknown; +} + +interface RawMitigate { + readonly action?: string; + readonly rateLimit?: unknown; + readonly redirect?: unknown; + readonly actionDuration?: string | null; + readonly bypassSystem?: boolean | null; +} + +interface RawRule { + readonly id: string; + readonly name: string; + readonly description?: string; + readonly active: boolean; + readonly conditionGroup: ReadonlyArray<{ + readonly conditions: ReadonlyArray; + }>; + readonly action: { readonly mitigate?: RawMitigate }; +} + +interface RawIp { + readonly id: string; + readonly hostname: string; + readonly ip: string; + readonly notes?: string; + readonly action: string; +} + +interface RawDocument { + readonly ownerId: string; + readonly id: string; + readonly version: number; + readonly updatedAt: string; + readonly firewallEnabled: boolean; + readonly rules: ReadonlyArray; + readonly ips: ReadonlyArray; + readonly crs?: unknown; + readonly botIdEnabled?: boolean; +} + +const toRequestRule = ( + rule: FirewallRule, +): security.PutFirewallConfigRequestRulesItem => ({ + name: rule.name, + description: rule.description, + active: rule.active ?? true, + conditionGroup: rule.conditionGroup.map((group) => ({ + conditions: group.conditions.map((condition) => ({ + type: condition.type, + op: condition.op, + neg: condition.neg, + key: condition.key, + value: condition.value, + })), + })), + action: { + mitigate: { + action: rule.action.action, + rateLimit: rule.action.rateLimit, + redirect: rule.action.redirect, + actionDuration: rule.action.actionDuration, + bypassSystem: rule.action.bypassSystem, + }, + }, +}); + +const toAttributes = ( + projectId: string, + doc: RawDocument, +): FirewallConfigAttributes => ({ + projectId, + ownerId: doc.ownerId, + configId: doc.id, + version: doc.version, + updatedAt: doc.updatedAt, + firewallEnabled: doc.firewallEnabled, + rules: doc.rules.map((rule) => ({ + id: rule.id, + name: rule.name, + active: rule.active, + })), + ips: doc.ips.map((ip) => ({ + id: ip.id, + hostname: ip.hostname, + ip: ip.ip, + ...(ip.notes !== undefined ? { notes: ip.notes } : {}), + action: ip.action as FirewallIpAction, + })), +}); + +/** + * Deterministic stringify: object keys sorted, `undefined`/`null` members + * dropped, arrays kept in order — so a server-echoed document (arbitrary + * key order, explicit `null`s) compares equal to our desired literal. + */ +const stableStringify = (value: unknown): string => { + if (value === null || typeof value !== "object") { + return JSON.stringify(value) ?? "null"; + } + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(",")}]`; + } + const entries = Object.entries(value as Record) + .filter(([, v]) => v !== undefined && v !== null) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`); + return `{${entries.join(",")}}`; +}; + +interface CanonicalRule { + name: string; + description?: string; + active: boolean; + conditionGroup: { + conditions: { + type: string; + op: string; + neg?: boolean; + key?: string; + value?: unknown; + }[]; + }[]; + action: { + action?: string; + rateLimit?: unknown; + redirect?: unknown; + actionDuration?: string; + bypassSystem?: boolean; + }; +} + +const canonicalRuleFromProps = (rule: FirewallRule): CanonicalRule => ({ + name: rule.name, + description: emptyToUndefined(rule.description), + active: rule.active ?? true, + conditionGroup: rule.conditionGroup.map((group) => ({ + conditions: group.conditions.map((condition) => ({ + type: condition.type, + op: condition.op, + neg: condition.neg === true ? true : undefined, + key: emptyToUndefined(condition.key), + value: condition.value, + })), + })), + action: { + action: rule.action.action, + rateLimit: rule.action.rateLimit, + redirect: rule.action.redirect, + actionDuration: rule.action.actionDuration ?? undefined, + bypassSystem: rule.action.bypassSystem === true ? true : undefined, + }, +}); + +const canonicalRuleFromObserved = (rule: RawRule): CanonicalRule => ({ + name: rule.name, + description: emptyToUndefined(rule.description), + active: rule.active, + conditionGroup: rule.conditionGroup.map((group) => ({ + conditions: group.conditions.map((condition) => ({ + type: condition.type, + op: condition.op, + neg: condition.neg === true ? true : undefined, + key: emptyToUndefined(condition.key), + value: condition.value, + })), + })), + action: { + action: rule.action.mitigate?.action, + rateLimit: rule.action.mitigate?.rateLimit ?? undefined, + redirect: rule.action.mitigate?.redirect ?? undefined, + actionDuration: rule.action.mitigate?.actionDuration ?? undefined, + bypassSystem: + rule.action.mitigate?.bypassSystem === true ? true : undefined, + }, +}); + +const canonicalIp = (ip: { + hostname: string; + ip: string; + notes?: string; + action: string; +}) => ({ + hostname: ip.hostname, + ip: ip.ip, + notes: emptyToUndefined(ip.notes), + action: ip.action, +}); + +const CRS_KEYS = [ + "sd", + "ma", + "lfi", + "rfi", + "rce", + "php", + "gen", + "xss", + "sqli", + "sf", + "java", +] as const; + +const canonicalCrs = (crs: FirewallCrs | undefined) => + CRS_KEYS.map((key) => { + const entry = crs?.[key]; + return entry === undefined + ? undefined + : { key, active: entry.active, action: entry.action }; + }).filter((entry) => entry !== undefined); + +const emptyToUndefined = (value: string | undefined): string | undefined => + value === undefined || value === "" ? undefined : value; + +/** + * Compare the desired document (from props) against the observed active + * configuration. `crs` and `botIdEnabled` participate only when the user + * declared them — omitted means "leave the platform setting untouched". + */ +const matchesDesired = ( + news: FirewallConfigProps, + observed: RawDocument, +): boolean => { + if ((news.enabled ?? true) !== observed.firewallEnabled) return false; + const desiredRules = (news.rules ?? []).map(canonicalRuleFromProps); + const observedRules = observed.rules.map(canonicalRuleFromObserved); + if (stableStringify(desiredRules) !== stableStringify(observedRules)) { + return false; + } + const desiredIps = (news.ips ?? []).map(canonicalIp); + const observedIps = observed.ips.map((ip) => canonicalIp(ip)); + if (stableStringify(desiredIps) !== stableStringify(observedIps)) { + return false; + } + if (news.crs !== undefined) { + if ( + stableStringify(canonicalCrs(news.crs)) !== + stableStringify(canonicalCrs(observed.crs as FirewallCrs | undefined)) + ) { + return false; + } + } + if (news.botIdEnabled !== undefined) { + if (news.botIdEnabled !== (observed.botIdEnabled ?? false)) return false; + } + return true; +}; diff --git a/packages/alchemy/src/Vercel/StateStore/Api.ts b/packages/alchemy/src/Vercel/StateStore/Api.ts new file mode 100644 index 0000000000..9ea2272830 --- /dev/null +++ b/packages/alchemy/src/Vercel/StateStore/Api.ts @@ -0,0 +1,473 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Etag from "effect/unstable/http/Etag"; +import * as HttpPlatform from "effect/unstable/http/HttpPlatform"; +import * as HttpRouter from "effect/unstable/http/HttpRouter"; +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; +import * as HttpApiError from "effect/unstable/httpapi/HttpApiError"; +import { createHash, timingSafeEqual } from "node:crypto"; +import { RuntimeContext } from "../../RuntimeContext.ts"; +import { + BearerTokenValidator, + StateApi, + StateAuthLive, +} from "../../State/HttpStateApi.ts"; +import { BlobStore } from "../Blob/BlobStore.ts"; +import { ReadWriteBlob } from "../Blob/ReadWriteBlob.ts"; +import { ReadWriteBlobHttp } from "../Blob/ReadWriteBlobHttp.ts"; +import { Function } from "../Functions/Function.ts"; +import { FunctionEnvironment } from "../Functions/FunctionBridge.ts"; +import { decryptRow, encryptRow, importStateKey } from "./Codec.ts"; +import { + isFamilyMember, + latestOfFamily, + outputKey, + parseRowKey, + parseStackIndexKey, + pickLatestPerFamily, + revisionedKey, + revisionToken, + rowKey, + STACK_INDEX_PREFIX, + stackIndexKey, + stackOutputsPrefix, + stackRowsPrefix, + stagePrefix, +} from "./Keys.ts"; +import { STATE_ENCRYPTION_KEY_ENV, STATE_TOKEN_ENV } from "./Token.ts"; + +/** + * Deterministic name of the state-store Vercel project (and of its + * private Blob store). Deterministic so `loginWithVercel` can find the + * deployed store without any local state. Overridable via + * `ALCHEMY_VERCEL_STATE_PROJECT` (advanced — parallel stores / tests). + */ +export const STATE_STORE_PROJECT_NAME = + process.env.ALCHEMY_VERCEL_STATE_PROJECT ?? "alchemy-state"; + +/** + * Version of the deployed Vercel State Store function contract. + * + * Bump this whenever the wire format or runtime behaviour changes in a + * way an older deployed copy can no longer satisfy. Clients query + * `/version` on the deployed function and compare against this constant; + * a mismatch (or 404) triggers a redeploy via the bootstrap flow. + * + * v2: immutable revisioned row pathnames (`{base}@{rev}` + LIST-resolved + * latest) — restores read-after-write consistency over Vercel Blob's + * eventually-consistent overwrite/delete GETs. + */ +export const STATE_STORE_VERSION = 2 as const; + +/** + * The private Blob store holding one encrypted JSON blob per state row. + * Bound to the state Function through the `ReadWriteBlob` capability, + * which transparently connects the Function's project (injecting the + * data-plane token env) — the store itself is deployed by the same + * bootstrap stack. + */ +export const StateBlob = BlobStore("Store", { + name: STATE_STORE_PROJECT_NAME, + access: "private", +}); + +/** Content type stored with every state row (framed base64 text). */ +const ROW_CONTENT_TYPE = "text/plain"; + +/** Hard page cap for prefix listings (1000 rows per page). */ +const MAX_LIST_PAGES = 200; + +/** + * Constant-time string comparison (fixed-length sha256 digests close the + * per-char timing leak; the trailing `===` guards the astronomically + * unlikely digest collision). Mirrors the bridge's cron-secret check. + */ +const timingSafeStringEqual = (a: string, b: string): boolean => { + const digestA = createHash("sha256").update(a).digest(); + const digestB = createHash("sha256").update(b).digest(); + return timingSafeEqual(digestA, digestB) && a === b; +}; + +/** + * The state-store Vercel Function (DESIGN §13): implements the shared + * {@link StateApi} HttpApi contract — bearer auth via + * {@link BearerTokenValidator}, unauthenticated `/version` pinned to + * {@link STATE_STORE_VERSION} — over the private {@link StateBlob} + * store, with AES-CTR row encryption keyed from the + * `ALCHEMY_STATE_ENCRYPTION_KEY` project env var. Deployed by alchemy's + * own Vercel deploy engine from `Vercel.state()` / `alchemy vercel + * bootstrap`. + */ +export default class Api extends Function()( + "Api", + { + name: STATE_STORE_PROJECT_NAME, + main: import.meta.url, + }, + Effect.gen(function* () { + const blob = yield* ReadWriteBlob(StateBlob); + const env = yield* FunctionEnvironment; + + // The Blob capability's runtime ops are colored with RuntimeContext; + // inside the deployed Function that requirement is definitionally + // satisfied. + const runtime = Effect.provide(RuntimeContext.phantom); + + // Import the AES-CTR key once per instance, lazily on first use (the + // env record is empty at plan time, so nothing may read it eagerly). + const cryptoKey = yield* Effect.cached( + Effect.suspend(() => { + const hex = env[STATE_ENCRYPTION_KEY_ENV]; + if (hex === undefined || hex === "") { + return Effect.die( + new Error( + `Vercel state store: ${STATE_ENCRYPTION_KEY_ENV} is not set — ` + + "the bootstrap stack binds it as a project env var; redeploy via 'alchemy vercel bootstrap'.", + ), + ); + } + return importStateKey(hex); + }), + ); + + const bearerTokenValidator = Layer.succeed( + BearerTokenValidator, + BearerTokenValidator.of({ + validate: Effect.fn(function* (token) { + const expected = env[STATE_TOKEN_ENV]; + return !!expected && + timingSafeStringEqual(token.trim(), expected.trim()) + ? yield* Effect.void + : yield* new HttpApiError.Unauthorized(); + }), + }), + ); + + /** Every pathname under `prefix`, across pagination (bounded). */ + const listAll = Effect.fn(function* (prefix: string) { + const pathnames: string[] = []; + let cursor: string | undefined; + for (let page = 0; page < MAX_LIST_PAGES; page++) { + const result = yield* blob + .list({ prefix, limit: 1000, cursor }) + .pipe(runtime, Effect.orDie); + for (const row of result.blobs) pathnames.push(row.pathname); + if (!result.hasMore || result.cursor === undefined) break; + cursor = result.cursor; + } + return pathnames; + }); + + /** Read + decrypt one specific (already-resolved) pathname. */ + const readPathname = (pathname: string) => + blob.get(pathname).pipe( + Effect.flatMap((got) => got.text), + Effect.flatMap((framed) => + Effect.flatMap(cryptoKey, (key) => decryptRow(key, framed)), + ), + Effect.catchTag("Vercel.Blob.NotFound", () => + Effect.succeed(undefined), + ), + runtime, + Effect.orDie, + ); + + /** Every pathname in `base`'s row family, fresh from LIST. */ + const listFamily = Effect.fn(function* (base: string) { + const pathnames = yield* listAll(base); + return pathnames.filter((pathname) => isFamilyMember(base, pathname)); + }); + + /** + * Read a row family's current content; `undefined` when absent. + * + * Blob content GETs are eventually consistent for OVERWRITTEN or + * recreated pathnames, but prefix LISTs and never-overwritten + * pathnames are read-after-write consistent (live-verified). So: + * resolve the family's latest immutable revision via LIST, then GET + * it. A revision can vanish between the list and the get when a + * concurrent writer prunes it — re-resolve, bounded. + */ + const readRow = (base: string) => + Effect.gen(function* () { + for (let attempt = 0; attempt < 3; attempt++) { + const family = yield* listFamily(base); + const target = latestOfFamily(base, family); + if (target === undefined) return undefined; + const value = yield* readPathname(target); + if (value !== undefined) return value; + // Deleted between list and get (a newer writer pruned it, or + // the legacy base GET served a cached 404) — resolve again. + } + return undefined; + }); + + /** + * Encrypt + write a row as a NEW immutable revision, then prune + * superseded revisions (and the legacy unrevisioned row) + * best-effort. Readers always pick the highest revision, so a + * failed prune only costs storage. Never prunes revisions newer + * than ours — a concurrent writer may already have superseded us. + */ + const writeRow = (base: string, value: unknown) => + Effect.gen(function* () { + const target = yield* Effect.sync(() => + revisionedKey( + base, + revisionToken( + Date.now(), + Math.random().toString(36).slice(2, 10).padEnd(8, "0"), + ), + ), + ); + const framed = yield* Effect.flatMap(cryptoKey, (key) => + encryptRow(key, value), + ).pipe(Effect.orDie); + yield* blob + .put(target, framed, { contentType: ROW_CONTENT_TYPE }) + .pipe(runtime, Effect.orDie); + const family = yield* listFamily(base); + const superseded = family.filter( + (pathname) => + pathname === base || (pathname !== target && pathname < target), + ); + yield* removeRows(superseded).pipe(Effect.ignore); + }); + + /** Idempotent batch delete of specific pathnames. */ + const removeRows = (pathnames: readonly string[]) => + pathnames.length === 0 + ? Effect.void + : blob.del(pathnames).pipe(runtime, Effect.orDie); + + /** Delete a row family: every revision plus the legacy base row. */ + const removeRowFamily = (base: string) => + Effect.flatMap(listFamily(base), removeRows); + + /** Register a stack in the global index (idempotent overwrite). */ + const registerStack = (stack: string) => + blob + .put(stackIndexKey(stack), "1", { contentType: ROW_CONTENT_TYPE }) + .pipe(Effect.asVoid, runtime, Effect.orDie); + + const versionApi = HttpApiBuilder.group(StateApi, "version", (handlers) => + handlers.handle("getVersion", () => + Effect.succeed({ version: STATE_STORE_VERSION }).pipe( + Effect.withSpan("state_store.getVersion", { + attributes: { "alchemy.state_store.op": "getVersion" }, + }), + ), + ), + ); + + const stateApi = HttpApiBuilder.group(StateApi, "state", (handlers) => + handlers + .handle("listStacks", () => + listAll(STACK_INDEX_PREFIX).pipe( + Effect.map((pathnames) => + pathnames.flatMap((pathname) => { + const stack = parseStackIndexKey(pathname); + return stack === undefined ? [] : [stack]; + }), + ), + Effect.withSpan("state_store.listStacks", { + attributes: { "alchemy.state_store.op": "listStacks" }, + }), + ), + ) + .handle("listStages", ({ params }) => + listAll(stackRowsPrefix(params.stack)).pipe( + Effect.map((pathnames) => { + const stages = new Set(); + for (const pathname of pathnames) { + const parsed = parseRowKey(pathname); + if (parsed?.stack === params.stack) stages.add(parsed.stage); + } + return [...stages]; + }), + Effect.withSpan("state_store.listStages", { + attributes: { + "alchemy.state_store.op": "listStages", + "alchemy.state_store.stack": params.stack, + }, + }), + ), + ) + .handle("listResources", ({ params }) => + listAll(stagePrefix(params.stack, params.stage)).pipe( + Effect.map((pathnames) => { + // Revisions of one row all parse to the same fqn — dedupe. + const fqns = new Set(); + for (const pathname of pathnames) { + const parsed = parseRowKey(pathname); + if (parsed !== undefined) fqns.add(parsed.fqn); + } + return [...fqns]; + }), + Effect.withSpan("state_store.listResources", { + attributes: { + "alchemy.state_store.op": "listResources", + "alchemy.state_store.stack": params.stack, + "alchemy.state_store.stage": params.stage, + }, + }), + ), + ) + .handle("getState", ({ params }) => { + const fqn = decodeURIComponent(params.fqn); + return readRow(rowKey(params.stack, params.stage, fqn)).pipe( + Effect.withSpan("state_store.getState", { + attributes: { + "alchemy.state_store.op": "getState", + "alchemy.state_store.stack": params.stack, + "alchemy.state_store.stage": params.stage, + "alchemy.state_store.fqn": fqn, + }, + }), + ); + }) + .handle("setState", ({ params, payload }) => { + const fqn = decodeURIComponent(params.fqn); + return writeRow( + rowKey(params.stack, params.stage, fqn), + payload, + ).pipe( + Effect.andThen(registerStack(params.stack)), + Effect.map(() => payload), + Effect.withSpan("state_store.setState", { + attributes: { + "alchemy.state_store.op": "setState", + "alchemy.state_store.stack": params.stack, + "alchemy.state_store.stage": params.stage, + "alchemy.state_store.fqn": fqn, + }, + }), + ); + }) + .handle("deleteState", ({ params }) => { + const fqn = decodeURIComponent(params.fqn); + return removeRowFamily(rowKey(params.stack, params.stage, fqn)).pipe( + Effect.withSpan("state_store.deleteState", { + attributes: { + "alchemy.state_store.op": "deleteState", + "alchemy.state_store.stack": params.stack, + "alchemy.state_store.stage": params.stage, + "alchemy.state_store.fqn": fqn, + }, + }), + ); + }) + .handle("getReplacedResources", ({ params }) => + listAll(stagePrefix(params.stack, params.stage)).pipe( + // One read per row family (its latest revision) — older + // revisions and legacy rows are superseded content. + Effect.map(pickLatestPerFamily), + Effect.flatMap((pathnames) => + Effect.forEach(pathnames, readPathname, { + concurrency: 8, + }), + ), + Effect.map((rows) => + rows.filter( + (row): row is { status: string } => + typeof row === "object" && + row !== null && + (row as { status?: unknown }).status === "replaced", + ), + ), + Effect.withSpan("state_store.getReplacedResources", { + attributes: { + "alchemy.state_store.op": "getReplacedResources", + "alchemy.state_store.stack": params.stack, + "alchemy.state_store.stage": params.stage, + }, + }), + ), + ) + .handle("getStackOutput", ({ params }) => + readRow(outputKey(params.stack, params.stage)).pipe( + Effect.withSpan("state_store.getStackOutput", { + attributes: { + "alchemy.state_store.op": "getStackOutput", + "alchemy.state_store.stack": params.stack, + "alchemy.state_store.stage": params.stage, + }, + }), + ), + ) + .handle("setStackOutput", ({ params, payload }) => + writeRow(outputKey(params.stack, params.stage), payload).pipe( + Effect.andThen(registerStack(params.stack)), + Effect.map(() => payload), + Effect.withSpan("state_store.setStackOutput", { + attributes: { + "alchemy.state_store.op": "setStackOutput", + "alchemy.state_store.stack": params.stack, + "alchemy.state_store.stage": params.stage, + }, + }), + ), + ) + .handle("deleteStack", ({ params, query }) => + Effect.gen(function* () { + if (query.stage !== undefined) { + const rows = yield* listAll( + stagePrefix(params.stack, query.stage), + ); + const outputs = yield* listFamily( + outputKey(params.stack, query.stage), + ); + yield* removeRows([...rows, ...outputs]); + } else { + const rows = yield* listAll(stackRowsPrefix(params.stack)); + const outputs = yield* listAll(stackOutputsPrefix(params.stack)); + yield* removeRows([ + ...rows, + ...outputs, + stackIndexKey(params.stack), + ]); + } + }).pipe( + Effect.withSpan("state_store.deleteStack", { + attributes: { + "alchemy.state_store.op": "deleteStack", + "alchemy.state_store.stack": params.stack, + "alchemy.state_store.stage": query.stage ?? "", + "alchemy.state_store.scope": + query.stage === undefined ? "stack" : "stage", + }, + }), + ), + ), + ); + + return { + fetch: HttpApiBuilder.layer(StateApi).pipe( + Layer.provide(stateApi), + Layer.provide(versionApi), + Layer.provide(StateAuthLive), + Layer.provide(bearerTokenValidator), + // The state-store function never serves files, so HttpPlatform's + // file-response surface is stubbed (mirrors the Cloudflare store). + Layer.provide([Etag.layer, HttpPlatformStub, Path.layer]), + HttpRouter.toHttpEffect, + ), + }; + }).pipe(Effect.provide(ReadWriteBlobHttp)), +) {} + +/** + * Stub `HttpPlatform`: the state API never issues file responses, so + * both surface methods die if invoked. + */ +const HttpPlatformStub = Layer.succeed(HttpPlatform.HttpPlatform, { + platform: "web", + compression: { + algorithms: new Set(), + compressResponse: (response) => Effect.succeed(response), + }, + fileResponse: () => Effect.die("HttpPlatform.fileResponse not supported"), + fileWebResponse: () => + Effect.die("HttpPlatform.fileWebResponse not supported"), +}); diff --git a/packages/alchemy/src/Vercel/StateStore/Codec.ts b/packages/alchemy/src/Vercel/StateStore/Codec.ts new file mode 100644 index 0000000000..34b9ee8277 --- /dev/null +++ b/packages/alchemy/src/Vercel/StateStore/Codec.ts @@ -0,0 +1,118 @@ +import * as Effect from "effect/Effect"; + +/** + * AES-CTR row codec for the Vercel state store. + * + * Mirrors the Cloudflare state store's at-rest framing exactly (see + * `Cloudflare/StateStore/Store.ts`): each row is + * `base64(nonce || ciphertext)` where the nonce is a random 16-byte + * AES-CTR counter block. The key is 32 random bytes, hex-encoded, + * minted once at bootstrap (`Alchemy.Random`) and delivered to the + * deployed Function as a project env var. + * + * Kept as a leaf module (Web Crypto only — runs identically on the + * Vercel Node runtime and under bun in tests) so the codec is + * unit-testable without credentials. + */ + +/** AES-CTR counter block length. */ +export const NONCE_BYTES = 16; + +/** + * Allocate a `Uint8Array` over a fresh `ArrayBuffer` (not shared) so the + * buffer satisfies Web Crypto's `BufferSource` constraint under strict + * DOM typings. + */ +const allocBytes = (size: number): Uint8Array => + new Uint8Array(new ArrayBuffer(size)); + +/** Decode a hex string into bytes (2 hex chars per byte). */ +const hexToBytes = (hex: string): Uint8Array => { + const bytes = allocBytes(hex.length >>> 1); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +}; + +const bytesToBase64 = (bytes: Uint8Array): string => + Buffer.from(bytes).toString("base64"); + +const base64ToBytes = (base64: string): Uint8Array => { + const buf = Buffer.from(base64, "base64"); + const bytes = allocBytes(buf.byteLength); + bytes.set(buf); + return bytes; +}; + +/** Import the hex-encoded 256-bit key as an AES-CTR `CryptoKey`. */ +export const importStateKey = (hexKey: string): Effect.Effect => + Effect.tryPromise(() => + crypto.subtle.importKey( + "raw", + hexToBytes(hexKey), + { name: "AES-CTR" }, + false, + ["encrypt", "decrypt"], + ), + ).pipe(Effect.orDie); + +/** Encrypt a JSON-serializable row value into `base64(nonce || ct)`. */ +export const encryptRow = ( + key: CryptoKey, + value: unknown, +): Effect.Effect => + Effect.tryPromise(async () => { + const plaintext = new TextEncoder().encode(JSON.stringify(value)); + const counter = crypto.getRandomValues(allocBytes(NONCE_BYTES)); + const ciphertext = new Uint8Array( + await crypto.subtle.encrypt( + { name: "AES-CTR", counter, length: 64 }, + key, + plaintext, + ), + ); + const framed = allocBytes(NONCE_BYTES + ciphertext.byteLength); + framed.set(counter, 0); + framed.set(ciphertext, NONCE_BYTES); + return bytesToBase64(framed); + }).pipe(Effect.orDie); + +/** + * Decrypt a framed row back into its JSON value. A row that fails to + * decrypt (e.g. after an out-of-band key rotation) resolves to + * `undefined` instead of failing, so the engine reconciles from scratch + * rather than dying — mirrors the Cloudflare store's recovery behavior. + */ +export const decryptRow = ( + key: CryptoKey, + framed: string, +): Effect.Effect => + Effect.tryPromise(async () => { + const bytes = base64ToBytes(framed); + const counter = bytes.subarray(0, NONCE_BYTES); + const ciphertext = bytes.subarray(NONCE_BYTES); + let plaintext: ArrayBuffer; + try { + plaintext = await crypto.subtle.decrypt( + { name: "AES-CTR", counter, length: 64 }, + key, + ciphertext, + ); + } catch (error) { + console.error( + "Vercel state store: failed to decrypt row; returning undefined.", + error, + ); + return undefined; + } + try { + return JSON.parse(new TextDecoder().decode(plaintext)) as T; + } catch (error) { + console.error( + "Vercel state store: decrypted row is not valid JSON; returning undefined.", + error, + ); + return undefined; + } + }).pipe(Effect.orDie); diff --git a/packages/alchemy/src/Vercel/StateStore/CredentialsFile.ts b/packages/alchemy/src/Vercel/StateStore/CredentialsFile.ts new file mode 100644 index 0000000000..d38ec4b307 --- /dev/null +++ b/packages/alchemy/src/Vercel/StateStore/CredentialsFile.ts @@ -0,0 +1,38 @@ +import type { HttpStateStoreCredentials } from "../../State/HttpStateStore.ts"; + +/** + * Filename (under `~/.alchemy/credentials/{profile}/`) used to cache the + * Vercel-deployed HTTP state store's endpoint + bearer token. + * + * Kept in this leaf module (rather than `StateStore/State.ts`) so the + * Vercel auth provider can invalidate the cache without importing the + * heavy state-store module — mirrors + * `Cloudflare/StateStore/CredentialsFile.ts`. + */ +export const CREDENTIALS_FILE = "vercel-state"; + +/** + * On-disk shape of the cached Vercel state-store credentials. + * + * Extends the generic {@link HttpStateStoreCredentials} with the + * `teamId` the credentials were minted against (`undefined` = personal + * scope). The `url` encodes the team implicitly (the state project + * lives in one team), so a cache written while configured for team A + * keeps pointing at team A's store after re-authenticating against + * team B — persisting `teamId` lets `state()` detect the mismatch and + * re-derive. + */ +export interface StoredStateStoreCredentials extends HttpStateStoreCredentials { + /** Vercel team the `url`/`authToken` were minted against. */ + teamId?: string; +} + +/** + * `true` when cached state-store credentials must not be trusted for + * the current scope: minted against a different team (or personal + * scope) than the one now in use. + */ +export const isStateStoreCredentialsStale = ( + credentials: StoredStateStoreCredentials, + currentTeamId: string | undefined, +): boolean => (credentials.teamId ?? null) !== (currentTeamId ?? null); diff --git a/packages/alchemy/src/Vercel/StateStore/Keys.ts b/packages/alchemy/src/Vercel/StateStore/Keys.ts new file mode 100644 index 0000000000..13d600ad18 --- /dev/null +++ b/packages/alchemy/src/Vercel/StateStore/Keys.ts @@ -0,0 +1,171 @@ +/** + * Blob pathname scheme for the Vercel state store (DESIGN §13). + * + * One JSON blob per state row, addressed by URI-encoded segments so that + * stack/stage/fqn values containing `/` (FQNs always do) or other + * separator characters can never collide with the scheme's own + * delimiters: + * + * ``` + * r/{stackEnc}/{stageEnc}/{fqnEnc}@{rev} resource rows (revisioned) + * o/{stackEnc}/{stageEnc}@{rev} stack outputs (revisioned) + * s/{stackEnc} stack index (listStacks) + * ``` + * + * **Rows are immutable**: every write lands at a NEW `@{rev}` pathname + * and readers resolve the family's latest revision through a prefix + * LIST. Vercel Blob's content GETs are only eventually consistent when + * a pathname is overwritten or delete-then-recreated (live-measured: + * stale reads and cached 404s for many seconds — the StateStoreCycles + * chain caught this), while prefix LISTs and never-overwritten + * pathnames are read-after-write consistent (live-verified, + * processes/Vercel/PROBES.md). Revisioning therefore restores + * read-after-write for the store: LIST finds the newest revision, whose + * content URL has never been overwritten. Superseded revisions (and + * legacy unrevisioned rows from a v1 store) are pruned best-effort + * after each write; readers always take the lexicographic max, so a + * failed prune costs storage, never correctness. + * + * The `@` delimiter is unambiguous because every dynamic segment is + * URI-encoded (`@` → `%40`). Revision tokens are fixed-width + * zero-padded millis plus a random tiebreaker, so lexicographic order + * is timestamp order. + * + * `listStacks` / `listStages` / `list` are prefix lists over these + * paths. + * + * Kept as a leaf module (pure functions, zero imports) so both the + * deployed state Function and credential-free unit tests share the + * exact same scheme. + */ + +const enc = encodeURIComponent; +const dec = decodeURIComponent; + +/** Prefix for resource rows. */ +export const ROW_PREFIX = "r/"; + +/** Prefix for stack-output rows. */ +export const OUTPUT_PREFIX = "o/"; + +/** Prefix for stack-index rows. */ +export const STACK_INDEX_PREFIX = "s/"; + +/** Pathname of a single resource row. */ +export const rowKey = (stack: string, stage: string, fqn: string): string => + `${ROW_PREFIX}${enc(stack)}/${enc(stage)}/${enc(fqn)}`; + +/** Prefix matching every resource row in one `(stack, stage)`. */ +export const stagePrefix = (stack: string, stage: string): string => + `${ROW_PREFIX}${enc(stack)}/${enc(stage)}/`; + +/** Prefix matching every resource row in a stack (all stages). */ +export const stackRowsPrefix = (stack: string): string => + `${ROW_PREFIX}${enc(stack)}/`; + +/** Pathname of the stack-output row for `(stack, stage)`. */ +export const outputKey = (stack: string, stage: string): string => + `${OUTPUT_PREFIX}${enc(stack)}/${enc(stage)}`; + +/** Prefix matching every stack-output row of a stack. */ +export const stackOutputsPrefix = (stack: string): string => + `${OUTPUT_PREFIX}${enc(stack)}/`; + +/** Pathname of the stack-index row. */ +export const stackIndexKey = (stack: string): string => + `${STACK_INDEX_PREFIX}${enc(stack)}`; + +/** + * Revision delimiter. Every dynamic segment is URI-encoded (`@` → + * `%40`), so a literal `@` in a pathname always marks the revision + * suffix. + */ +export const REV_DELIMITER = "@"; + +/** + * A sortable revision token: fixed-width zero-padded epoch millis plus + * a caller-supplied random tiebreaker, so lexicographic order is + * timestamp order (ties broken arbitrarily but deterministically). + */ +export const revisionToken = (epochMillis: number, random: string): string => + `${String(epochMillis).padStart(14, "0")}-${random}`; + +/** Pathname of one immutable revision of a row family. */ +export const revisionedKey = (base: string, rev: string): string => + `${base}${REV_DELIMITER}${rev}`; + +/** The family base of a (possibly revisioned) pathname. */ +export const familyBaseOf = (pathname: string): string => { + const at = pathname.indexOf(REV_DELIMITER); + return at === -1 ? pathname : pathname.slice(0, at); +}; + +/** Does `pathname` belong to `base`'s family (legacy base or a revision)? */ +export const isFamilyMember = (base: string, pathname: string): boolean => + pathname === base || pathname.startsWith(base + REV_DELIMITER); + +/** + * Pick the pathname holding the family's current content: the highest + * revision, else the legacy unrevisioned base, else `undefined`. + */ +export const latestOfFamily = ( + base: string, + family: readonly string[], +): string | undefined => { + let latest: string | undefined; + let hasLegacy = false; + for (const pathname of family) { + if (!isFamilyMember(base, pathname)) continue; + if (pathname === base) { + hasLegacy = true; + } else if (latest === undefined || pathname > latest) { + latest = pathname; + } + } + return latest ?? (hasLegacy ? base : undefined); +}; + +/** + * Group pathnames into row families and pick each family's latest + * revision (legacy base rows lose to any revision). + */ +export const pickLatestPerFamily = (pathnames: readonly string[]): string[] => { + const byBase = new Map(); + for (const pathname of pathnames) { + const base = familyBaseOf(pathname); + const family = byBase.get(base); + if (family === undefined) byBase.set(base, [pathname]); + else family.push(pathname); + } + const latest: string[] = []; + for (const [base, family] of byBase) { + const pick = latestOfFamily(base, family); + if (pick !== undefined) latest.push(pick); + } + return latest; +}; + +/** + * Parse a resource-row pathname back into its decoded + * `(stack, stage, fqn)` tuple; `undefined` for foreign pathnames. + * Revision suffixes are stripped, so every revision of one row parses + * to the same tuple. + */ +export const parseRowKey = ( + pathname: string, +): { stack: string; stage: string; fqn: string } | undefined => { + if (!pathname.startsWith(ROW_PREFIX)) return undefined; + const parts = familyBaseOf(pathname).slice(ROW_PREFIX.length).split("/"); + if (parts.length !== 3) return undefined; + const [stack, stage, fqn] = parts; + if (stack === "" || stage === "" || fqn === "") return undefined; + return { stack: dec(stack!), stage: dec(stage!), fqn: dec(fqn!) }; +}; + +/** Parse a stack-index pathname back into the decoded stack name. */ +export const parseStackIndexKey = (pathname: string): string | undefined => + pathname.startsWith(STACK_INDEX_PREFIX) && + pathname.length > STACK_INDEX_PREFIX.length && + !pathname.slice(STACK_INDEX_PREFIX.length).includes("/") + ? dec(pathname.slice(STACK_INDEX_PREFIX.length)) + : undefined; diff --git a/packages/alchemy/src/Vercel/StateStore/State.ts b/packages/alchemy/src/Vercel/StateStore/State.ts new file mode 100644 index 0000000000..ead9cd6319 --- /dev/null +++ b/packages/alchemy/src/Vercel/StateStore/State.ts @@ -0,0 +1,1161 @@ +import * as projects from "@distilled.cloud/vercel/projects"; +import { + createStorageStoreConnection, + deleteStorageStoreConnection, + deleteStorageStoresBlobById, + getStorageStoreConnections, + getStorageStores, +} from "@distilled.cloud/vercel/storage"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; +import * as Schedule from "effect/Schedule"; +import { isHttpClientError } from "effect/unstable/http/HttpClientError"; +import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; + +import { adopt } from "../../AdoptPolicy.ts"; +import { AlchemyContext } from "../../AlchemyContext.ts"; +import { AuthError } from "../../Auth/AuthProvider.ts"; +import { CredentialsStore } from "../../Auth/Credentials.ts"; +import { ALCHEMY_PROFILE } from "../../Auth/Profile.ts"; +import { deploy } from "../../Deploy.ts"; +import * as Output from "../../Output.ts"; +import { RandomProvider } from "../../Random.ts"; +import * as Alchemy from "../../Stack.ts"; +import { StateApi } from "../../State/HttpStateApi.ts"; +import { + checkHttpStateStoreAuth, + makeHttpStateStore, + type HttpStateStoreCredentials, +} from "../../State/HttpStateStore.ts"; +import { makeLocalState } from "../../State/LocalState.ts"; +import { State, type StateService } from "../../State/State.ts"; +import { + recordStateStoreInit, + recordStateStoreOp, +} from "../../Telemetry/Metrics.ts"; +import * as Clank from "../../Util/Clank.ts"; +import { + DEFAULT_BLOB_API_URL, + deleteBlobsRaw, + listBlobsRaw, + type BlobScope, +} from "../Blob/BlobHttp.ts"; +import type { BlobStoreAccess } from "../Blob/BlobStore.ts"; +import { BLOB_TOKEN_ENV } from "../Blob/BlobTypes.ts"; +import { + deleteProjectByIdOrName, + ensureProject, + observeProject, + readAssignedProductionUrl, + readProductionUrl, +} from "../Deploy/Engine.ts"; +import * as VercelProviders from "../Providers.ts"; +import { VercelEnvironment } from "../VercelEnvironment.ts"; +import Api, { STATE_STORE_PROJECT_NAME, STATE_STORE_VERSION } from "./Api.ts"; +import { + CREDENTIALS_FILE, + isStateStoreCredentialsStale, + type StoredStateStoreCredentials, +} from "./CredentialsFile.ts"; +import { + EncryptionKeyValue, + STATE_ENCRYPTION_KEY_ENV, + STATE_TOKEN_ENV, + TokenValue, +} from "./Token.ts"; + +const CI = Config.boolean("CI").pipe(Config.withDefault(false)); + +/** Alchemy stack name of the self-deployed state store. */ +export const STATE_STORE_STACK = "VercelStateStore" as const; + +/** + * Vercel-hosted state store: a self-deployed Vercel Function + * (`alchemy-state`) implementing the shared HTTP state contract over a + * private Blob store (one AES-CTR-encrypted JSON blob per state row). + * + * On first use the layer deploys the store via alchemy's own Vercel + * deploy engine (prompting, or automatically with `--yes` / in CI via + * `alchemy vercel bootstrap`), caches `{ url, authToken }` under + * `~/.alchemy/credentials/{profile}/vercel-state.json`, and detects + * contract drift through the store's unauthenticated `/version` probe. + * If the local credentials cache is lost, the bearer token is recovered + * out-of-band from the state project's `encrypted` + * `ALCHEMY_STATE_TOKEN` env var via the management API. + * + * @resource + * + * @section Using the Vercel State Store + * Pass `Vercel.state()` as the `state` option of a Stack. + * + * @example Stack backed by the Vercel state store + * ```typescript + * import * as Alchemy from "alchemy"; + * import * as Vercel from "alchemy/Vercel"; + * import * as Effect from "effect/Effect"; + * + * export default Alchemy.Stack( + * "my-stack", + * { providers: Vercel.providers(), state: Vercel.state() }, + * Effect.gen(function* () { + * // ... + * }), + * ); + * ``` + * + * @section Bootstrapping from CI + * The interactive deploy prompt only appears in local runs. In CI, + * deploy or upgrade the store explicitly (or pass `--yes` to deploy): + * + * @example Deploy the store non-interactively + * ```sh + * alchemy vercel bootstrap --profile ci + * ``` + */ +export const state = () => + Layer.effect( + State, + Effect.gen(function* () { + const isCI = yield* CI; + const projectName = STATE_STORE_PROJECT_NAME; + const profileName = yield* ALCHEMY_PROFILE; + const localStage = `${profileName}_${projectName}`; + const credStore = yield* CredentialsStore; + // `deploy --yes` flows in here (via AlchemyContext.updateStateStore) to + // auto-accept an out-of-date state store upgrade instead of prompting. + const autoUpdateStateStore = + Option.getOrUndefined(yield* Effect.serviceOption(AlchemyContext)) + ?.updateStateStore ?? false; + const context = yield* Effect.context>(); + + const init = Effect.gen(function* () { + if (yield* hasLocalStack(localStage)) { + // A local bootstrap stack still exists — the previous bootstrap + // never finished hoisting; resume it. + return yield* deployWithLocalState({ + projectName, + profileName, + isCI, + force: false, + }); + } + + const ensureLatest = ({ + url, + authToken, + }: { + url: string; + authToken: string; + }) => + Effect.gen(function* () { + const { matches, expected, observed } = + yield* checkStateStoreVersion(url); + + if (observed === undefined) { + const shouldDeploy = + autoUpdateStateStore || + (yield* Clank.confirm({ + message: `Vercel State Store '${projectName}' is not available. Do you want to deploy it?`, + })); + if (shouldDeploy) { + return yield* bootstrap({ profile: profileName }); + } else { + return yield* Effect.die(new Clank.PromptCancelled()); + } + } + + const httpState = yield* ensureAccess({ url, authToken }); + if (matches) { + return httpState; + } + + // The store is out of date. Upgrade it in place. + const upgrade = Effect.gen(function* () { + yield* Clank.info( + `Vercel State Store '${projectName}' is out of date ` + + `(expected v${expected}, observed v${observed ?? "unknown"}); upgrading...`, + ); + const stateStoreOptions = yield* deployStateStore({ + stage: projectName, + profileName, + state: httpState, + force: false, + }); + return yield* makeVercelStateStore(stateStoreOptions); + }); + + if (autoUpdateStateStore) { + return yield* upgrade; + } else if (isCI) { + return yield* Effect.die( + new AuthError({ + message: + `Vercel State store is out of date ` + + `(expected v${expected}, observed v${observed ?? "unknown"}). ` + + `Run 'alchemy vercel bootstrap --profile ' to upgrade it first, or pass --yes.`, + }), + ); + } else { + const shouldDeploy = yield* Clank.confirm({ + message: + `Vercel State Store '${projectName}' is out of date ` + + `(expected v${expected}, observed v${observed ?? "unknown"})`, + }); + if (shouldDeploy) { + return yield* upgrade; + } else { + return yield* Effect.die(new Clank.PromptCancelled()); + } + } + }); + + const ensureAccess = (credentials: HttpStateStoreCredentials) => + Effect.gen(function* () { + const isAuth = yield* checkHttpStateStoreAuth(credentials); + if (!isAuth) { + // our token is wrong, force a refresh + yield* Clank.info( + `Vercel State store authentication failed, refreshing credentials...`, + ); + const refreshed = yield* loginWithVercel(profileName, true); + if (!(yield* checkHttpStateStoreAuth(refreshed))) { + return yield* Effect.die( + new AuthError({ + message: `Vercel State store authentication failed, after refreshing credentials.`, + }), + ); + } + return yield* makeVercelStateStore(refreshed); + } + return yield* makeVercelStateStore(credentials); + }); + + const { teamId } = yield* VercelEnvironment.current; + + const credentials = yield* credStore.read( + profileName, + CREDENTIALS_FILE, + ); + if (credentials) { + // The cached `url`/`authToken` are minted per-team. If the active + // team changed since they were written, trusting the cache would + // silently read/write state in the wrong team — discard and + // re-derive from the current scope. + if (isStateStoreCredentialsStale(credentials, teamId)) { + yield* Clank.info( + `Vercel State Store credentials were minted for a different ` + + `Vercel team; re-deriving for the current scope.`, + ); + yield* credStore + .delete(profileName, CREDENTIALS_FILE) + .pipe(Effect.ignore); + } else { + return yield* ensureLatest(credentials); + } + } + if (yield* isStateStoreServing()) { + return yield* ensureLatest( + yield* loginWithVercel(profileName, false), + ); + } else if (autoUpdateStateStore) { + // `--yes`: deploy the missing state store automatically (also CI). + return yield* bootstrap({ profile: profileName }); + } else if (isCI) { + return yield* Effect.die( + new AuthError({ + message: `Vercel State store not found. Run 'alchemy vercel bootstrap --profile ' to deploy it first, or pass --yes.`, + }), + ); + } else { + return yield* Clank.confirm({ + message: "Vercel State Store not found. Do you want to deploy it?", + }).pipe( + Effect.flatMap((shouldDeploy) => + shouldDeploy + ? bootstrap({ profile: profileName }) + : Effect.die(new Clank.PromptCancelled()), + ), + ); + } + }).pipe(recordStateStoreInit, Effect.orDie); + + return yield* Effect.cached(init.pipe(Effect.provideContext(context))); + }), + ).pipe( + // The Vercel API foundation shared with `providers()` — credentials, + // environment (team scope), auth, profile + credential store, HTTP + // client. `provide` (not `provideMerge`) so the foundation stays out + // of this layer's public type. + Layer.provide(VercelProviders.VercelApiLive()), + Layer.orDie, + ); + +export interface BootstrapOptions { + /** + * Override the state-store project name. Advanced: the deployed + * Function's project name is pinned to + * {@link STATE_STORE_PROJECT_NAME} (set `ALCHEMY_VERCEL_STATE_PROJECT` + * to change both consistently). + * @default "alchemy-state" + */ + projectName?: string; + /** @default false */ + force?: boolean; + /** @default "default" */ + profile?: string; +} + +/** + * Deploy (or upgrade) the Vercel state store: the `alchemy vercel + * bootstrap` entrypoint. Non-interactive — resumes an interrupted + * bootstrap, adopts an already-serving store (refreshing credentials), + * and otherwise deploys with local state and hoists it into the newly + * deployed store. + */ +export const bootstrap = (options: BootstrapOptions = {}) => + Effect.gen(function* () { + const isCI = yield* CI; + const profileName = options.profile ?? (yield* ALCHEMY_PROFILE); + const projectName = options.projectName ?? STATE_STORE_PROJECT_NAME; + const force = options.force ?? false; + const localStage = `${profileName}_${projectName}`; + yield* Effect.annotateCurrentSpan({ + "alchemy.state_store.project_name": projectName, + "alchemy.state_store.profile": profileName, + "alchemy.state_store.force": force, + "alchemy.state_store.ci": isCI, + }); + + if (yield* hasLocalStack(localStage)) { + // A local stack that wasn't hoisted — finish the bootstrap. + yield* Clank.info( + `Resuming Vercel State Store '${projectName}' deployment...`, + ); + return yield* deployWithLocalState({ + projectName, + profileName, + isCI, + force, + }).pipe( + Effect.tap(() => + Clank.success(`Vercel State Store '${projectName}' is ready.`), + ), + ); + } + if (yield* isStateStoreServing()) { + // Regular update: check version drift and refresh credentials. + if (!force) { + yield* Clank.info( + `Vercel project '${projectName}' already serves a state store; ` + + `adopting and refreshing credentials. Use --force to redeploy.`, + ); + } + const { url, authToken } = yield* loginWithVercel(profileName, true); + const { matches, expected, observed } = + yield* checkStateStoreVersion(url); + const httpState = yield* makeVercelStateStore({ url, authToken }); + if (!matches || force) { + if (matches && force) { + yield* Clank.info( + `Vercel State Store '${projectName}' is up to date; force redeploying...`, + ); + } else { + yield* Clank.info( + `Vercel State Store '${projectName}' is out of date ` + + `(expected v${expected}, observed v${observed ?? "unknown"}); redeploying...`, + ); + } + return yield* makeVercelStateStore( + yield* deployStateStore({ + stage: projectName, + profileName, + state: httpState, + force, + }), + ); + } else { + return httpState; + } + } else { + yield* Clank.info(`Deploying Vercel State Store '${projectName}'...`); + return yield* deployWithLocalState({ + projectName, + profileName, + isCI, + force, + }).pipe( + Effect.tap(() => + Clank.success(`Vercel State Store '${projectName}' is ready.`), + ), + ); + } + }).pipe( + Effect.withSpan("state_store.bootstrap", { + attributes: { + "alchemy.state_store.op": "bootstrap", + "alchemy.state_store.project_name": + options.projectName ?? STATE_STORE_PROJECT_NAME, + }, + }), + ); + +export interface TeardownOptions { + /** @default "alchemy-state" */ + projectName?: string; + /** @default "default" */ + profile?: string; +} + +/** + * The inverse of {@link bootstrap}: tear down the Vercel-deployed state + * store. Deletes the state-store project (and with it every deployment + * and the injected env vars), disconnects and deletes the private Blob + * store (and with it every state row), and drops the locally cached + * state-store credentials plus any leftover local bootstrap state. + * + * Idempotent — missing resources are treated as already-gone. + */ +export const teardownStateStore = (options: TeardownOptions = {}) => + Effect.gen(function* () { + const profileName = options.profile ?? (yield* ALCHEMY_PROFILE); + const projectName = options.projectName ?? STATE_STORE_PROJECT_NAME; + const { teamId } = yield* VercelEnvironment.current; + + yield* Effect.annotateCurrentSpan({ + "alchemy.state_store.project_name": projectName, + "alchemy.state_store.profile": profileName, + }); + + // 1. Drop cached credentials + any leftover local bootstrap stack + // FIRST: a cloud-side failure below must never leave a stale local + // bootstrap stack behind (a later bootstrap would "resume" it and + // plan noops against resources this teardown deletes). + const credStore = yield* CredentialsStore; + yield* credStore.delete(profileName, CREDENTIALS_FILE).pipe(Effect.ignore); + const localState = yield* makeLocalState(); + yield* localState + .deleteStack({ + stack: STATE_STORE_STACK, + stage: `${profileName}_${projectName}`, + }) + .pipe(Effect.ignore); + + // 2. Empty + delete the private Blob store BEFORE deleting the + // project: Vercel refuses to delete a non-empty store (409 + // `not_empty`), and purging blobs needs the data-plane token that + // only a project connection injects — harvested through the state + // project while it still exists. + const { stores } = yield* getStorageStores({ teamId }); + const store = stores.find( + (row) => row.type === "blob" && row.name === projectName, + ); + if (store !== undefined) { + yield* Clank.info( + `Purging and deleting state store blob store '${store.id}'...`, + ); + yield* purgeAndDeleteStore({ + storeId: store.id, + access: store.access as BlobStoreAccess, + projectName, + }); + } + + // 3. Delete the state-store project (deployments + env rows go with it). + const project = yield* observeProject(projectName); + if (project !== undefined) { + yield* Clank.info(`Deleting state store project '${projectName}'...`); + yield* deleteProjectByIdOrName(project.id); + } else { + yield* Clank.info(` Project '${projectName}' not found (already gone).`); + } + + yield* Clank.success(`Vercel State Store '${projectName}' torn down.`); + }).pipe( + Effect.withSpan("state_store.teardown", { + attributes: { + "alchemy.state_store.op": "teardown", + "alchemy.state_store.project_name": + options.projectName ?? STATE_STORE_PROJECT_NAME, + }, + }), + ); + +/** + * Read a project's platform-injected `BLOB_READ_WRITE_TOKEN` via the + * management API's single-env GET (the injected row is `encrypted`, so + * the GET returns the plaintext). Bounded retry — injection can lag the + * connect call by a moment. + */ +const readBlobTokenFromProject = (projectId: string) => + Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + const envsBody = yield* projects.filterProjectEnvs({ + idOrName: projectId, + teamId, + }); + const rows = ( + Array.isArray(envsBody) + ? envsBody + : typeof envsBody === "object" && + envsBody !== null && + "envs" in envsBody + ? (envsBody as { envs: unknown[] }).envs + : [] + ) as Array<{ key?: string; id?: string }>; + const tokenRow = rows.find((row) => row.key === BLOB_TOKEN_ENV); + if (tokenRow?.id === undefined) return undefined; + const decrypted = yield* projects.getProjectEnv({ + idOrName: projectId, + id: tokenRow.id, + teamId, + }); + return typeof decrypted === "object" && + decrypted !== null && + "value" in decrypted && + typeof decrypted.value === "string" && + decrypted.value !== "" + ? decrypted.value + : undefined; + }); + +class BlobTokenNotInjected extends Error { + readonly _tag = "BlobTokenNotInjected"; +} + +/** + * Empty a state blob store via the data plane, then disconnect every + * project and delete the store. The data-plane token is harvested + * through the state project's connection (re-creating it if needed), + * falling back to a throwaway `-reaper` project when the state project + * is already gone. + */ +const purgeAndDeleteStore = ({ + storeId, + access, + projectName, +}: { + storeId: string; + access: BlobStoreAccess; + projectName: string; +}) => + Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + + // 1. A project to harvest the injected data-plane token from. + let project = yield* observeProject(projectName); + let reaper = false; + if (project === undefined) { + reaper = true; + project = yield* ensureProject({ name: `${projectName}-reaper` }); + } + + // 2. Ensure the project is connected (the connect injects the token). + const observed = yield* getStorageStoreConnections({ + storeId, + teamId, + }).pipe( + Effect.catchTag("NotFound", () => + Effect.succeed({ + connections: [] as Array<{ id: string; projectId: string }>, + }), + ), + ); + if ( + !observed.connections.some( + (connection) => connection.projectId === project.id, + ) + ) { + yield* createStorageStoreConnection({ + storeId, + projectId: project.id, + envVarEnvironments: ["production", "preview", "development"], + type: "integration", + teamId, + }).pipe( + // Already-connected race (`store_project_connection_not_unique`): + // trust observation. + Effect.catchTag("BadRequest", (error) => + getStorageStoreConnections({ storeId, teamId }).pipe( + Effect.flatMap((after) => + after.connections.some( + (connection) => connection.projectId === project.id, + ) + ? Effect.void + : Effect.fail(error), + ), + ), + ), + ); + } + + // 3. Read the injected token (bounded retry — injection can lag). + const token = yield* readBlobTokenFromProject(project.id).pipe( + Effect.flatMap((value) => + value === undefined + ? Effect.fail(new BlobTokenNotInjected()) + : Effect.succeed(value), + ), + Effect.retry({ + while: (error) => error instanceof BlobTokenNotInjected, + schedule: Schedule.max([ + Schedule.spaced("1 second"), + Schedule.recurs(15), + ]), + }), + ); + + // 4. Purge every blob (batched by page; bounded). + const scope: BlobScope = { + token: Redacted.make(token), + storeId, + access, + apiUrl: DEFAULT_BLOB_API_URL, + }; + for (let page = 0; page < 100; page++) { + const listed = yield* listBlobsRaw(scope, { limit: 1000 }); + if (listed.blobs.length === 0) break; + // Delete by canonical URL so re-encoding pathnames can't mismatch. + yield* deleteBlobsRaw( + scope, + listed.blobs.map((row) => row.url), + ); + if (!listed.hasMore) break; + } + + // 5. Disconnect everything and delete the store. + const remaining = yield* getStorageStoreConnections({ + storeId, + teamId, + }).pipe( + Effect.catchTag("NotFound", () => + Effect.succeed({ + connections: [] as Array<{ id: string; projectId: string }>, + }), + ), + ); + for (const connection of remaining.connections) { + yield* deleteStorageStoreConnection({ + storeId, + connectionId: connection.id, + teamId, + }).pipe(Effect.catchTag("NotFound", () => Effect.void)); + } + yield* deleteStorageStoresBlobById({ id: storeId, teamId }).pipe( + Effect.catchTag("NotFound", () => Effect.void), + ); + + // 6. Reap the throwaway project. + if (reaper) { + yield* deleteProjectByIdOrName(project.id); + } + }).pipe( + Effect.withSpan("state_store.purge_store", { + attributes: { "alchemy.state_store.op": "purge_store" }, + }), + ); + +/** + * Deploy the state-store stack (Function + private Blob store + the two + * persisted Random secrets) with the given backing state, write the + * refreshed credentials, and block until `/version` serves the version + * this CLI was built against. + */ +const deployStateStore = ({ + stage, + profileName, + state, + force, +}: { + stage: string; + profileName: string; + state: StateService; + force?: boolean; +}) => + Effect.gen(function* () { + const stateLayer = Layer.succeed(State, Effect.succeed(state)); + const { url, authToken } = yield* deploy({ + // the project name doubles as the stage so multiple stores coexist + stage, + force, + stack: Alchemy.Stack( + STATE_STORE_STACK, + { + providers: Layer.mergeAll( + VercelProviders.providers(), + RandomProvider(), + ), + state: stateLayer, + }, + Effect.gen(function* () { + const token = yield* TokenValue; + const key = yield* EncryptionKeyValue; + const api = yield* Api; + + // Deliver the bearer token + AES key as `encrypted` project env + // vars (plain strings — NOT Redacted/sensitive) so + // `loginWithVercel` can recover them out-of-band via the + // management API's single-env GET when the local cache is lost. + yield* api.bind("StateStoreSecrets", { + env: { + [STATE_TOKEN_ENV]: token.text.pipe(Output.map(Redacted.value)), + [STATE_ENCRYPTION_KEY_ENV]: key.text.pipe( + Output.map(Redacted.value), + ), + }, + }); + + return { + url: api.url.as(), + authToken: token.text.pipe(Output.map(Redacted.value)), + }; + }), + ), + }).pipe( + // The state store is team-level infrastructure that outlives any + // single deploy: its project and Blob store may already exist from a + // previous (possibly partially-failed) bootstrap. Opt in to adoption + // so the resources reconcile in place instead of failing on conflict. + adopt(true), + Effect.provide(stateLayer), + ); + + yield* writeCredentials(profileName, url, authToken); + + // Block until the freshly deployed function serves the expected + // /version — downstream steps (hoisting local state, adoption version + // probes) must not race edge propagation of the new deployment. + yield* waitForStateStoreVersion(url); + return { url, authToken }; + }).pipe( + Effect.withSpan("state_store.deploy", { + attributes: { + "alchemy.state_store.op": "deploy", + }, + }), + recordStateStoreOp("deploy"), + ); + +/** + * Greenfield bootstrap: deploy the store with LOCAL state under a + * profile-scoped stage, then hoist that state into the deployed store + * itself and delete the local copy. + */ +const deployWithLocalState = ({ + projectName, + profileName, + isCI, + force, +}: { + projectName: string; + profileName: string; + isCI: boolean; + force: boolean; +}) => + Effect.gen(function* () { + const localState = yield* makeLocalState(); + const localStage = `${profileName}_${projectName}`; + const remoteStage = projectName; + const { url, authToken } = yield* deployStateStore({ + stage: localStage, + profileName, + state: localState, + force, + }); + const httpState = yield* makeVercelStateStore({ url, authToken }); + + yield* hoistBootstrapStack({ + source: { + state: localState, + stage: localStage, + }, + destination: { + state: httpState, + stage: remoteStage, + }, + }); + + yield* localState.deleteStack({ + stack: STATE_STORE_STACK, + stage: localStage, + }); + + return httpState; + }).pipe( + Effect.withSpan("state_store.finish_bootstrap", { + attributes: { + "alchemy.state_store.op": "finish_bootstrap", + "alchemy.state_store.ci": isCI, + }, + }), + ); + +/** + * Writes against a just-deployed state-store function can fail + * transiently while Vercel propagates the deployment: + * + * - 404 — the production alias isn't serving the new deployment yet + * - 401 — the function is up but its env binding hasn't landed on the + * serving deployment yet + * - 5xx / transport errors — cold-start blips + * + * @internal exported for unit testing. + */ +export const isTransientBootstrapWriteError = (error: { + cause?: unknown; +}): boolean => { + const cause = error.cause; + if (cause == null) return false; + const tag = (cause as { _tag?: unknown })._tag; + if (typeof tag === "string" && tag.startsWith("Unauthorized")) return true; + if (isHttpClientError(cause)) { + const status = cause.response?.status; + return status === undefined || status === 404 || status >= 500; + } + return false; +}; + +/** Is there a local bootstrap stack that wasn't properly hoisted? */ +const hasLocalStack = (stage: string) => + Effect.gen(function* () { + const localState = yield* makeLocalState(); + return yield* Effect.map( + localState.listStages(STATE_STORE_STACK), + // key off the profile name to avoid conflicts with other profiles + (stages) => stages.includes(stage), + ); + }); + +/** + * Non-destructively copy every resource in the bootstrap stack from + * `source` into `destination`, leaving every other stack in + * `destination` untouched (the destination is the user's live remote + * store — deleting entries missing locally would be catastrophic). + */ +const hoistBootstrapStack = Effect.fn(function* ({ + source, + destination, +}: { + source: { + state: StateService; + stage: string; + }; + destination: { + state: StateService; + stage: string; + }; +}) { + const stack = STATE_STORE_STACK; + const fqns = yield* source.state.list({ stack, stage: source.stage }); + yield* Effect.annotateCurrentSpan({ + "alchemy.state_store.stack": stack, + "alchemy.state_store.stage": source.stage, + "alchemy.state_store.resources.count": fqns.length, + }); + yield* Effect.forEach( + fqns, + Effect.fn(function* (fqn) { + const value = yield* source.state.get({ + stack, + stage: source.stage, + fqn, + }); + if (value) { + yield* destination.state + .set({ + stack, + stage: destination.stage, + fqn, + value, + }) + .pipe( + Effect.retry({ + while: isTransientBootstrapWriteError, + // Bounded at ~30s: propagation of the fresh deployment can + // take a while; anything persisting past that is a real + // failure to surface, not to spin on. + schedule: Schedule.max([ + Schedule.fixed(500), + Schedule.recurs(60), + ]), + }), + ); + } + }), + { concurrency: "unbounded" }, + ); +}, Effect.withSpan("state_store.hoist_bootstrap_stack")); + +/** + * Recover `{ url, authToken }` for the deployed state store from the + * management API alone (no local state): find the deterministic + * `alchemy-state` project, read the bearer token back from its + * `encrypted` `ALCHEMY_STATE_TOKEN` env row (Vercel's single-env GET + * returns the plaintext of `encrypted` rows), and read back the + * project's production URL. Persists the credentials for the profile + * (outside CI). + */ +export const loginWithVercel = (profileName: string, force: boolean) => + Effect.gen(function* () { + const credStore = yield* CredentialsStore; + const isCI = yield* CI; + const { teamId } = yield* VercelEnvironment.current; + + if (!force) { + const credentials = yield* credStore.read( + profileName, + CREDENTIALS_FILE, + ); + if (credentials && !isStateStoreCredentialsStale(credentials, teamId)) { + return credentials; + } + } + + const project = yield* observeProject(STATE_STORE_PROJECT_NAME); + if (project === undefined) { + return yield* Effect.fail( + new AuthError({ + message: `No Vercel project '${STATE_STORE_PROJECT_NAME}' found in this scope. Deploy the state store first ('alchemy vercel bootstrap').`, + }), + ); + } + + const envsBody = yield* projects.filterProjectEnvs({ + idOrName: project.id, + teamId, + }); + const rows = ( + Array.isArray(envsBody) + ? envsBody + : typeof envsBody === "object" && + envsBody !== null && + "envs" in envsBody + ? (envsBody as { envs: unknown[] }).envs + : [] + ) as Array<{ key?: string; id?: string }>; + const tokenRow = rows.find((row) => row.key === STATE_TOKEN_ENV); + if (tokenRow?.id === undefined) { + return yield* Effect.fail( + new AuthError({ + message: + `Vercel project '${STATE_STORE_PROJECT_NAME}' has no ${STATE_TOKEN_ENV} env var — ` + + `it does not look like a state store deployment. Re-run 'alchemy vercel bootstrap --force'.`, + }), + ); + } + const decrypted = yield* projects.getProjectEnv({ + idOrName: project.id, + id: tokenRow.id, + teamId, + }); + const authToken = + typeof decrypted === "object" && + decrypted !== null && + "value" in decrypted && + typeof decrypted.value === "string" + ? decrypted.value + : undefined; + if (authToken === undefined || authToken === "") { + return yield* Effect.fail( + new AuthError({ + message: `Failed to read the ${STATE_TOKEN_ENV} value from Vercel project '${STATE_STORE_PROJECT_NAME}'.`, + }), + ); + } + + const url = + readProductionUrl(project) ?? + (yield* readAssignedProductionUrl(project.id)); + if (url === undefined) { + return yield* Effect.fail( + new AuthError({ + message: `Vercel project '${STATE_STORE_PROJECT_NAME}' has no production URL to reach the state store at.`, + }), + ); + } + + const credentials: StoredStateStoreCredentials = { + url, + authToken: authToken.trim(), + ...(teamId !== undefined ? { teamId } : {}), + }; + + if (!isCI) { + yield* credStore + .write( + profileName, + CREDENTIALS_FILE, + credentials, + ) + .pipe( + Effect.mapError( + (e) => + new AuthError({ + message: "Failed to write credentials", + cause: e, + }), + ), + ); + yield* Clank.success( + `Vercel state store credentials saved for '${profileName}'.`, + ); + yield* Clank.info(` url: ${url}`); + } + + return credentials; + }).pipe( + Effect.withSpan("state_store.login", { + attributes: { + "alchemy.state_store.op": "login", + "alchemy.state_store.project_name": STATE_STORE_PROJECT_NAME, + }, + }), + ); + +/** + * Does this scope have a functioning state-store function, verified by + * probing the /version endpoint of the deterministic project's + * production URL? + */ +const isStateStoreServing = () => + Effect.gen(function* () { + const project = yield* observeProject(STATE_STORE_PROJECT_NAME).pipe( + Effect.catch(() => Effect.succeed(undefined)), + ); + if (project === undefined) return false; + const url = + readProductionUrl(project) ?? + (yield* readAssignedProductionUrl(project.id).pipe( + Effect.catch(() => Effect.succeed(undefined)), + )); + if (url === undefined) return false; + const { observed } = yield* checkStateStoreVersion(url); + return observed !== undefined; + }); + +const makeVercelStateStore = Effect.fn(function* ({ + url, + authToken, +}: { + url: string; + authToken: string; +}) { + return yield* makeHttpStateStore({ + url, + authToken, + id: "vercel-http", + }); +}); + +class StateStoreVersionNotReady extends Error { + readonly _tag = "StateStoreVersionNotReady"; + constructor( + readonly expected: number, + readonly observed: number | undefined, + ) { + super( + `Vercel State Store version not ready (expected v${expected}, observed v${observed ?? "unknown"}).`, + ); + } +} + +const waitForStateStoreVersion = (url: string) => + Effect.gen(function* () { + const { matches, expected, observed } = yield* checkStateStoreVersion(url); + if (!matches) { + return yield* Effect.fail( + new StateStoreVersionNotReady(expected, observed), + ); + } + }).pipe( + Effect.retry({ + while: (error) => error instanceof StateStoreVersionNotReady, + // Alias propagation is usually fast, but poll for ~30s before + // failing loudly. + schedule: Schedule.max([ + Schedule.spaced("500 millis"), + Schedule.recurs(60), + ]), + }), + Effect.withSpan("state_store.wait_for_version", { + attributes: { + "alchemy.state_store.op": "wait_for_version", + "alchemy.state_store.url": url, + "alchemy.state_store.expected_version": STATE_STORE_VERSION, + }, + }), + ); + +const checkStateStoreVersion = (url: string) => + Effect.gen(function* () { + const client = yield* HttpApiClient.make(StateApi, { baseUrl: url }); + const isAvailable = yield* Effect.cached( + observeProject(STATE_STORE_PROJECT_NAME).pipe( + Effect.map((project) => project !== undefined), + Effect.catch(() => Effect.succeed(false)), + ), + ); + // The /version route can 404 transiently right after a deploy while + // the production alias moves over, and can blip at the transport + // level. Retry the probe for ~10s before collapsing to `undefined` + // (which callers treat as "not serving"). + const result = yield* client.version.getVersion().pipe( + Effect.catchTag("HttpClientError", (e) => + e.response?.status === 404 + ? isAvailable.pipe( + Effect.flatMap((available) => + // Project exists → assume propagation and retry; no + // project → there is no store, report unknown. + available ? Effect.fail(e) : Effect.succeed(undefined), + ), + ) + : Effect.fail(e), + ), + Effect.retry({ + schedule: Schedule.max([ + Schedule.spaced("250 millis"), + Schedule.recurs(40), + ]), + }), + Effect.catch(() => Effect.succeed(undefined)), + ); + const matches = result?.version === STATE_STORE_VERSION; + yield* Effect.annotateCurrentSpan({ + "alchemy.state_store.expected_version": STATE_STORE_VERSION, + "alchemy.state_store.observed_version": result?.version ?? -1, + "alchemy.state_store.version_match": matches, + }); + return { + matches, + expected: STATE_STORE_VERSION, + observed: result?.version, + }; + }).pipe( + Effect.withSpan("state_store.check_version", { + attributes: { "alchemy.state_store.op": "check_version" }, + }), + ); + +const writeCredentials = ( + profileName: string, + url: string, + authToken: string, +) => + Effect.gen(function* () { + const isCI = yield* CI; + // CI file systems are ephemeral — don't bother persisting there. + if (isCI) return; + const credStore = yield* CredentialsStore; + const { teamId } = yield* VercelEnvironment.current; + yield* credStore.write( + profileName, + CREDENTIALS_FILE, + { + url, + authToken, + ...(teamId !== undefined ? { teamId } : {}), + }, + ); + }); diff --git a/packages/alchemy/src/Vercel/StateStore/Token.ts b/packages/alchemy/src/Vercel/StateStore/Token.ts new file mode 100644 index 0000000000..793a5c4d14 --- /dev/null +++ b/packages/alchemy/src/Vercel/StateStore/Token.ts @@ -0,0 +1,36 @@ +import { Random } from "../../Random.ts"; + +/** + * Secrets of the Vercel state store, minted once at bootstrap and kept + * stable across redeploys (`Alchemy.Random` persists its value in state). + * + * Both are delivered to the deployed state Function as `encrypted` + * project env vars (bound from the bootstrap stack's composition, NOT + * `sensitive`): Vercel's single-env GET (`getProjectEnv`) returns the + * plaintext of `encrypted` rows to management-API credentials, which is + * the out-of-band recovery path `loginWithVercel` uses when the local + * credentials cache is lost — the Vercel-native equivalent of the + * Cloudflare state store's Secrets-Store edge probe. The trust boundary + * is identical: anyone with management access to the team can already + * read/replace the store's data plane. + */ + +/** + * The bearer token every state API request must present. 32 random + * bytes, hex-encoded. + */ +export const TokenValue = Random("VercelStateAuthToken"); + +/** + * The 256-bit AES-CTR key (hex-encoded) that encrypts state rows at + * rest in the Blob store. + */ +export const EncryptionKeyValue = Random("VercelStateEncryptionKey", { + bytes: 32, +}); + +/** Project env var carrying the bearer token. */ +export const STATE_TOKEN_ENV = "ALCHEMY_STATE_TOKEN" as const; + +/** Project env var carrying the hex-encoded AES-CTR encryption key. */ +export const STATE_ENCRYPTION_KEY_ENV = "ALCHEMY_STATE_ENCRYPTION_KEY" as const; diff --git a/packages/alchemy/src/Vercel/StateStore/index.ts b/packages/alchemy/src/Vercel/StateStore/index.ts new file mode 100644 index 0000000000..5d2560098c --- /dev/null +++ b/packages/alchemy/src/Vercel/StateStore/index.ts @@ -0,0 +1,8 @@ +export { + bootstrap, + loginWithVercel, + state, + teardownStateStore, + type BootstrapOptions, + type TeardownOptions, +} from "./State.ts"; diff --git a/packages/alchemy/src/Vercel/Teams/Team.ts b/packages/alchemy/src/Vercel/Teams/Team.ts new file mode 100644 index 0000000000..3f63c3a4e6 --- /dev/null +++ b/packages/alchemy/src/Vercel/Teams/Team.ts @@ -0,0 +1,337 @@ +import * as teams from "@distilled.cloud/vercel/teams"; +import * as Effect from "effect/Effect"; +import { Unowned } from "../../AdoptPolicy.ts"; +import { isResolved } from "../../Diff.ts"; +import { createPhysicalName } from "../../PhysicalName.ts"; +import * as Provider from "../../Provider.ts"; +import { Resource } from "../../Resource.ts"; +import type { Providers } from "../Providers.ts"; + +export interface TeamProps { + /** + * The team's slug, unique across the Vercel platform. Used to create the + * team and to resolve an existing team for adoption. If omitted, a unique + * slug is generated from `${app}-${stage}-${id}`. Slugs are immutable via + * the update API, so changing the slug replaces the team. + */ + slug?: string; + /** + * Display name of the team. + */ + name?: string; + /** + * A short text that describes the team. + */ + description?: string; + /** + * Suffix used for all preview deployments of the team's projects. + * Requires a domain-of-record on the team. + */ + previewDeploymentSuffix?: string; + /** + * Whether remote caching (Turborepo) is enabled for the team. + */ + remoteCaching?: { enabled: boolean }; + /** + * Sensitive environment variable policy: one of `on`, `off` or `default`. + */ + sensitiveEnvironmentVariablePolicy?: string; + /** + * Display or hide IP addresses in Monitoring queries. + */ + hideIpAddresses?: boolean; + /** + * Display or hide IP addresses in Log Drains. + */ + hideIpAddressesInLogDrains?: boolean; +} + +export type Team = Resource< + "Vercel.Team", + TeamProps, + { + /** The team's unique identifier (`team_…`). */ + teamId: string; + /** The team's slug, unique across the Vercel platform. */ + slug: string; + /** Display name of the team, or `null` if none has been provided. */ + name: string | null; + /** A short text that describes the team, or `null`. */ + description: string | null; + /** ID of the user who created the team. */ + creatorId: string; + /** Prefix prepended to automatic preview aliases. */ + stagingPrefix: string; + /** Timestamp (ms) when the team was created. */ + createdAt: number; + /** Timestamp (ms) when the team was last updated. */ + updatedAt: number; + /** + * Whether this team was created by alchemy (as opposed to adopted). + * Adopted teams are never deleted on destroy — creating a Vercel team + * has billing side effects and deleting one is catastrophic, so destroy + * releases adopted teams instead of deleting them. + */ + created: boolean; + }, + never, + Providers +>; + +type TeamAttributes = Team["Attributes"]; + +/** + * A Vercel Team — the tenancy and billing boundary that owns projects, + * domains, and storage. + * + * Creating a team has **billing side effects** (each team has its own plan + * and invoices), so the common use of this resource is *adoption*: point it + * at an existing team's `slug` (with `--adopt` / `adopt(true)`) and manage + * the team's settings declaratively. An adopted team is never deleted on + * destroy — only teams that alchemy itself created are deleted, and only + * during an explicit `destroy`. + * + * @resource + * @section Adopting an existing team + * @example Manage settings of the current team + * ```typescript + * import { adopt } from "alchemy/AdoptPolicy"; + * + * const team = yield* Vercel.Team("Team", { + * slug: "my-team", + * description: "Managed by alchemy", + * remoteCaching: { enabled: true }, + * }).pipe(adopt(true)); + * ``` + * + * @section Creating a team + * @example New team (billing side effects!) + * ```typescript + * const team = yield* Vercel.Team("Team", { + * slug: "my-new-team", + * name: "My New Team", + * }); + * ``` + * + * @see https://vercel.com/docs/accounts/create-a-team + */ +export const Team = Resource("Vercel.Team"); + +const toAttributes = (team: teams.Team, created: boolean): TeamAttributes => ({ + teamId: team.id, + slug: team.slug, + name: team.name, + description: team.description, + creatorId: team.creatorId, + stagingPrefix: team.stagingPrefix, + createdAt: team.createdAt, + updatedAt: team.updatedAt, + created, +}); + +const createTeamSlug = (id: string) => + createPhysicalName({ id, lowercase: true }); + +/** + * Observe a team by id (preferred) or by slug via the authenticated user's + * team list. Returns `undefined` when no such team is visible. + */ +const observeTeam = (teamId: string | undefined, slug: string) => + Effect.gen(function* () { + if (teamId !== undefined) { + const byId = yield* teams + .getTeam({ teamId }) + .pipe( + Effect.catchTag(["NotFound", "Forbidden"], () => + Effect.succeed(undefined), + ), + ); + if (byId !== undefined) return byId; + } + // Fall back to resolving the slug against the teams the token can see. + // List rows may be `TeamLimited` (restricted membership), so re-read the + // match by id for the authoritative settings view. + let until: number | undefined; + do { + const page = yield* teams.getTeams({ + limit: 100, + ...(until !== undefined ? { until } : {}), + }); + const match = page.teams.find((t) => t.slug === slug); + if (match !== undefined) { + return yield* teams + .getTeam({ teamId: match.id }) + .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))); + } + until = page.pagination.next ?? undefined; + } while (until !== undefined); + return undefined; + }); + +/** Compute the settings delta between the observed team and desired props. */ +const settingsDelta = (observed: teams.Team, news: TeamProps) => { + const delta: Partial< + Pick< + teams.PatchTeamRequest, + | "name" + | "description" + | "previewDeploymentSuffix" + | "remoteCaching" + | "sensitiveEnvironmentVariablePolicy" + | "hideIpAddresses" + | "hideIpAddressesInLogDrains" + > + > = {}; + if (news.name !== undefined && news.name !== (observed.name ?? undefined)) { + delta.name = news.name; + } + if ( + news.description !== undefined && + news.description !== (observed.description ?? undefined) + ) { + delta.description = news.description; + } + if ( + news.previewDeploymentSuffix !== undefined && + news.previewDeploymentSuffix !== + (observed.previewDeploymentSuffix ?? undefined) + ) { + delta.previewDeploymentSuffix = news.previewDeploymentSuffix; + } + if ( + news.remoteCaching !== undefined && + news.remoteCaching.enabled !== (observed.remoteCaching?.enabled ?? false) + ) { + delta.remoteCaching = { enabled: news.remoteCaching.enabled }; + } + if ( + news.sensitiveEnvironmentVariablePolicy !== undefined && + news.sensitiveEnvironmentVariablePolicy !== + (observed.sensitiveEnvironmentVariablePolicy ?? undefined) + ) { + delta.sensitiveEnvironmentVariablePolicy = + news.sensitiveEnvironmentVariablePolicy; + } + if ( + news.hideIpAddresses !== undefined && + news.hideIpAddresses !== (observed.hideIpAddresses ?? undefined) + ) { + delta.hideIpAddresses = news.hideIpAddresses; + } + if ( + news.hideIpAddressesInLogDrains !== undefined && + news.hideIpAddressesInLogDrains !== + (observed.hideIpAddressesInLogDrains ?? undefined) + ) { + delta.hideIpAddressesInLogDrains = news.hideIpAddressesInLogDrains; + } + return delta; +}; + +export const TeamProvider = () => + Provider.succeed(Team, { + stables: ["teamId", "slug", "creatorId", "stagingPrefix", "createdAt"], + diff: Effect.fn(function* ({ olds, news, output }) { + if (!isResolved(news)) return undefined; + if (!output) return undefined; + // Slugs cannot be renamed through the update API — changing the slug + // replaces the team. Only an explicitly-set slug can drift (generated + // slugs are stable per instance). + if (news.slug !== undefined && news.slug !== output.slug) { + return { action: "replace" } as const; + } + return undefined; + }), + read: Effect.fn(function* ({ id, olds, output }) { + const slug = output?.slug ?? olds?.slug ?? (yield* createTeamSlug(id)); + const observed = yield* observeTeam(output?.teamId, slug); + if (observed === undefined) return undefined; + // With prior state we know whether we created the team; without it, + // an existing team is someone's tenancy/billing boundary — gate + // takeover behind `--adopt`, and never mark it as alchemy-created. + return output !== undefined + ? toAttributes(observed, output.created) + : Unowned(toAttributes(observed, false)); + }), + list: Effect.fn(function* () { + // List rows may be `TeamLimited`; re-read each by id for the full + // shape. `created: false` — a list row carries no provenance, and + // only rows alchemy provably created may ever be deleted. + const rows: TeamAttributes[] = []; + let until: number | undefined; + do { + const page = yield* teams.getTeams({ + limit: 100, + ...(until !== undefined ? { until } : {}), + }); + for (const row of page.teams) { + const full = yield* teams + .getTeam({ teamId: row.id }) + .pipe( + Effect.catchTag(["NotFound", "Forbidden"], () => + Effect.succeed(undefined), + ), + ); + if (full !== undefined) rows.push(toAttributes(full, false)); + } + until = page.pagination.next ?? undefined; + } while (until !== undefined); + return rows; + }), + reconcile: Effect.fn(function* ({ id, news, output }) { + const slug = news.slug ?? output?.slug ?? (yield* createTeamSlug(id)); + + // Observe — cloud state is authoritative; `output` only caches the + // stable team id (and whether we created the team). + let observed = yield* observeTeam(output?.teamId, slug); + let created = output?.created ?? false; + + // Ensure — missing → create. NOTE: creating a Vercel team has + // billing side effects (its own plan/invoices). + if (observed === undefined) { + const result = yield* teams + .createTeam({ + slug, + ...(news.name !== undefined ? { name: news.name } : {}), + }) + .pipe( + // A concurrent create of the same slug is a race — trust + // observation and re-resolve below. + Effect.catchTag("Conflict", () => Effect.succeed(undefined)), + ); + created = true; + observed = yield* observeTeam(result?.id, slug); + if (observed === undefined) { + return yield* Effect.die( + `Vercel team ${slug} not observable after create`, + ); + } + } + + // Sync — diff OBSERVED settings against desired and apply only the + // delta. Props left undefined are unmanaged. + const delta = settingsDelta(observed, news); + if (Object.keys(delta).length > 0) { + const updated = yield* teams.patchTeam({ + teamId: observed.id, + ...delta, + }); + return toAttributes(updated, created); + } + return toAttributes(observed, created); + }), + delete: Effect.fn(function* ({ output }) { + // HARD SAFETY RULE: only delete teams alchemy itself created. + // An adopted team is an entire tenancy (projects, domains, billing) — + // destroy releases it from state without touching the cloud. + if (!output.created) { + yield* Effect.logInfo( + `Vercel.Team: releasing adopted team ${output.slug} (${output.teamId}) without deleting it`, + ); + return; + } + yield* teams + .deleteTeam({ teamId: output.teamId }) + .pipe(Effect.catchTag("NotFound", () => Effect.void)); + }), + }); diff --git a/packages/alchemy/src/Vercel/Teams/TeamMember.ts b/packages/alchemy/src/Vercel/Teams/TeamMember.ts new file mode 100644 index 0000000000..2a2b04d4c7 --- /dev/null +++ b/packages/alchemy/src/Vercel/Teams/TeamMember.ts @@ -0,0 +1,176 @@ +import * as teams from "@distilled.cloud/vercel/teams"; +import * as Effect from "effect/Effect"; +import { Unowned } from "../../AdoptPolicy.ts"; +import { isResolved } from "../../Diff.ts"; +import * as Provider from "../../Provider.ts"; +import { Resource } from "../../Resource.ts"; +import type { Providers } from "../Providers.ts"; +import { listAllTeamMembers, resolveTeamId } from "./internal.ts"; + +export interface TeamMemberProps { + /** + * Email address of the user to invite to the team. Changing the email + * replaces the membership (the old member is removed, the new one + * invited). + */ + email: string; + /** + * Role of the member in the team, e.g. `MEMBER`, `OWNER`, `DEVELOPER`, + * `BILLING`, `VIEWER`, `CONTRIBUTOR`, `SECURITY`. + * + * @default "MEMBER" + */ + role?: string; +} + +export type TeamMember = Resource< + "Vercel.TeamMember", + TeamMemberProps, + { + /** The user ID of the member. */ + uid: string; + /** The email of the member. */ + email: string; + /** The username of the member. */ + username: string; + /** Role of the member in the team. */ + role: string; + /** Whether the membership was confirmed (invite accepted / approved). */ + confirmed: boolean; + /** ID of the team the membership belongs to. */ + teamId: string; + }, + never, + Providers +>; + +type TeamMemberAttributes = TeamMember["Attributes"]; + +/** + * A membership of a user in the Vercel team, managed by email invitation. + * + * Inviting a user **sends a real email invitation** to that address, so the + * resource should only ever target addresses you control. An existing + * membership (e.g. the team owner) is reported as unowned — takeover is + * gated behind `--adopt` / `adopt(true)`, after which the member's role is + * managed declaratively. + * + * Destroying the resource removes the member from the team (idempotent — + * an already-removed member is not an error). + * + * @resource + * @section Inviting a member + * @example Invite a developer + * ```typescript + * const member = yield* Vercel.TeamMember("Dev", { + * email: "dev@acme.com", + * role: "DEVELOPER", + * }); + * ``` + * + * @section Managing an existing membership + * @example Adopt and manage a member's role + * ```typescript + * import { adopt } from "alchemy/AdoptPolicy"; + * + * const member = yield* Vercel.TeamMember("Ops", { + * email: "ops@acme.com", + * role: "MEMBER", + * }).pipe(adopt(true)); + * ``` + * + * @see https://vercel.com/docs/rbac/managing-team-members + */ +export const TeamMember = Resource("Vercel.TeamMember"); + +const findMemberByEmail = (teamId: string, email: string) => + Effect.gen(function* () { + const needle = email.toLowerCase(); + const members = yield* listAllTeamMembers(teamId, email); + return members.find((m) => m.email.toLowerCase() === needle); + }); + +const toAttributes = ( + member: teams.GetTeamMembersResponse["members"][number], + teamId: string, +): TeamMemberAttributes => ({ + uid: member.uid, + email: member.email, + username: member.username, + role: member.role, + confirmed: member.confirmed, + teamId, +}); + +export const TeamMemberProvider = () => + Provider.succeed(TeamMember, { + stables: ["uid", "email", "username", "teamId"], + diff: Effect.fn(function* ({ olds, news, output }) { + if (!isResolved(news)) return undefined; + if (!output) return undefined; + // Membership identity IS the user — a different email is a different + // membership. + if (news.email !== olds.email) { + return { action: "replace" } as const; + } + return undefined; + }), + read: Effect.fn(function* ({ olds, output }) { + const teamId = yield* resolveTeamId; + const email = output?.email ?? olds?.email; + if (email === undefined) return undefined; + const observed = yield* findMemberByEmail(teamId, email); + if (observed === undefined) return undefined; + const attrs = toAttributes(observed, teamId); + // An existing membership without prior state is someone's real team + // access — gate takeover behind `--adopt`. + return output !== undefined ? attrs : Unowned(attrs); + }), + list: Effect.fn(function* () { + const teamId = yield* resolveTeamId; + const members = yield* listAllTeamMembers(teamId); + return members.map((m) => toAttributes(m, teamId)); + }), + reconcile: Effect.fn(function* ({ news }) { + const teamId = yield* resolveTeamId; + const desiredRole = news.role ?? "MEMBER"; + + // Observe — cloud membership is authoritative. + const observed = yield* findMemberByEmail(teamId, news.email); + + // Ensure — missing → invite. NOTE: this sends a real email + // invitation to `news.email`. + if (observed === undefined) { + const invited = yield* teams.inviteUserToTeam({ + teamId, + email: news.email, + role: desiredRole, + }); + return { + uid: invited.uid, + email: invited.email, + username: invited.username, + role: invited.role, + confirmed: false, + teamId, + }; + } + + // Sync — apply only the role delta. + if (observed.role !== desiredRole) { + yield* teams.updateTeamMember({ + teamId, + uid: observed.uid, + role: desiredRole, + }); + const fresh = yield* findMemberByEmail(teamId, news.email); + return toAttributes(fresh ?? observed, teamId); + } + return toAttributes(observed, teamId); + }), + delete: Effect.fn(function* ({ output }) { + yield* teams + .removeTeamMember({ teamId: output.teamId, uid: output.uid }) + .pipe(Effect.catchTag("NotFound", () => Effect.void)); + }), + }); diff --git a/packages/alchemy/src/Vercel/Teams/index.ts b/packages/alchemy/src/Vercel/Teams/index.ts new file mode 100644 index 0000000000..f8d969e220 --- /dev/null +++ b/packages/alchemy/src/Vercel/Teams/index.ts @@ -0,0 +1,3 @@ +export * from "./Team.ts"; +export * from "./TeamMember.ts"; +// NOTE: ./internal.ts is deliberately NOT exported (shared scaffolding). diff --git a/packages/alchemy/src/Vercel/Teams/internal.ts b/packages/alchemy/src/Vercel/Teams/internal.ts new file mode 100644 index 0000000000..0f0fc870f8 --- /dev/null +++ b/packages/alchemy/src/Vercel/Teams/internal.ts @@ -0,0 +1,55 @@ +// Shared scaffolding for the Teams service — NOT exported from +// `Teams/index.ts` (generic helper names must not leak into the flat +// `Vercel` namespace). +import * as teams from "@distilled.cloud/vercel/teams"; +import * as user from "@distilled.cloud/vercel/user"; +import * as Effect from "effect/Effect"; +import { VercelEnvironment } from "../VercelEnvironment.ts"; + +/** + * Resolve the team the token operates in: the ambient `VercelEnvironment` + * scope when set, otherwise the authenticated user's default team (on + * northstar accounts every token has one — "personal scope" requests + * actually land on it). Dies when neither exists: team-scoped operations + * (members, team settings) have no meaning without a team. + */ +export const resolveTeamId = Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + if (teamId !== undefined) return teamId; + const auth = yield* user.getAuthUser({}).pipe(Effect.orDie); + const defaultTeamId = auth.user.defaultTeamId; + if (defaultTeamId === null || defaultTeamId === undefined) { + return yield* Effect.die( + "Vercel team-scoped operation requires a team: set VERCEL_TEAM_ID or use a token whose user has a default team", + ); + } + return defaultTeamId; +}); + +/** + * Exhaustively enumerate every member of the given team. Shared by the + * TeamMember provider's observe step and `list` fan-out. + */ +export const listAllTeamMembers = (teamId: string, search?: string) => + Effect.gen(function* () { + const members: teams.GetTeamMembersResponse["members"][number][] = []; + let until: number | undefined; + let hasNext = true; + while (hasNext) { + const page = yield* teams.getTeamMembers({ + teamId, + limit: 100, + ...(search !== undefined ? { search } : {}), + ...(until !== undefined ? { until } : {}), + }); + members.push(...page.members); + hasNext = page.pagination.hasNext; + // The members feed pages by createdAt timestamp cursors. + until = + typeof page.pagination.next === "number" + ? page.pagination.next + : undefined; + if (until === undefined) hasNext = false; + } + return members; + }); diff --git a/packages/alchemy/src/Vercel/VercelEnvironment.ts b/packages/alchemy/src/Vercel/VercelEnvironment.ts new file mode 100644 index 0000000000..822d55f98c --- /dev/null +++ b/packages/alchemy/src/Vercel/VercelEnvironment.ts @@ -0,0 +1,55 @@ +import * as Config from "effect/Config"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { getAuthProvider } from "../Auth/AuthProvider.ts"; +import { ALCHEMY_PROFILE, AlchemyProfile } from "../Auth/Profile.ts"; +import { + VERCEL_AUTH_PROVIDER_NAME, + type VercelAuthConfig, + type VercelResolvedCredentials, +} from "./AuthProvider.ts"; + +/** + * Tenancy for a Vercel stack (≙ Cloudflare's `accountId`). Vercel scopes + * team requests via a per-op `teamId` query parameter, so providers resolve + * it INSIDE lifecycle operations (`yield* VercelEnvironment.current`) and + * pass it whenever set — never via per-resource props. + */ +export interface VercelEnvironmentShape { + /** Team to operate in; `undefined` = personal scope. */ + teamId?: string; +} + +export class VercelEnvironment extends Context.Service< + VercelEnvironment, + Effect.Effect +>()("Vercel::VercelEnvironment") { + static current = VercelEnvironment.use((env) => env); + readonly kind = "Environment" as const; +} + +export const fromProfile = () => + Layer.effect( + VercelEnvironment, + Effect.gen(function* () { + const profile = yield* AlchemyProfile; + const auth = yield* getAuthProvider< + VercelAuthConfig, + VercelResolvedCredentials + >(VERCEL_AUTH_PROVIDER_NAME); + const profileName = yield* ALCHEMY_PROFILE; + const ci = yield* Config.boolean("CI").pipe(Config.withDefault(false)); + // `loadOrConfigure` reads the persisted config under the canonical + // provider name (`Vercel`); only runs `configure` (and persists the + // result) if no stored config exists. + return yield* profile.loadOrConfigure(auth, profileName, { ci }).pipe( + Effect.flatMap((config) => + auth.read(profileName, config as VercelAuthConfig), + ), + Effect.map((creds) => ({ teamId: creds.teamId })), + Effect.orDie, + Effect.cached, + ); + }), + ); diff --git a/packages/alchemy/src/Vercel/Webhooks/Webhook.ts b/packages/alchemy/src/Vercel/Webhooks/Webhook.ts new file mode 100644 index 0000000000..b9704a5cc5 --- /dev/null +++ b/packages/alchemy/src/Vercel/Webhooks/Webhook.ts @@ -0,0 +1,241 @@ +import { + type CreateWebhookRequestEventsItem, + createWebhook, + deleteWebhook, + getWebhook, +} from "@distilled.cloud/vercel/webhooks"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import { isResolved } from "../../Diff.ts"; +import * as Provider from "../../Provider.ts"; +import { Resource } from "../../Resource.ts"; +import { VercelEnvironment } from "../VercelEnvironment.ts"; +import type { Providers } from "../Providers.ts"; + +/** + * An event name a Vercel webhook can subscribe to, e.g. + * `"deployment.succeeded"` or `"project.created"`. + */ +export type WebhookEvent = CreateWebhookRequestEventsItem; + +export interface WebhookProps { + /** + * HTTPS endpoint that receives the webhook's POST deliveries. Vercel + * signs each delivery with the webhook's secret (`x-vercel-signature`). + * + * Changing the URL replaces the webhook (Vercel has no update API) — a + * new webhook (with a new id and secret) is created and the old one is + * deleted. + */ + url: string; + /** + * Events that trigger a delivery, e.g. `["deployment.succeeded"]`. + * Order does not matter — the list is compared as a set. + * + * Changing the event list replaces the webhook. + */ + events: (WebhookEvent | (string & {}))[]; + /** + * Restrict deliveries to specific project IDs. When omitted, the webhook + * fires for every project in the team. + * + * Changing the project list replaces the webhook. + */ + projectIds?: string[]; +} + +export type Webhook = Resource< + "Vercel.Webhook", + WebhookProps, + { + /** The webhook id (`account_hook_…`). Changes on replacement. */ + webhookId: string; + /** The URL receiving deliveries. */ + url: string; + /** The subscribed events, as returned by Vercel. */ + events: string[]; + /** Project IDs the webhook is scoped to; `undefined` = all projects. */ + projectIds: string[] | undefined; + /** The team (or user) the webhook belongs to. */ + ownerId: string; + /** + * The secret used to sign deliveries (`x-vercel-signature` is an HMAC + * SHA-1 of the raw body keyed with this). **Write-only on Vercel's + * side**: it is returned exactly once by the create call and can never + * be read back from the API, so Alchemy persists it here in state. + * A replacement (any prop change) mints a new secret. + */ + secret: string; + /** Creation time in epoch milliseconds. */ + createdAt: number; + /** Last update time in epoch milliseconds. */ + updatedAt: number; + }, + never, + Providers +>; + +type WebhookAttributes = Webhook["Attributes"]; + +/** + * A Vercel account webhook: an HTTPS endpoint that Vercel POSTs platform + * events to (deployments, domains, projects, …), signed with a + * create-time secret. + * + * Vercel's API has no webhook update operation, so this resource is + * **replace-only**: changing any prop (`url`, `events`, `projectIds`) + * creates a new webhook — with a **new id and a new signing secret** — + * and deletes the old one. + * + * @resource + * @section Creating a Webhook + * @example Notify an endpoint on successful deployments + * ```typescript + * const hook = yield* Vercel.Webhook("Deploys", { + * url: "https://ops.example.com/hooks/vercel", + * events: ["deployment.succeeded", "deployment.error"], + * }); + * ``` + * + * @example Scope a webhook to specific projects + * ```typescript + * const hook = yield* Vercel.Webhook("SiteDeploys", { + * url: "https://ops.example.com/hooks/site", + * events: ["deployment.succeeded"], + * projectIds: [project.projectId], + * }); + * ``` + * + * @section Verifying deliveries + * @example Use the write-once signing secret + * ```typescript + * // `secret` is returned exactly once by Vercel at create time and is + * // persisted in the webhook's attributes; deliveries carry an + * // `x-vercel-signature` header — the HMAC-SHA1 of the raw body keyed + * // with this secret. + * const hook = yield* Vercel.Webhook("Deploys", { + * url: "https://ops.example.com/hooks/vercel", + * events: ["deployment.succeeded"], + * }); + * return { signingSecret: hook.secret.as() }; + * ``` + * + * @see https://vercel.com/docs/webhooks + */ +export const Webhook = Resource("Vercel.Webhook"); + +/** Set-equality for event/project-id lists (order-insensitive). */ +const sameMembers = ( + a: readonly string[] | undefined, + b: readonly string[] | undefined, +): boolean => { + const as = [...(a ?? [])].sort(); + const bs = [...(b ?? [])].sort(); + return as.length === bs.length && as.every((v, i) => v === bs[i]); +}; + +const teamScope = Effect.gen(function* () { + const { teamId } = yield* VercelEnvironment.current; + return teamId !== undefined ? { teamId } : {}; +}); + +export const WebhookProvider = () => + Provider.succeed(Webhook, { + stables: ["webhookId", "ownerId", "secret", "createdAt"], + diff: Effect.fn(function* ({ news, olds, output }) { + if (!isResolved(news)) return undefined; + // Replace-only: Vercel has no webhook PATCH/update operation, so any + // difference between the desired props and the last-known state must + // be converged by create-new-then-delete-old. + const oldUrl = output?.url ?? olds?.url; + const oldEvents = output?.events ?? olds?.events; + const oldProjectIds = output?.projectIds ?? olds?.projectIds; + if ( + news.url !== oldUrl || + !sameMembers(news.events, oldEvents) || + !sameMembers(news.projectIds, oldProjectIds) + ) { + return { action: "replace" } as const; + } + return undefined; + }), + read: Effect.fn(function* ({ output }) { + // Webhooks carry no name and no metadata to stamp, so identity is the + // id persisted in state — without it there is nothing to look up + // (DESIGN §5.4: ownership = state for resources with no env stamp). + if (!output?.webhookId) return undefined; + const team = yield* teamScope; + return yield* getWebhook({ id: output.webhookId, ...team }).pipe( + Effect.map( + (hook): WebhookAttributes => ({ + webhookId: hook.id, + url: hook.url, + events: [...hook.events], + projectIds: hook.projectIds ? [...hook.projectIds] : undefined, + ownerId: hook.ownerId, + // The secret is write-only on Vercel's side (returned once at + // create) — carry the persisted value forward. + secret: output.secret, + createdAt: hook.createdAt, + updatedAt: hook.updatedAt, + }), + ), + Effect.catchTag("NotFound", () => Effect.succeed(undefined)), + ); + }), + reconcile: Effect.fn(function* ({ news, output }) { + const team = yield* teamScope; + // Observe — the persisted id is a cache, not proof of existence. + if (output?.webhookId) { + const observed = yield* getWebhook({ + id: output.webhookId, + ...team, + }).pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))); + if (observed !== undefined) { + // Existence-only convergence: diff already forces a replacement + // for any prop change (no update API), so a live webhook is + // already at desired state — refresh attrs from observation. + return { + webhookId: observed.id, + url: observed.url, + events: [...observed.events], + projectIds: observed.projectIds + ? [...observed.projectIds] + : undefined, + ownerId: observed.ownerId, + secret: output.secret, + createdAt: observed.createdAt, + updatedAt: observed.updatedAt, + } satisfies WebhookAttributes; + } + } + // Ensure — missing (greenfield, or deleted out-of-band) → create. + const created = yield* createWebhook({ + url: news.url, + events: news.events, + ...(news.projectIds ? { projectIds: news.projectIds } : {}), + ...team, + }); + return { + webhookId: created.id, + url: created.url, + events: [...created.events], + projectIds: created.projectIds ? [...created.projectIds] : undefined, + ownerId: created.ownerId, + // Returned exactly once — persist it now or lose it forever. + secret: Redacted.isRedacted(created.secret) + ? Redacted.value(created.secret) + : created.secret, + createdAt: created.createdAt, + updatedAt: created.updatedAt, + } satisfies WebhookAttributes; + }), + delete: Effect.fn(function* ({ output }) { + const team = yield* teamScope; + yield* deleteWebhook({ id: output.webhookId, ...team }).pipe( + // Already gone (out-of-band delete, or a re-run after a state + // persistence failure) is success, not an error. + Effect.catchTag("NotFound", () => Effect.void), + ); + }), + }); diff --git a/packages/alchemy/src/Vercel/Website/Astro.ts b/packages/alchemy/src/Vercel/Website/Astro.ts new file mode 100644 index 0000000000..277a9fc907 --- /dev/null +++ b/packages/alchemy/src/Vercel/Website/Astro.ts @@ -0,0 +1,99 @@ +import * as Effect from "effect/Effect"; +import type { MemoOptions } from "../../Command/Memo.ts"; +import type { InputProps } from "../../Input.ts"; +import { effectClass } from "../../Util/effect.ts"; +import { Function, type FunctionProps } from "../Functions/Function.ts"; +import type { Providers } from "../Providers.ts"; +import { makeVercelOutputSite, resolveWebsiteProps } from "./internal.ts"; + +export interface AstroProps extends Omit< + FunctionProps, + "main" | "script" | "prebuilt" | "source" | "build" +> { + /** + * Astro project root (the directory containing `astro.config.*`). + * Defaults to the process working directory. + */ + rootDir?: string; + /** + * Build command run in {@link rootDir}. + * @default "bunx astro build" + */ + command?: string; + /** + * Controls which files are content-hashed to decide whether a rebuild is + * needed. By default every non-gitignored file under `rootDir` (plus the + * nearest lockfile) is hashed — make sure `dist` and `.vercel` are + * gitignored so build output doesn't churn the hash. + */ + memo?: MemoOptions | boolean; +} + +/** + * An [Astro](https://astro.build) site deployed to Vercel. + * + * `Astro` runs the project's own build; the + * [`@astrojs/vercel`](https://docs.astro.build/en/guides/integrations-guide/vercel/) + * adapter (which must be installed and declared in the project's + * `astro.config.*`) emits a complete `.vercel/output` (Build Output v3) + * tree — server-rendered pages as serverless functions, prerendered pages + * and client assets as static files. The tree is deployed by the same + * engine as every `Vercel.Function`. + * + * ```sh + * bunx astro add vercel # installs + configures the adapter + * ``` + * + * Input files are content-hashed (respecting `.gitignore`) so unchanged + * projects skip the build and deploy entirely. + * + * @resource + * @product Website + * + * @section Deploying an Astro Site + * @example Basic Astro site + * ```typescript + * const site = yield* Vercel.Website.Astro("Site", { + * rootDir: "./web", + * }); + * ``` + * + * @section Class Form + * @example Declaring a site class + * ```typescript + * class Site extends Vercel.Website.Astro()("Site", { + * rootDir: "./web", + * }) {} + * + * const site = yield* Site; + * ``` + */ +export const Astro: { + (): ( + id: string, + props?: + | InputProps + | Effect.Effect, never, Req>, + ) => Effect.Effect & { + new (_: never): Function; + }; + ( + id: string, + props?: + | InputProps + | Effect.Effect, never, Req>, + ): Effect.Effect; +} = ((id?: any, propsEff?: any) => + id === undefined + ? (id: string, propsEff: any) => effectClass(makeAstro(id, propsEff)) + : makeAstro(id, propsEff)) as any; + +const makeAstro = (id: string, propsEff: any) => + Effect.gen(function* () { + const props = yield* resolveWebsiteProps(propsEff); + return yield* makeVercelOutputSite({ + id, + props, + defaultCommand: "bunx astro build", + }); + }); diff --git a/packages/alchemy/src/Vercel/Website/Nuxt.ts b/packages/alchemy/src/Vercel/Website/Nuxt.ts new file mode 100644 index 0000000000..7658a161d4 --- /dev/null +++ b/packages/alchemy/src/Vercel/Website/Nuxt.ts @@ -0,0 +1,106 @@ +import * as Effect from "effect/Effect"; +import type { MemoOptions } from "../../Command/Memo.ts"; +import type { InputProps } from "../../Input.ts"; +import { effectClass } from "../../Util/effect.ts"; +import { Function, type FunctionProps } from "../Functions/Function.ts"; +import type { Providers } from "../Providers.ts"; +import { makeVercelOutputSite, resolveWebsiteProps } from "./internal.ts"; + +export interface NuxtProps extends Omit< + FunctionProps, + "main" | "script" | "prebuilt" | "source" | "build" +> { + /** + * Nuxt project root (the directory containing `nuxt.config.ts`). + * Defaults to the process working directory. + */ + rootDir?: string; + /** + * Build command run in {@link rootDir} with `NITRO_PRESET=vercel` set. + * @default "bunx nuxi build" + */ + command?: string; + /** + * Controls which files are content-hashed to decide whether a rebuild is + * needed. By default every non-gitignored file under `rootDir` (plus the + * nearest lockfile) is hashed — make sure `.nuxt`, `.output`, and + * `.vercel` are gitignored so build output doesn't churn the hash. + */ + memo?: MemoOptions | boolean; +} + +/** + * A [Nuxt](https://nuxt.com) app deployed to Vercel. + * + * `Nuxt` runs the project's own build with Nitro's built-in `vercel` + * preset (`NITRO_PRESET=vercel` — no adapter package or config changes + * needed), which emits a complete `.vercel/output` (Build Output v3) tree: + * server routes and SSR as serverless functions, client assets and + * prerendered pages as static files. The tree is deployed by the same + * engine as every `Vercel.Function` — same `env`, same skip-on-hash, same + * immutable-deployment semantics. + * + * Input files are content-hashed (respecting `.gitignore`) so unchanged + * projects skip the build and deploy entirely. + * + * @resource + * @product Website + * + * @section Deploying a Nuxt App + * @example Basic Nuxt site + * ```typescript + * const site = yield* Vercel.Website.Nuxt("Site", { + * rootDir: "./web", + * }); + * ``` + * + * @example Passing environment to the build and runtime + * `env` reaches both the build subprocess (baked into client code via + * `NUXT_PUBLIC_*`) and the deployed functions (as project env vars). + * ```typescript + * const site = yield* Vercel.Website.Nuxt("Site", { + * rootDir: "./web", + * env: { NUXT_PUBLIC_API_URL: api.url }, + * }); + * ``` + * + * @section Class Form + * @example Declaring a site class + * ```typescript + * class Site extends Vercel.Website.Nuxt()("Site", { + * rootDir: "./web", + * }) {} + * + * const site = yield* Site; + * ``` + */ +export const Nuxt: { + (): ( + id: string, + props?: + | InputProps + | Effect.Effect, never, Req>, + ) => Effect.Effect & { + new (_: never): Function; + }; + ( + id: string, + props?: + | InputProps + | Effect.Effect, never, Req>, + ): Effect.Effect; +} = ((id?: any, propsEff?: any) => + id === undefined + ? (id: string, propsEff: any) => effectClass(makeNuxt(id, propsEff)) + : makeNuxt(id, propsEff)) as any; + +const makeNuxt = (id: string, propsEff: any) => + Effect.gen(function* () { + const props = yield* resolveWebsiteProps(propsEff); + return yield* makeVercelOutputSite({ + id, + props, + defaultCommand: "bunx nuxi build", + buildEnv: { NITRO_PRESET: "vercel" }, + }); + }); diff --git a/packages/alchemy/src/Vercel/Website/Source.ts b/packages/alchemy/src/Vercel/Website/Source.ts new file mode 100644 index 0000000000..9440c43a76 --- /dev/null +++ b/packages/alchemy/src/Vercel/Website/Source.ts @@ -0,0 +1,78 @@ +/** + * The serializable source descriptor a `Vercel.Website.*` transformer sets + * on `Vercel.Function` props (DESIGN §4.3 / §7.1), plus the resolver the + * FunctionProvider uses to turn it into a {@link DeploymentArtifact}. + * + * The descriptor is plain JSON data — it persists in state (`olds`) and + * participates in props identity, so a changed `hash` (the producing + * build's content hash) flows through the engine's default props + * comparison and forces an update without the provider re-walking the + * tree. + */ +import * as Effect from "effect/Effect"; +import type * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import type { PlatformError } from "effect/PlatformError"; +import { initialCwd } from "../../Util/Node.ts"; +import type { DeploymentArtifact } from "../Deploy/Artifact.ts"; +import { + fromBuildOutputDir, + fromStaticDir, + type BuildOutputRoute, +} from "../Deploy/BuildOutput.ts"; + +/** + * A built website source consumed by the Vercel deploy engine. + * + * - `kind: "static"` — `dir` is a directory of plain files; the engine + * wraps it in a static-only Build Output artifact (everything under + * `static/`, filesystem routing). + * - `kind: "buildOutput"` — `dir` is a complete `.vercel/output` (Build + * Output v3) tree emitted by a framework's Vercel preset/adapter, + * passed through as-is. + */ +export interface FunctionSourceDescriptor { + /** Which Build Output shape {@link dir} holds. */ + readonly kind: "static" | "buildOutput"; + /** + * The built directory — relative to the initial working directory (the + * `Command.Build` output convention) or absolute. + */ + readonly dir: string; + /** + * Content hash of {@link dir} (a `Command.Build`'s output hash). Not + * read by the engine directly — it participates in props identity so a + * content change triggers an update even before the artifact is + * re-hashed. + */ + readonly hash?: string | undefined; + /** + * Extra route entries merged BEFORE the filesystem handler + * (`kind: "static"` only — a `buildOutput` tree carries its own + * `config.json`). + */ + readonly routes?: BuildOutputRoute[]; +} + +/** + * Resolve a {@link FunctionSourceDescriptor} to a + * {@link DeploymentArtifact}. Used by the FunctionProvider's `diff` (code + * identity) and `reconcile` (artifact assembly) — both paths hash the + * actual tree, so the descriptor's `hash` is never load-bearing. + */ +export const artifactFromSource = ( + source: FunctionSourceDescriptor, +): Effect.Effect< + DeploymentArtifact, + PlatformError, + FileSystem.FileSystem | Path.Path +> => + Effect.gen(function* () { + const path = yield* Path.Path; + const dir = path.isAbsolute(source.dir) + ? source.dir + : path.resolve(initialCwd, source.dir); + return source.kind === "static" + ? yield* fromStaticDir(dir, source.routes) + : yield* fromBuildOutputDir(dir); + }); diff --git a/packages/alchemy/src/Vercel/Website/StaticSite.ts b/packages/alchemy/src/Vercel/Website/StaticSite.ts new file mode 100644 index 0000000000..5698093668 --- /dev/null +++ b/packages/alchemy/src/Vercel/Website/StaticSite.ts @@ -0,0 +1,174 @@ +import * as Effect from "effect/Effect"; +import * as Command from "../../Command/index.ts"; +import type { MemoOptions } from "../../Command/Memo.ts"; +import type { InputProps } from "../../Input.ts"; +import * as Namespace from "../../Namespace.ts"; +import { effectClass } from "../../Util/effect.ts"; +import type { BuildOutputRoute } from "../Deploy/BuildOutput.ts"; +import { Function, type FunctionProps } from "../Functions/Function.ts"; +import type { Providers } from "../Providers.ts"; +import { resolveWebsiteProps, serializeBuildEnv } from "./internal.ts"; + +export interface StaticSiteProps extends Omit< + FunctionProps, + | "main" + | "script" + | "prebuilt" + | "source" + | "build" + | "runtime" + | "resources" + | "regions" +> { + /** + * Shell command that produces {@link outdir} (e.g. `npm run build`, + * `hugo --minify`, or a `./build.sh`). + */ + command: string; + /** + * Working directory for {@link command}. Defaults to the process working + * directory. + */ + cwd?: string; + /** + * The output directory produced by the build, relative to {@link cwd}. + * Every file under it deploys as a static asset. + * @example "dist" + */ + outdir: string; + /** + * Controls which files are hashed to decide whether the build should + * re-run. By default every non-gitignored file in `cwd` is hashed, plus + * the nearest lockfile. Provide explicit globs to narrow the scope, or + * `false` to rebuild on every deploy. + * @default true + */ + memo?: MemoOptions | boolean; + /** + * Extra Build Output route entries merged BEFORE the filesystem handler + * (redirects, headers, rewrites). + */ + routes?: BuildOutputRoute[]; +} + +/** + * A static site served from a Vercel project, built by a shell command. + * + * `StaticSite` runs a build command (via `Command.Build`), content-hashes + * the output directory, and deploys the result as a static-only Build + * Output deployment — no serverless function ships, Vercel's static layer + * answers every request. Use this when your site has its own build step + * that produces a directory of files: Hugo, Eleventy, Zola, or any custom + * pipeline. + * + * Unchanged inputs skip the rebuild (content-hash memoization) and an + * unchanged output tree skips the deploy entirely (the deployment id is + * stable across no-op runs). + * + * @resource + * @product Website + * + * @section Basic Usage + * Point `command` at your build script and `outdir` at where it writes + * output. + * + * @example Deploying a static site + * ```typescript + * const site = yield* Vercel.Website.StaticSite("Blog", { + * command: "hugo --minify", + * outdir: "public", + * }); + * ``` + * + * @section Building from a Subdirectory + * Set `cwd` to run the build command in a subdirectory (e.g. a monorepo + * package). `outdir` is resolved relative to `cwd`. + * + * @example Building a frontend in a monorepo + * ```typescript + * const site = yield* Vercel.Website.StaticSite("Web", { + * cwd: "apps/web", + * command: "npm run build", + * outdir: "dist", + * }); + * ``` + * + * @section Custom Rebuild Scope + * By default, all non-gitignored files under `cwd` are hashed to decide + * whether the build should re-run. Use `memo` to narrow the scope. + * + * @example Narrowing the memo scope + * ```typescript + * const site = yield* Vercel.Website.StaticSite("Docs", { + * command: "npm run build", + * outdir: "dist", + * memo: { include: ["content/**", "templates/**", "config.toml"] }, + * }); + * ``` + * + * @section Class Form + * Calling `StaticSite` with no arguments returns a constructor you can + * `extend` to declare the site as a named class — both an `Effect` you can + * `yield*` to deploy and a type you can reference elsewhere. + * + * @example Declaring a site class + * ```typescript + * class Blog extends Vercel.Website.StaticSite()("Blog", { + * command: "hugo --minify", + * outdir: "public", + * }) {} + * + * const site = yield* Blog; + * ``` + */ +export const StaticSite: { + (): ( + id: string, + props: + | InputProps + | Effect.Effect, never, Req>, + ) => Effect.Effect & { + new (_: never): Function; + }; + ( + id: string, + props: + | InputProps + | Effect.Effect, never, Req>, + ): Effect.Effect; +} = ((id?: any, propsEff?: any) => + id === undefined + ? (id: string, propsEff: any) => effectClass(makeStaticSite(id, propsEff)) + : makeStaticSite(id, propsEff)) as any; + +const makeStaticSite = (id: string, propsEff: any) => + Effect.gen(function* () { + const props = yield* resolveWebsiteProps(propsEff); + // `Build` carries a constant logical id, so it is namespaced under the + // site's id to keep two sites on one stack from colliding. The + // Function itself resolves in the CALLER's namespace. + const build = yield* Command.Build("Build", { + command: props.command, + cwd: props.cwd, + outdir: props.outdir, + memo: props.memo, + env: yield* serializeBuildEnv(props.env), + }).pipe(Namespace.push(id)); + const { + command: _command, + cwd: _cwd, + outdir: _outdir, + memo: _memo, + routes, + ...fnProps + } = props; + return yield* Function(id, { + ...fnProps, + source: { + kind: "static", + dir: build.outdir, + hash: build.hash.output, + ...(routes !== undefined ? { routes } : {}), + }, + } as any); + }); diff --git a/packages/alchemy/src/Vercel/Website/SvelteKit.ts b/packages/alchemy/src/Vercel/Website/SvelteKit.ts new file mode 100644 index 0000000000..beb5242fda --- /dev/null +++ b/packages/alchemy/src/Vercel/Website/SvelteKit.ts @@ -0,0 +1,95 @@ +import * as Effect from "effect/Effect"; +import type { MemoOptions } from "../../Command/Memo.ts"; +import type { InputProps } from "../../Input.ts"; +import { effectClass } from "../../Util/effect.ts"; +import { Function, type FunctionProps } from "../Functions/Function.ts"; +import type { Providers } from "../Providers.ts"; +import { makeVercelOutputSite, resolveWebsiteProps } from "./internal.ts"; + +export interface SvelteKitProps extends Omit< + FunctionProps, + "main" | "script" | "prebuilt" | "source" | "build" +> { + /** + * SvelteKit project root (the directory containing `svelte.config.js`). + * Defaults to the process working directory. + */ + rootDir?: string; + /** + * Build command run in {@link rootDir}. + * @default "bunx vite build" + */ + command?: string; + /** + * Controls which files are content-hashed to decide whether a rebuild is + * needed. By default every non-gitignored file under `rootDir` (plus the + * nearest lockfile) is hashed — make sure `.svelte-kit` and `.vercel` + * are gitignored so build output doesn't churn the hash. + */ + memo?: MemoOptions | boolean; +} + +/** + * A [SvelteKit](https://svelte.dev/docs/kit) app deployed to Vercel. + * + * `SvelteKit` runs the project's own Vite build; the + * [`@sveltejs/adapter-vercel`](https://svelte.dev/docs/kit/adapter-vercel) + * adapter (which must be installed and declared in the project's + * `svelte.config.js`) emits a complete `.vercel/output` (Build Output v3) + * tree — server routes and SSR as serverless functions, client assets and + * prerendered pages as static files. The tree is deployed by the same + * engine as every `Vercel.Function`. + * + * Input files are content-hashed (respecting `.gitignore`) so unchanged + * projects skip the build and deploy entirely. + * + * @resource + * @product Website + * + * @section Deploying a SvelteKit App + * @example Basic SvelteKit site + * ```typescript + * const site = yield* Vercel.Website.SvelteKit("Site", { + * rootDir: "./web", + * }); + * ``` + * + * @section Class Form + * @example Declaring a site class + * ```typescript + * class Site extends Vercel.Website.SvelteKit()("Site", { + * rootDir: "./web", + * }) {} + * + * const site = yield* Site; + * ``` + */ +export const SvelteKit: { + (): ( + id: string, + props?: + | InputProps + | Effect.Effect, never, Req>, + ) => Effect.Effect & { + new (_: never): Function; + }; + ( + id: string, + props?: + | InputProps + | Effect.Effect, never, Req>, + ): Effect.Effect; +} = ((id?: any, propsEff?: any) => + id === undefined + ? (id: string, propsEff: any) => effectClass(makeSvelteKit(id, propsEff)) + : makeSvelteKit(id, propsEff)) as any; + +const makeSvelteKit = (id: string, propsEff: any) => + Effect.gen(function* () { + const props = yield* resolveWebsiteProps(propsEff); + return yield* makeVercelOutputSite({ + id, + props, + defaultCommand: "bunx vite build", + }); + }); diff --git a/packages/alchemy/src/Vercel/Website/index.ts b/packages/alchemy/src/Vercel/Website/index.ts new file mode 100644 index 0000000000..54a04e9480 --- /dev/null +++ b/packages/alchemy/src/Vercel/Website/index.ts @@ -0,0 +1,7 @@ +export * from "./Astro.ts"; +export * from "./Nuxt.ts"; +export * from "./Source.ts"; +export * from "./StaticSite.ts"; +export * from "./SvelteKit.ts"; +// NOTE: ./internal.ts (build-env serialization + the shared framework +// transformer) is deliberately NOT exported. diff --git a/packages/alchemy/src/Vercel/Website/internal.ts b/packages/alchemy/src/Vercel/Website/internal.ts new file mode 100644 index 0000000000..83edee4745 --- /dev/null +++ b/packages/alchemy/src/Vercel/Website/internal.ts @@ -0,0 +1,133 @@ +/** + * Shared scaffolding for the `Vercel.Website.*` props-transformers. + * NOT exported from `Website/index.ts` (or `Vercel/index.ts`). + */ +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import * as Command from "../../Command/index.ts"; +import * as Namespace from "../../Namespace.ts"; +import * as Output from "../../Output.ts"; +import { + isYieldableEffectLike, + type YieldableEffectLike, +} from "../../Util/effect.ts"; +import { asEffect } from "../../Util/types.ts"; +import { Function } from "../Functions/Function.ts"; + +/** + * Serialize a Website's `env` for the build subprocess. The same record + * doubles as the Function's project-env input, so entries may be plain + * strings, `Redacted` secrets, `effect/Config` values, `Output` + * references, or binding sentinels: + * + * - strings and `Redacted` values pass through unchanged + * - `Output` references resolve at reconcile; the resolved value is + * serialized the same way inline values are + * - binding sentinels (`~alchemy/Kind`-marked, e.g. `Function.URL`) have + * no build-time env representation and are dropped (the Function + * provider substitutes them during project-env sync) + * - `Config` (and any other runnable Effect) is resolved here, at stack + * construction + * - remaining plain values (`null`, numbers, JSON objects) are stringified + */ +export const serializeBuildEnv = Effect.fn(function* ( + env: Record | undefined, +) { + const entries: [string, unknown][] = []; + for (const [k, v] of Object.entries(env ?? {})) { + if (v === undefined) continue; + if (typeof v === "string" || Redacted.isRedacted(v)) { + entries.push([k, v]); + } else if (Output.isOutput(v)) { + entries.push([k, Output.map(v, serializeBuildEnvValue)]); + } else if (v !== null && typeof v === "object" && "~alchemy/Kind" in v) { + // Binding sentinel (`Function.URL` is Effect-shaped AND kind-marked, + // so this check must precede the runnable-Effect branch) — deploy-time + // only, nothing to expose to the build subprocess. + continue; + } else if (isYieldableEffectLike(v)) { + const resolved = serializeBuildEnvValue( + yield* asEffect(v as YieldableEffectLike).pipe( + Effect.orDie, + ), + ); + if (resolved === undefined) continue; + entries.push([k, resolved]); + } else { + entries.push([k, JSON.stringify(v)]); + } + } + return Object.fromEntries(entries) as Record< + string, + string | Redacted.Redacted + >; +}); + +const serializeBuildEnvValue = ( + value: unknown, +): string | Redacted.Redacted | undefined => + value === undefined + ? undefined + : typeof value === "string" + ? value + : Redacted.isRedacted(value) + ? (value as Redacted.Redacted) + : JSON.stringify(value); + +/** + * The shared shape of a framework Website transformer: run the framework's + * own build (whose Vercel preset/adapter emits `.vercel/output` natively) + * via a `Command.Build` namespaced under the site's id, then deploy the + * tree through `Vercel.Function`'s `source` channel. + * + * The `Command.Build` is memoized by content hash of the project inputs + * (`.gitignore`-aware plus the nearest lockfile by default), and the + * Function's own skip-on-hash makes an unchanged tree a no-op deploy. + */ +export const makeVercelOutputSite = (input: { + id: string; + props: any; + /** Build command used when `props.command` is absent. */ + defaultCommand: string; + /** Env forced onto the build subprocess (e.g. `NITRO_PRESET=vercel`). */ + buildEnv?: Record; +}) => + Effect.gen(function* () { + const props = input.props; + // `Build` carries a constant logical id, so it is namespaced under the + // site's id to keep two sites on one stack from colliding. The + // Function itself resolves in the CALLER's namespace. + const build = yield* Command.Build("Build", { + command: props.command ?? input.defaultCommand, + cwd: props.rootDir, + outdir: ".vercel/output", + memo: props.memo, + env: { + ...(yield* serializeBuildEnv(props.env)), + ...input.buildEnv, + }, + }).pipe(Namespace.push(input.id)); + const { + rootDir: _rootDir, + command: _command, + memo: _memo, + ...fnProps + } = props; + return yield* Function(input.id, { + ...fnProps, + source: { + kind: "buildOutput", + dir: build.outdir, + hash: build.hash.output, + }, + } as any); + }); + +/** Resolve a Website's props argument (plain object or Effect) to props. */ +export const resolveWebsiteProps = Effect.fn(function* (propsEff: unknown) { + const props: any = + (Effect.isEffect(propsEff) + ? yield* propsEff as Effect.Effect + : propsEff) ?? {}; + return props; +}); diff --git a/packages/alchemy/src/Vercel/index.ts b/packages/alchemy/src/Vercel/index.ts new file mode 100644 index 0000000000..77da0f2af7 --- /dev/null +++ b/packages/alchemy/src/Vercel/index.ts @@ -0,0 +1,35 @@ +export * from "./AccessGroups/index.ts"; +export * from "./Aliases/index.ts"; +export * from "./Analytics/index.ts"; +export * from "./AuthProvider.ts"; +export * from "./Blob/index.ts"; +export * from "./Checks/index.ts"; +export * from "./Credentials.ts"; +export * from "./Domains/index.ts"; +export * from "./Drains/Drain.ts"; +export * from "./EdgeCache/Purge.ts"; +export * from "./EdgeConfig/index.ts"; +export * from "./Environments/index.ts"; +export * from "./FeatureFlags/index.ts"; +export * from "./Functions/CronEventSource.ts"; +export * from "./Functions/Function.ts"; +export * from "./Functions/FunctionBridge.ts"; +export * from "./Functions/InvokeFunction.ts"; +export * from "./Microfrontends/index.ts"; +export * from "./ProjectMembers/ProjectMember.ts"; +export * from "./Projects/Env.ts"; +export * from "./Projects/Project.ts"; +export * from "./Projects/RollingRelease.ts"; +export * from "./Providers.ts"; +export * from "./Queues/index.ts"; +export * from "./Routes/BulkRedirects.ts"; +export * from "./Routes/ProjectRoutes.ts"; +export * from "./Sandboxes/index.ts"; +export * from "./Security/FirewallConfig.ts"; +export * from "./StateStore/index.ts"; +export * from "./Teams/index.ts"; +export * from "./VercelEnvironment.ts"; +export * from "./Webhooks/Webhook.ts"; +export * as Website from "./Website/index.ts"; +// NOTE: ./Deploy/* (the deploy engine) is deliberately NOT exported — it is +// an internal library shared by the Function/Website providers (DESIGN §6.1). diff --git a/packages/alchemy/test/Cloudflare/Workers/FinalizerLatency.test.ts b/packages/alchemy/test/Cloudflare/Workers/FinalizerLatency.test.ts new file mode 100644 index 0000000000..788013870a --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Workers/FinalizerLatency.test.ts @@ -0,0 +1,102 @@ +/** + * Pins the documented bridge contract (AGENTS.md + Worker JSDoc): + * "`Effect.addFinalizer` in a handler runs post-response via `ctx.waitUntil`" + * — including the LATENCY half of that promise. + * + * `EffectHttp.toHandled` runs the handler under its OWN internal per-request + * scope and closes it INLINE right after the response callback, so without + * scope ejection (see `toHandledWebResponse` in + * `src/Cloudflare/Workers/HttpServer.ts`) a handler's `Effect.addFinalizer` + * delays the HTTP response by the finalizer's full duration. The fixture's + * `/finalize` route registers a 3s finalizer; the timed request must come + * back well under that, and the finalizer must still run afterwards + * (durable-object journal readback, isolate-independent). + */ +import * as Cloudflare from "@/Cloudflare"; +import * as Test from "@/Test/Alchemy"; +import { describe, expect } from "alchemy-test"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import { expectUrlContains } from "../Utils/Http.ts"; +import Stack from "./fixtures/finalizer-latency/stack.ts"; + +const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ + providers: Cloudflare.providers(), + state: Cloudflare.state(), +}); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +const stack = beforeAll(deploy(Stack)); +afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack)); + +// Cache-busting query param on every request: after destroy+recreate of the +// same workers.dev subdomain the edge can serve a cached placeholder for the +// bare URL. Route matching uses `url.pathname`, so the param is invisible to +// the fixture. +let bust = 0; +const getText = ( + client: HttpClient.HttpClient, + url: string, +): Effect.Effect => + client + .get(`${url}?cb=${Date.now()}-${bust++}`) + .pipe(Effect.flatMap((res) => res.text)); + +describe.skipIf(!!process.env.FAST)( + "request finalizers never delay the response", + () => { + test( + "a 3s Effect.addFinalizer does not delay the fetch response (and still runs post-response)", + Effect.gen(function* () { + const { url } = yield* stack; + const client = yield* HttpClient.HttpClient; + + // Content-based readiness through workers.dev propagation — this + // also warms the isolate so the timed request below measures the + // handler, not a cold start. + yield* expectUrlContains(`${url}/ready`, "ready-ok", { + label: "finalizer-latency worker propagation", + }); + + // The timed request: the handler registers a 3s finalizer then + // responds. The contract is that the finalizer settles post-response + // under ctx.waitUntil — so the round-trip must NOT include the 3s. + // (Against the pre-fix bridge this measures ~3.0-3.5s.) + const [elapsed, body] = yield* Effect.timed( + getText(client, `${url}/finalize`), + ); + expect(body).toBe("finalizer-scheduled"); + yield* Effect.log( + `/finalize round-trip: ${Duration.toMillis(elapsed)}ms (3s finalizer)`, + ); + expect(Duration.toMillis(elapsed)).toBeLessThan(2500); + + // ...and the finalizer DID run afterwards: it appends a journal + // entry to the Durable Object ~3s after the response. + const entries = yield* Effect.gen(function* () { + const text = yield* getText(client, `${url}/entries`); + const parsed = yield* Effect.try( + () => JSON.parse(text) as { entries?: string[] }, + ); + return parsed.entries ?? []; + }).pipe( + Effect.catch(() => Effect.succeed([] as string[])), + Effect.repeat({ + schedule: Schedule.spaced("1 second"), + until: (entries) => entries.includes("slow-finalizer-ran"), + times: 30, + }), + ); + expect(entries).toContain("slow-finalizer-ran"); + }).pipe(logLevel), + { timeout: 180_000 }, + ); + }, +); diff --git a/packages/alchemy/test/Cloudflare/Workers/fixtures/finalizer-latency/stack.ts b/packages/alchemy/test/Cloudflare/Workers/fixtures/finalizer-latency/stack.ts new file mode 100644 index 0000000000..69f8f38c3b --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Workers/fixtures/finalizer-latency/stack.ts @@ -0,0 +1,18 @@ +import * as Cloudflare from "@/Cloudflare"; +import * as Alchemy from "@/index.ts"; +import * as Effect from "effect/Effect"; +import FinalizerLatencyWorker from "./worker.ts"; + +export default Alchemy.Stack( + "FinalizerLatencyStack", + { + providers: Cloudflare.providers(), + state: Cloudflare.state(), + }, + Effect.gen(function* () { + const worker = yield* FinalizerLatencyWorker; + return { + url: worker.url.as(), + }; + }), +); diff --git a/packages/alchemy/test/Cloudflare/Workers/fixtures/finalizer-latency/worker.ts b/packages/alchemy/test/Cloudflare/Workers/fixtures/finalizer-latency/worker.ts new file mode 100644 index 0000000000..97a50409a0 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Workers/fixtures/finalizer-latency/worker.ts @@ -0,0 +1,72 @@ +import * as Cloudflare from "@/Cloudflare/index.ts"; +import * as Effect from "effect/Effect"; +import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; + +/** + * Durable Object journal for `FinalizerLatency.test.ts` — durable storage so + * the "finalizer actually ran" readback is isolate-independent (a module + * global would only be visible to the isolate that served the request). + */ +export class LatencyJournal extends Cloudflare.DurableObject()( + "LatencyJournal", + Effect.gen(function* () { + const state = yield* Cloudflare.DurableObjectState; + return Effect.gen(function* () { + return { + record: Effect.fn(function* (entry: string) { + const entries = (yield* state.storage.get("entries")) ?? []; + yield* state.storage.put("entries", [...entries, entry]); + }), + snapshot: Effect.fn(function* () { + return { + entries: (yield* state.storage.get("entries")) ?? [], + }; + }), + }; + }); + }), +) {} + +/** + * Fixture worker for `FinalizerLatency.test.ts`. + * + * `GET /finalize` registers a SLOW (3s) `Effect.addFinalizer` and responds + * immediately. The documented bridge contract is that request-scope + * finalizers settle post-response via `ctx.waitUntil` — so the response + * latency must NOT include the 3s. The finalizer records a journal entry + * when it completes, which the test reads back via `GET /entries`. + */ +export default class FinalizerLatencyWorker extends Cloudflare.Worker()( + "FinalizerLatencyWorker", + { + main: import.meta.url, + }, + Effect.gen(function* () { + const journals = yield* LatencyJournal; + + return { + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; + const url = new URL(request.url, "http://x"); + const journal = journals.getByName("default"); + + if (url.pathname === "/finalize") { + yield* Effect.addFinalizer(() => + Effect.sleep("3 seconds").pipe( + Effect.andThen(journal.record("slow-finalizer-ran")), + Effect.ignore, + ), + ); + return HttpServerResponse.text("finalizer-scheduled"); + } + + if (url.pathname === "/entries") { + return yield* HttpServerResponse.json(yield* journal.snapshot()); + } + + return HttpServerResponse.text("ready-ok"); + }), + }; + }), +) {} diff --git a/packages/alchemy/test/Vercel/AIGateway/AIGateway.test.ts b/packages/alchemy/test/Vercel/AIGateway/AIGateway.test.ts new file mode 100644 index 0000000000..029ae797d3 --- /dev/null +++ b/packages/alchemy/test/Vercel/AIGateway/AIGateway.test.ts @@ -0,0 +1,40 @@ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as aiGateway from "@distilled.cloud/vercel/ai_gateway"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Result from "effect/Result"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +// The AI Gateway routing-rules API (`/v1/ai-gateway/rules`) is documented +// in Vercel's OpenAPI spec but NOT deployed on the live platform: every +// call answers 404 {"code":"not_found","message":"The requested API +// endpoint was not found."} (verified Aug 2026 on the standing Pro team). +// No AIGatewayRule resource is implemented until the endpoint ships — the +// create/update operations don't even carry request bodies in the spec, so +// their wire contract is unknowable. This ungated probe pins the typed +// availability rejection (via the distilled 404 patch) so the day the +// endpoint goes live, this test flips and tells us to build the resource. +test.provider( + "AI Gateway rules API is not deployed: typed NotFound on list", + () => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + const listed = yield* Effect.result( + aiGateway.listAiGatewayRules({ teamId }), + ); + if (Result.isSuccess(listed)) { + return yield* Effect.die( + "listAiGatewayRules unexpectedly succeeded — the AI Gateway rules API has shipped; implement Vercel.AIGatewayRule (probe the create/update body contract first)", + ); + } + expect(listed.failure._tag).toBe("NotFound"); + }).pipe(logLevel), +); diff --git a/packages/alchemy/test/Vercel/AccessGroups/AccessGroup.test.ts b/packages/alchemy/test/Vercel/AccessGroups/AccessGroup.test.ts new file mode 100644 index 0000000000..c8a1b542fe --- /dev/null +++ b/packages/alchemy/test/Vercel/AccessGroups/AccessGroup.test.ts @@ -0,0 +1,168 @@ +import * as Provider from "@/Provider"; +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as accessGroups from "@distilled.cloud/vercel/access_groups"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Result from "effect/Result"; +import * as Schedule from "effect/Schedule"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +// Access Groups are an Enterprise-plan feature. On the standing (Pro) testing +// team every access-group op — including reads — answers 403. The ungated +// probe below pins that typed rejection; the full lifecycle runs only on an +// entitled account with VERCEL_TEST_ACCESS_GROUPS=1. +const ENTITLED = !!process.env.VERCEL_TEST_ACCESS_GROUPS; + +// Deterministic names — same on every run. +const PROBE_NAME = "alchemy-access-group-probe"; +const NAME_RENAMED = "alchemy-test-access-group-renamed"; + +const teamScopeOf = Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + return teamId === undefined ? {} : { teamId }; +}); + +// Typed "is it gone?" wait — bounded, NotFound maps to gone. +const waitUntilAccessGroupGone = ( + idOrName: string, + scope: { teamId?: string }, +) => + accessGroups.readAccessGroup({ idOrName, ...scope }).pipe( + Effect.map(() => true), + Effect.catchTag("NotFound", () => Effect.succeed(false)), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (exists) => !exists, + times: 10, + }), + ); + +// Ungated probe: proves the entitlement gate surfaces as the TYPED `Forbidden` +// tag (never a catch-all) on both a write and a read. Skipped on entitled +// accounts, where the gated lifecycle below runs instead. +test.provider.skipIf(ENTITLED)( + "access groups are Enterprise-gated: typed Forbidden on create and read", + () => + Effect.gen(function* () { + const scope = yield* teamScopeOf; + + const created = yield* Effect.result( + accessGroups.createAccessGroup({ name: PROBE_NAME, ...scope }), + ); + if (Result.isSuccess(created)) { + // The account is actually entitled — clean up the probe group and + // direct the runner to the gated lifecycle suite. + yield* accessGroups + .deleteAccessGroup({ + idOrName: created.success.accessGroupId, + ...scope, + }) + .pipe(Effect.catchTag("NotFound", () => Effect.void)); + return yield* Effect.die( + "createAccessGroup unexpectedly succeeded — this account is entitled; run with VERCEL_TEST_ACCESS_GROUPS=1", + ); + } + expect(created.failure._tag).toBe("Forbidden"); + if (created.failure._tag === "Forbidden") { + expect(created.failure.message).toContain("permission"); + } + + const read = yield* Effect.result( + accessGroups.readAccessGroup({ + idOrName: "ag_nonexistent0000000000000000", + ...scope, + }), + ); + expect(Result.isFailure(read)).toBe(true); + if (Result.isFailure(read)) { + expect(read.failure._tag).toBe("Forbidden"); + } + }).pipe(logLevel), +); + +test.provider.skipIf(!ENTITLED)( + "create, verify, rename, and destroy an access group", + (stack) => + Effect.gen(function* () { + const scope = yield* teamScopeOf; + + yield* stack.destroy(); + + // Create with the engine-generated deterministic name and exact + // (empty) managed membership. + const group = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.AccessGroup("Group", { members: [] }); + }), + ); + expect(group.accessGroupId).toMatch(/^ag_/); + expect(group.name).toBeDefined(); + expect(group.members).toEqual([]); + + // Out-of-band verification via distilled. + const observed = yield* accessGroups.readAccessGroup({ + idOrName: group.accessGroupId, + ...scope, + }); + expect(observed.name).toEqual(group.name); + expect(observed.teamId).toEqual(group.teamId); + + // Rename in place — same accessGroupId (update, not replace). + const renamed = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.AccessGroup("Group", { + name: NAME_RENAMED, + members: [], + }); + }), + ); + expect(renamed.accessGroupId).toEqual(group.accessGroupId); + expect(renamed.name).toEqual(NAME_RENAMED); + + const observedRenamed = yield* accessGroups.readAccessGroup({ + idOrName: group.accessGroupId, + ...scope, + }); + expect(observedRenamed.name).toEqual(NAME_RENAMED); + + yield* stack.destroy(); + const gone = yield* waitUntilAccessGroupGone(group.accessGroupId, scope); + expect(gone).toBe(false); + }).pipe(logLevel), + { timeout: 120_000 }, +); + +test.provider.skipIf(!ENTITLED)( + "list enumerates the deployed access group", + (stack) => + Effect.gen(function* () { + const scope = yield* teamScopeOf; + + yield* stack.destroy(); + + const group = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.AccessGroup("ListGroup", {}); + }), + ); + + const provider = yield* Provider.findProvider(Vercel.AccessGroup); + const all = yield* provider.list(); + const found = all.find((g) => g.accessGroupId === group.accessGroupId); + expect(found).toBeDefined(); + expect(found?.name).toEqual(group.name); + + yield* stack.destroy(); + const gone = yield* waitUntilAccessGroupGone(group.accessGroupId, scope); + expect(gone).toBe(false); + }).pipe(logLevel), + { timeout: 120_000 }, +); diff --git a/packages/alchemy/test/Vercel/AccessGroups/AccessGroupProject.test.ts b/packages/alchemy/test/Vercel/AccessGroups/AccessGroupProject.test.ts new file mode 100644 index 0000000000..3aa56d1283 --- /dev/null +++ b/packages/alchemy/test/Vercel/AccessGroups/AccessGroupProject.test.ts @@ -0,0 +1,205 @@ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as accessGroups from "@distilled.cloud/vercel/access_groups"; +import * as projects from "@distilled.cloud/vercel/projects"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Result from "effect/Result"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +// Access Groups are an Enterprise-plan feature (see AccessGroup.test.ts for +// the ungated Forbidden probe). The lifecycle below runs only on an entitled +// account with VERCEL_TEST_ACCESS_GROUPS=1. +const ENTITLED = !!process.env.VERCEL_TEST_ACCESS_GROUPS; + +// Deterministic host-project names — one per test so concurrently running +// tests never fight over a fixture. +const HOST_LIFECYCLE = "alchemy-access-groups-host-lifecycle"; +const HOST_REPLACE = "alchemy-access-groups-host-replace"; + +const teamScopeOf = Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + return teamId === undefined ? {} : { teamId }; +}); + +// Out-of-band host-project fixture (NOT the Vercel.Project resource — the +// attachment tests must not depend on a concurrently-owned provider). +// Delete-if-exists first so an interrupted previous run can't wedge the +// deterministic name. +const ensureHostProject = (name: string, scope: { teamId?: string }) => + Effect.gen(function* () { + yield* projects + .deleteProject({ idOrName: name, ...scope }) + .pipe(Effect.catchTag("NotFound", () => Effect.void)); + const created = yield* projects.createProject({ name, ...scope }); + return created.id; + }); + +// Finalizer-safe (used with `Effect.ensuring`): never fails. +const deleteHostProject = (name: string, scope: { teamId?: string }) => + projects.deleteProject({ idOrName: name, ...scope }).pipe(Effect.ignore); + +test.provider.skipIf(!ENTITLED)( + "create, verify, update role, and destroy an attachment", + (stack) => + Effect.gen(function* () { + const scope = yield* teamScopeOf; + const projectId = yield* ensureHostProject(HOST_LIFECYCLE, scope); + + yield* Effect.gen(function* () { + yield* stack.destroy(); + + const initial = yield* stack.deploy( + Effect.gen(function* () { + const group = yield* Vercel.AccessGroup("Group", {}); + const grant = yield* Vercel.AccessGroupProject("Grant", { + accessGroup: group, + projectId, + role: "PROJECT_VIEWER", + }); + return { group, grant }; + }), + ); + expect(initial.grant.accessGroupId).toEqual( + initial.group.accessGroupId, + ); + expect(initial.grant.projectId).toEqual(projectId); + expect(initial.grant.role).toEqual("PROJECT_VIEWER"); + + // Out-of-band verification via distilled. + const observed = yield* accessGroups.readAccessGroupProject({ + accessGroupIdOrName: initial.grant.accessGroupId, + projectId, + ...scope, + }); + expect(observed.role).toEqual("PROJECT_VIEWER"); + + // Role change is an in-place update — same (group, project) identity. + const updated = yield* stack.deploy( + Effect.gen(function* () { + const group = yield* Vercel.AccessGroup("Group", {}); + const grant = yield* Vercel.AccessGroupProject("Grant", { + accessGroup: group, + projectId, + role: "PROJECT_DEVELOPER", + }); + return { group, grant }; + }), + ); + expect(updated.grant.accessGroupId).toEqual( + initial.grant.accessGroupId, + ); + expect(updated.grant.role).toEqual("PROJECT_DEVELOPER"); + + const observedUpdated = yield* accessGroups.readAccessGroupProject({ + accessGroupIdOrName: initial.grant.accessGroupId, + projectId, + ...scope, + }); + expect(observedUpdated.role).toEqual("PROJECT_DEVELOPER"); + + yield* stack.destroy(); + + // Typed wait-until-gone: attachment and group both deleted. + const attachmentGone = yield* accessGroups + .readAccessGroupProject({ + accessGroupIdOrName: initial.grant.accessGroupId, + projectId, + ...scope, + }) + .pipe( + Effect.map(() => false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + // The group itself is gone too — a Forbidden here would be a + // regression, so let anything but NotFound propagate. + ); + expect(attachmentGone).toBe(true); + + const groupGone = yield* Effect.result( + accessGroups.readAccessGroup({ + idOrName: initial.group.accessGroupId, + ...scope, + }), + ); + expect( + Result.isFailure(groupGone) && groupGone.failure._tag === "NotFound", + ).toBe(true); + }).pipe(Effect.ensuring(deleteHostProject(HOST_LIFECYCLE, scope))); + }).pipe(logLevel), + { timeout: 120_000 }, +); + +test.provider.skipIf(!ENTITLED)( + "replaces the attachment when the access group changes", + (stack) => + Effect.gen(function* () { + const scope = yield* teamScopeOf; + const projectId = yield* ensureHostProject(HOST_REPLACE, scope); + + yield* Effect.gen(function* () { + yield* stack.destroy(); + + const initial = yield* stack.deploy( + Effect.gen(function* () { + const groupA = yield* Vercel.AccessGroup("GroupA", {}); + const groupB = yield* Vercel.AccessGroup("GroupB", {}); + const grant = yield* Vercel.AccessGroupProject("ReplaceGrant", { + accessGroup: groupA, + projectId, + role: "PROJECT_VIEWER", + }); + return { groupA, groupB, grant }; + }), + ); + expect(initial.grant.accessGroupId).toEqual( + initial.groupA.accessGroupId, + ); + + const replaced = yield* stack.deploy( + Effect.gen(function* () { + const groupA = yield* Vercel.AccessGroup("GroupA", {}); + const groupB = yield* Vercel.AccessGroup("GroupB", {}); + const grant = yield* Vercel.AccessGroupProject("ReplaceGrant", { + accessGroup: groupB, + projectId, + role: "PROJECT_VIEWER", + }); + return { groupA, groupB, grant }; + }), + ); + expect(replaced.grant.accessGroupId).toEqual( + replaced.groupB.accessGroupId, + ); + + // New attachment exists on B; old attachment on A is gone. + const onB = yield* accessGroups.readAccessGroupProject({ + accessGroupIdOrName: replaced.groupB.accessGroupId, + projectId, + ...scope, + }); + expect(onB.role).toEqual("PROJECT_VIEWER"); + + const onA = yield* accessGroups + .readAccessGroupProject({ + accessGroupIdOrName: initial.groupA.accessGroupId, + projectId, + ...scope, + }) + .pipe( + Effect.map(() => true), + Effect.catchTag("NotFound", () => Effect.succeed(false)), + ); + expect(onA).toBe(false); + + yield* stack.destroy(); + }).pipe(Effect.ensuring(deleteHostProject(HOST_REPLACE, scope))); + }).pipe(logLevel), + { timeout: 120_000 }, +); diff --git a/packages/alchemy/test/Vercel/Aliases/Alias.test.ts b/packages/alchemy/test/Vercel/Aliases/Alias.test.ts new file mode 100644 index 0000000000..73bdf19e85 --- /dev/null +++ b/packages/alchemy/test/Vercel/Aliases/Alias.test.ts @@ -0,0 +1,211 @@ +/** + * Vercel Alias lifecycle + promote/rollback action tests — live against the + * standing Vercel test team (run with the doppler alchemy-v2/dev env). + */ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as aliases from "@distilled.cloud/vercel/aliases"; +import * as projects from "@distilled.cloud/vercel/projects"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import { MinimumLogLevel } from "effect/References"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +const fixtureMain = new URL("./fixtures/handler.ts", import.meta.url).pathname; + +// Deterministic alias names — constant across runs (global .vercel.app +// namespace, owned by the standing test team after the first run). +const STABLE_ALIAS = "alchemy-vrc-alias-e2e.vercel.app"; + +const getJson = (url: string, bypass?: string) => + HttpClient.get( + url, + bypass !== undefined + ? { headers: { "x-vercel-protection-bypass": bypass } } + : undefined, + ).pipe( + Effect.flatMap((response) => + response.status === 200 + ? response.json + : Effect.fail(new Error(`status ${response.status}`)), + ), + // Fresh aliases/deployments take a few seconds to start serving — + // always retry the first request (bounded, ~15s worst case). + Effect.retry({ schedule: Schedule.spaced("1 second"), times: 15 }), + ); + +/** Poll (bounded) an URL until it reports the expected fixture version. */ +const expectVersion = (url: string, version: string, bypass?: string) => + Effect.gen(function* () { + const body = (yield* getJson(url, bypass).pipe( + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (b) => (b as { version: string | null }).version === version, + times: 20, + }), + )) as { version: string | null }; + expect(body.version).toEqual(version); + }); + +/** + * Mint an automation bypass secret on the project (PROBES.md: `.vercel.app` + * deployment aliases are SSO-gated on team accounts — only the auto-assigned + * production domain is public — and the secret only opens deployments + * created AFTER it was minted, so mint it before the deployments under + * test). + */ +const mintBypassSecret = (projectId: string) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + const response = yield* projects.updateProjectProtectionBypass({ + idOrName: projectId, + teamId, + generate: { note: "alias e2e" }, + }); + const secret = Object.keys(response.protectionBypass ?? {})[0]; + expect(secret).toBeDefined(); + return secret!; + }); + +/** Poll (bounded) until the function's project is gone. */ +const expectProjectGone = (projectId: string) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + const gone = yield* projects + .getProject({ idOrName: projectId, teamId }) + .pipe( + Effect.map(() => false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (g) => g, + times: 10, + }), + ); + expect(gone).toBe(true); + }); + +/** Out-of-band alias observation via distilled (undefined = gone). */ +const observeAlias = (aliasName: string) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + return yield* aliases + .getAlias({ idOrAlias: aliasName, teamId }) + .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))); + }); + +const makeFn = (version: string) => + Effect.gen(function* () { + return yield* Vercel.Function("Fn", { + main: fixtureMain, + env: { VERSION: version }, + }); + }); + +test.provider( + "alias a stable name to v1, re-point to v2, remove — alias gone, function intact", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + // 1. Bootstrap deploy (v0) to create the project, then mint the + // bypass secret — it only opens deployments created after it. + const v0 = yield* stack.deploy(makeFn("v0")); + const bypass = yield* mintBypassSecret(v0.projectId); + + // 2. Two retained deployments minted after the secret: v1, then v2 + // (env change forces a new immutable deployment; v1 stays + // retained — no pruning). + const v1 = yield* stack.deploy(makeFn("v1")); + const d1 = v1.deploymentId; + const v2 = yield* stack.deploy(makeFn("v2")); + const d2 = v2.deploymentId; + expect(d2).not.toEqual(d1); + expect(d1).not.toEqual(v0.deploymentId); + // Production alias now serves v2. + yield* expectVersion(`${v2.url}/`, "v2"); + + // 3. Alias the stable name to the OLD deployment (v1). + const makeStack = (deployment: string) => + Effect.gen(function* () { + const fn = yield* makeFn("v2"); + const alias = yield* Vercel.Alias("Stable", { + alias: STABLE_ALIAS, + deployment, + }); + return { fn, alias }; + }); + const step1 = yield* stack.deploy(makeStack(d1)); + expect(step1.alias.alias).toEqual(STABLE_ALIAS); + expect(step1.alias.uid).toBeDefined(); + expect(step1.alias.deploymentId).toEqual(d1); + expect(step1.alias.url).toEqual(`https://${STABLE_ALIAS}`); + // The function's own deployment was untouched (hash skip). + expect(step1.fn.deploymentId).toEqual(d2); + // The alias serves v1 while production serves v2. `.vercel.app` + // deployment aliases are SSO-gated on team accounts — send the + // bypass secret (both d1/d2 were minted after it). + yield* expectVersion(`${step1.alias.url}/`, "v1", bypass); + yield* expectVersion(`${step1.fn.url}/`, "v2"); + + // 4. Re-point the alias at v2 (assign is an upsert — same alias row). + const step2 = yield* stack.deploy(makeStack(d2)); + expect(step2.alias.deploymentId).toEqual(d2); + yield* expectVersion(`${step2.alias.url}/`, "v2", bypass); + + // 5. Idempotent re-deploy: no-op (alias already points at d2). + const step3 = yield* stack.deploy(makeStack(d2)); + expect(step3.alias.deploymentId).toEqual(d2); + + // 6. Remove the Alias from the stack — orphan delete. The alias is + // gone; the function (and its production alias) keeps serving. + const step4 = yield* stack.deploy(makeFn("v2")); + const observed = yield* observeAlias(STABLE_ALIAS); + expect(observed).toBeUndefined(); + yield* expectVersion(`${step4.url}/`, "v2"); + + // 7. Destroy cascades the owned project; typed wait-until-gone. + yield* stack.destroy(); + yield* expectProjectGone(v1.projectId); + }).pipe(logLevel), + { timeout: 240_000 }, +); + +test.provider( + "rollback production to the older deployment, then promote back", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const v1 = yield* stack.deploy(makeFn("v1")); + const d1 = v1.deploymentId; + const v2 = yield* stack.deploy(makeFn("v2")); + const d2 = v2.deploymentId; + expect(d2).not.toEqual(d1); + const productionUrl = v2.url!; + yield* expectVersion(`${productionUrl}/`, "v2"); + + // Rollback: production re-points to the retained v1 deployment — + // a pure traffic operation, no rebuild. + yield* Vercel.rollbackProduction(v2.projectId, d1, { + description: "alias e2e rollback", + }); + yield* expectVersion(`${productionUrl}/`, "v1"); + + // Promote back to v2. + yield* Vercel.promoteToProduction(v2.projectId, d2); + yield* expectVersion(`${productionUrl}/`, "v2"); + + yield* stack.destroy(); + yield* expectProjectGone(v2.projectId); + }).pipe(logLevel), + { timeout: 240_000 }, +); diff --git a/packages/alchemy/test/Vercel/Aliases/fixtures/handler.ts b/packages/alchemy/test/Vercel/Aliases/fixtures/handler.ts new file mode 100644 index 0000000000..4bcaf599ed --- /dev/null +++ b/packages/alchemy/test/Vercel/Aliases/fixtures/handler.ts @@ -0,0 +1,15 @@ +/** + * Async-mode Vercel Function fixture for alias tests: reports the VERSION + * env var it was deployed with, so two retained deployments (v1/v2) are + * distinguishable over HTTP. + */ +export default { + async fetch(request: Request): Promise { + const url = new URL(request.url); + return Response.json({ + ok: true, + version: process.env.VERSION ?? null, + path: url.pathname, + }); + }, +}; diff --git a/packages/alchemy/test/Vercel/Analytics/WebAnalytics.test.ts b/packages/alchemy/test/Vercel/Analytics/WebAnalytics.test.ts new file mode 100644 index 0000000000..7ab6dd5ab5 --- /dev/null +++ b/packages/alchemy/test/Vercel/Analytics/WebAnalytics.test.ts @@ -0,0 +1,116 @@ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as projects from "@distilled.cloud/vercel/projects"; +import * as webAnalytics from "@distilled.cloud/vercel/web_analytics"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Result from "effect/Result"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +// Deterministic out-of-band probe project — same name on every run. +const PROBE_PROJECT = "alchemy-test-web-analytics"; + +const teamScopeOf = Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + return teamId; +}); + +/** Create (or re-use) the out-of-band probe project. */ +const ensureProbeProject = Effect.gen(function* () { + const teamId = yield* teamScopeOf; + return yield* projects.getProject({ idOrName: PROBE_PROJECT, teamId }).pipe( + Effect.map((p) => p.id), + Effect.catchTag("NotFound", () => + projects + .createProject({ name: PROBE_PROJECT, teamId }) + .pipe(Effect.map((p) => p.id)), + ), + ); +}); + +const deleteProbeProject = Effect.gen(function* () { + const teamId = yield* teamScopeOf; + yield* projects + .deleteProject({ idOrName: PROBE_PROJECT, teamId }) + .pipe(Effect.catchTag("NotFound", () => Effect.void)); +}); + +const readProbeAnalytics = Effect.gen(function* () { + const teamId = yield* teamScopeOf; + const project = yield* projects.getProject({ + idOrName: PROBE_PROJECT, + teamId, + }); + return project.webAnalytics; +}); + +test.provider( + "enable, no-op redeploy, and disable web analytics on a probe project", + (stack) => + Effect.gen(function* () { + const projectId = yield* ensureProbeProject; + + yield* stack.destroy(); + + // Enable analytics via the resource. + const analytics = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.WebAnalytics("Analytics", { + project: PROBE_PROJECT, + }); + }), + ); + expect(analytics.projectId).toEqual(projectId); + expect(analytics.analyticsId).toBeDefined(); + expect(analytics.enabledAt).toBeDefined(); + + // Out-of-band verification via distilled: the project reports + // analytics as enabled. + const observed = yield* readProbeAnalytics; + expect(observed?.id).toEqual(analytics.analyticsId); + expect(observed?.enabledAt).toBeDefined(); + + // Idempotent redeploy — same analytics instance, no re-toggle. + const again = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.WebAnalytics("Analytics", { + project: PROBE_PROJECT, + }); + }), + ); + expect(again.analyticsId).toEqual(analytics.analyticsId); + + // Destroy toggles analytics back off (the project itself remains). + yield* stack.destroy(); + const after = yield* readProbeAnalytics; + expect(after?.disabledAt).toBeDefined(); + expect((after?.enabledAt ?? 0) <= (after?.disabledAt ?? 0)).toBe(true); + }).pipe(Effect.ensuring(deleteProbeProject.pipe(Effect.ignore)), logLevel), + { timeout: 120_000 }, +); + +// Pins the distilled patch: toggling analytics on a nonexistent project +// answers the TYPED NotFound tag (the unpatched op lacked 404 + teamId). +test.provider("toggle on a nonexistent project fails with typed NotFound", () => + Effect.gen(function* () { + const teamId = yield* teamScopeOf; + const toggled = yield* Effect.result( + webAnalytics.createWebInsightsToggle({ + projectId: "prj_nonexistent000000000000000", + value: false, + teamId, + }), + ); + expect(Result.isFailure(toggled)).toBe(true); + if (Result.isFailure(toggled)) { + expect(toggled.failure._tag).toBe("NotFound"); + } + }).pipe(logLevel), +); diff --git a/packages/alchemy/test/Vercel/Blob/BlobBinding.test.ts b/packages/alchemy/test/Vercel/Blob/BlobBinding.test.ts new file mode 100644 index 0000000000..6db9e2a8cf --- /dev/null +++ b/packages/alchemy/test/Vercel/Blob/BlobBinding.test.ts @@ -0,0 +1,319 @@ +/** + * Vercel Blob capability binding tests — live against the standing Vercel + * test team (run with the doppler alchemy-v2/dev env). + * + * Covers: the Effect-mode `ReadWriteBlob`/`ReadBlob` bindings (the binding + * alone connects the Function's project to the store, which makes the + * platform inject `BLOB_READ_WRITE_TOKEN`), the full data-plane round trip + * over the deployed function (put → get → head → list → CAS 412 → + * conditional-create 400 → del), redeploy stability of the captured env, + * and the async-mode `readWriteBlobFromEnv` client against a PRIVATE store + * connected through the store's binding contract from stack composition. + */ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import { + filterProjectEnvs, + getProject, +} from "@distilled.cloud/vercel/projects"; +import { + getStorageStoreConnections, + getStorageStoresById, +} from "@distilled.cloud/vercel/storage"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import BlobFn from "./fixtures/blob-worker.ts"; +import { Uploads } from "./fixtures/uploads-store.ts"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +// ───────────────────────────────────────────────────────────────────────────── +// Least-privilege pins (compile-time): the platform only issues a read-write +// store token, so access-level separation lives entirely on the CLIENT +// surface — these pins fail the type-check if a write method ever leaks onto +// the read client (or vice versa). +// ───────────────────────────────────────────────────────────────────────────── +type KeysOf = keyof T & string; +type ReadCannotWrite = + Extract, "put" | "del"> extends never + ? true + : never; +type WriteCannotRead = + Extract, "get" | "head" | "list"> extends never + ? true + : never; +const _readCannotWrite: ReadCannotWrite = true; +const _writeCannotRead: WriteCannotRead = true; +void _readCannotWrite; +void _writeCannotRead; + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +const asyncFixtureMain = new URL( + "./fixtures/async-blob-handler.ts", + import.meta.url, +).pathname; + +// Fresh .vercel.app URLs take a few seconds to start serving 200s — always +// retry the first request (bounded). +const readiness = Schedule.max([ + Schedule.exponential("500 millis"), + Schedule.recurs(20), +]); + +const getJson = (url: string) => + HttpClient.get(url).pipe( + Effect.flatMap((response) => + response.status === 200 + ? response.json + : Effect.fail(new Error(`status ${response.status}`)), + ), + Effect.retry({ schedule: readiness }), + ); + +/** Poll (bounded) until the project is gone. */ +const expectProjectGone = (projectId: string) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + const gone = yield* getProject({ idOrName: projectId, teamId }).pipe( + Effect.map(() => false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (g) => g, + times: 10, + }), + ); + expect(gone).toBe(true); + }); + +/** Poll (bounded) until the blob store is gone. */ +const expectStoreGone = (storeId: string) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + const gone = yield* getStorageStoresById({ id: storeId, teamId }).pipe( + Effect.map(() => false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (g) => g, + times: 10, + }), + ); + expect(gone).toBe(true); + }); + +const envKeys = (envs: unknown): Array<{ key: string; type: string }> => + (Array.isArray(envs) + ? envs + : typeof envs === "object" && envs !== null && "envs" in envs + ? (envs as { envs: unknown[] }).envs + : []) as Array<{ key: string; type: string }>; + +test.provider( + "effect-mode ReadWriteBlob/ReadBlob: binding connects the project, data plane round-trips", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const { fn, store } = yield* stack.deploy( + Effect.gen(function* () { + const fn = yield* BlobFn; + const store = yield* Uploads; + return { fn, store }; + }), + ); + expect(fn.url).toBeDefined(); + expect(store.storeId).toBeDefined(); + expect(store.access).toEqual("public"); + + // 1. The BINDING (no `projects:` prop) connected the Function's + // project to the store. + expect(store.projectIds).toContain(fn.projectId); + const { teamId } = yield* Vercel.VercelEnvironment.current; + const connections = yield* getStorageStoreConnections({ + storeId: store.storeId, + teamId, + }); + expect(connections.connections.map((c) => c.projectId)).toContain( + fn.projectId, + ); + + // 2. ... which injected the platform token env into the project. + const envs = yield* filterProjectEnvs({ + idOrName: fn.projectId, + teamId, + }); + const tokenRow = envKeys(envs).find( + (row) => row.key === "BLOB_READ_WRITE_TOKEN", + ); + expect(tokenRow).toBeDefined(); + + // 3. Data-plane round trip through the deployed function. + const path = "cap/hello.txt"; + const put = (yield* getJson( + `${fn.url}/blob/put?path=${encodeURIComponent(path)}&body=hello-v1`, + )) as { etag: string; url: string; pathname: string }; + expect(put.pathname).toEqual(path); + expect(put.etag).toBeDefined(); + // Public store: the canonical URL host carries the lowercased bare + // store id + access mode, and serves unauthenticated reads. + expect(put.url).toContain(".public.blob.vercel-storage.com"); + const raw = yield* HttpClient.get(put.url).pipe( + Effect.flatMap((response) => response.text), + ); + expect(raw).toEqual("hello-v1"); + + const got = (yield* getJson( + `${fn.url}/blob/get?path=${encodeURIComponent(path)}`, + )) as { text: string }; + expect(got.text).toEqual("hello-v1"); + + const head = (yield* getJson( + `${fn.url}/blob/head?path=${encodeURIComponent(path)}`, + )) as { size: number; etag: string }; + expect(head.size).toEqual("hello-v1".length); + expect(head.etag).toEqual(put.etag); + + // 4. Conditional create on an existing pathname → typed AlreadyExists. + const create = (yield* getJson( + `${fn.url}/blob/create?path=${encodeURIComponent(path)}&body=x`, + )) as { alreadyExists?: boolean }; + expect(create.alreadyExists).toBe(true); + + // 5. CAS: wrong etag → typed PreconditionFailed; right etag → 200. + const casWrong = (yield* getJson( + `${fn.url}/blob/cas?path=${encodeURIComponent(path)}&body=hello-v2&etag=${encodeURIComponent('"wrong-etag"')}`, + )) as { precondition?: boolean }; + expect(casWrong.precondition).toBe(true); + const casRight = (yield* getJson( + `${fn.url}/blob/cas?path=${encodeURIComponent(path)}&body=hello-v2&etag=${encodeURIComponent(put.etag)}`, + )) as { etag?: string }; + expect(casRight.etag).toBeDefined(); + expect(casRight.etag).not.toEqual(put.etag); + + // 6. List sees the blob; the read-only client reads the new content. + const listed = (yield* getJson( + `${fn.url}/blob/list?prefix=${encodeURIComponent("cap/")}`, + )) as { pathnames: string[] }; + expect(listed.pathnames).toContain(path); + const readOnly = (yield* getJson( + `${fn.url}/blob/read?path=${encodeURIComponent(path)}`, + )) as { text: string }; + expect(readOnly.text).toEqual("hello-v2"); + + // 7. Delete, then the typed NotFound surfaces on get. Content reads + // ride the CDN, so read-after-delete is eventually consistent — + // bounded poll. + const del = (yield* getJson( + `${fn.url}/blob/del?path=${encodeURIComponent(path)}`, + )) as { ok: boolean }; + expect(del.ok).toBe(true); + const gone = yield* getJson( + `${fn.url}/blob/get?path=${encodeURIComponent(path)}`, + ).pipe( + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (body) => (body as { notFound?: boolean }).notFound === true, + times: 10, + }), + Effect.map((body) => body as { notFound?: boolean }), + ); + expect(gone.notFound).toBe(true); + + // 8. Churn regression: an identical redeploy is a skip-on-hash no-op — + // the captured store accessors and the connection are stable. + const redeployed = yield* stack.deploy( + Effect.gen(function* () { + const fn = yield* BlobFn; + const store = yield* Uploads; + return { fn, store }; + }), + ); + expect(redeployed.fn.projectId).toEqual(fn.projectId); + expect(redeployed.fn.deploymentId).toEqual(fn.deploymentId); + + // 9. Destroy cascades the project AND the store; typed wait-until-gone. + yield* stack.destroy(); + yield* expectProjectGone(fn.projectId); + yield* expectStoreGone(store.storeId); + }).pipe(logLevel), + { timeout: 120_000 }, +); + +test.provider( + "async-mode readWriteBlobFromEnv against a private store (binding-contract connect from composition)", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const { fn, store } = yield* stack.deploy( + Effect.gen(function* () { + const store = yield* Vercel.BlobStore("AsyncBlobStore", { + access: "private", + }); + const fn = yield* Vercel.Function("AsyncBlobFn", { + main: asyncFixtureMain, + // The env ref creates the fn → store dependency edge, so the + // connection (and its injected token env) lands BEFORE the + // deployment is created — project env only takes effect on new + // deployments. + env: { BLOB_STORE_ID: store.storeId }, + }); + // Connect the Function's project through the store's binding + // contract (what the Effect-mode capability does under the hood). + yield* store.bind("AsyncBlobFn", { projects: [fn.projectId] }); + return { fn, store }; + }), + ); + expect(store.access).toEqual("private"); + expect(store.projectIds).toContain(fn.projectId); + + // The platform injected the token into the project. + const { teamId } = yield* Vercel.VercelEnvironment.current; + const envs = yield* filterProjectEnvs({ + idOrName: fn.projectId, + teamId, + }); + expect( + envKeys(envs).find((row) => row.key === "BLOB_READ_WRITE_TOKEN"), + ).toBeDefined(); + + // put → get through the deployed async handler (promise client). + const path = "async/p.txt"; + const put = (yield* getJson( + `${fn.url}/put?path=${encodeURIComponent(path)}&body=private-hello`, + )) as { etag: string; url: string }; + expect(put.etag).toBeDefined(); + expect(put.url).toContain(".private.blob.vercel-storage.com"); + + const got = (yield* getJson( + `${fn.url}/get?path=${encodeURIComponent(path)}`, + )) as { text: string }; + expect(got.text).toEqual("private-hello"); + + // Private store: the canonical URL rejects unauthenticated reads. + const status = yield* HttpClient.get(put.url).pipe( + Effect.map((response) => response.status), + ); + expect([401, 403]).toContain(status); + + // Cleanup the blob, then the stack (project + store), typed + // wait-until-gone. + const del = (yield* getJson( + `${fn.url}/del?path=${encodeURIComponent(path)}`, + )) as { ok: boolean }; + expect(del.ok).toBe(true); + + yield* stack.destroy(); + yield* expectProjectGone(fn.projectId); + yield* expectStoreGone(store.storeId); + }).pipe(logLevel), + { timeout: 120_000 }, +); diff --git a/packages/alchemy/test/Vercel/Blob/BlobStore.local.test.ts b/packages/alchemy/test/Vercel/Blob/BlobStore.local.test.ts new file mode 100644 index 0000000000..57c8e05320 --- /dev/null +++ b/packages/alchemy/test/Vercel/Blob/BlobStore.local.test.ts @@ -0,0 +1,292 @@ +/** + * Vercel BlobStore dev-mode (`alchemy dev`) tests — the local provider per + * the Local-tests doctrine: `dev: true` runs local providers behind the + * RPC sidecar proxy by default, matching the process topology of the real + * `alchemy dev` command. + * + * Covers (a) the local roundtrip: a `dev:store_…` row (proof no cloud call + * ran), the Effect-mode `ReadWriteBlob`/`ReadBlob` bindings driven through + * a locally-running Function against the directory-backed emulator — + * put → content URL fetch → get → head → conditional-create 400-equivalent + * (typed `Vercel.Blob.AlreadyExists`) → CAS 412-equivalent (typed + * `Vercel.Blob.PreconditionFailed`) → list → del — plus an out-of-band + * check from the test process via the async `readWriteBlobFromEnv` client + * pointed at the emulator through the store's `localApiUrl`/`localToken` + * attributes; and (b) the `Alchemy.remote()` opt-out deploying the store + * live during dev with out-of-band distilled verification and post-destroy + * absence (pins the stamped-mode delete path). + */ +import * as Alchemy from "@/index.ts"; +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import { getProject } from "@distilled.cloud/vercel/projects"; +import { getStorageStoresById } from "@distilled.cloud/vercel/storage"; +import { expect } from "alchemy-test"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import BlobFn from "./fixtures/blob-worker.ts"; +import { Uploads } from "./fixtures/uploads-store.ts"; + +const { test } = Test.make({ + providers: Vercel.providers(), + dev: true, +}); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +const asyncFixtureMain = new URL( + "./fixtures/async-blob-handler.ts", + import.meta.url, +).pathname; + +class NotReady extends Data.TaggedError("NotReady")<{ + status: number; + location: string | undefined; + message: string; +}> {} + +// The first request races the dev bundle's first build (rolldown over the +// alchemy barrel for Effect functions) — retry with a bounded cap. +const readiness = Schedule.max([ + Schedule.min([ + Schedule.exponential("500 millis"), + Schedule.spaced("2 seconds"), + ]), + Schedule.recurs(45), +]); + +const getJsonReady = (url: string) => + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const res = yield* client.get(url).pipe( + Effect.flatMap((res) => + res.status === 200 + ? Effect.succeed(res) + : Effect.fail( + new NotReady({ + status: res.status, + location: res.headers.location, + message: `GET ${url} -> ${res.status}${ + res.headers.location + ? ` (location: ${res.headers.location})` + : "" + }`, + }), + ), + ), + Effect.retry({ + while: (e): e is NotReady => e instanceof NotReady, + schedule: readiness, + }), + ); + return yield* res.json; + }).pipe(Effect.orDie); + +test.provider( + "local roundtrip: dev store markers, ReadWriteBlob through a local Function, CAS + conditional create", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + const client = yield* HttpClient.HttpClient; + + const { fn, store } = yield* stack.deploy( + Effect.gen(function* () { + const fn = yield* BlobFn; + const store = yield* Uploads; + return { fn, store }; + }), + ); + + // dev identity markers — proof no cloud call ran. + expect(store.storeId).toMatch(/^dev:store_/); + expect(store.localApiUrl).toMatch(/^http:\/\/localhost:\d+$/); + expect(store.localToken).toBeDefined(); + expect(fn.url).toMatch(/^http:\/\/localhost:\d+$/); + expect(fn.projectId).toMatch(/^dev:/); + + // put through the local Function's ReadWriteBlob binding; the + // returned content URL points at the emulator, not the platform. + const path = "cap/hello.txt"; + const put = (yield* getJsonReady( + `${fn.url}/blob/put?path=${encodeURIComponent(path)}&body=hello-v1`, + )) as { etag: string; url: string; pathname: string }; + expect(put.pathname).toEqual(path); + expect(put.etag).toBeDefined(); + expect(put.url.startsWith(`${store.localApiUrl}/`)).toBe(true); + + // Public store: the content URL serves unauthenticated reads. + const raw = yield* client + .get(put.url) + .pipe(Effect.flatMap((response) => response.text)); + expect(raw).toEqual("hello-v1"); + + const got = (yield* getJsonReady( + `${fn.url}/blob/get?path=${encodeURIComponent(path)}`, + )) as { text: string }; + expect(got.text).toEqual("hello-v1"); + + const head = (yield* getJsonReady( + `${fn.url}/blob/head?path=${encodeURIComponent(path)}`, + )) as { size: number; etag: string }; + expect(head.size).toEqual("hello-v1".length); + expect(head.etag).toEqual(put.etag); + + // Conditional create on an existing pathname → typed AlreadyExists + // (the emulator mirrors the wire's 400 "already exists"). + const create = (yield* getJsonReady( + `${fn.url}/blob/create?path=${encodeURIComponent(path)}&body=x`, + )) as { alreadyExists?: boolean }; + expect(create.alreadyExists).toBe(true); + + // CAS: wrong etag → typed PreconditionFailed (wire 412); right → 200. + const casWrong = (yield* getJsonReady( + `${fn.url}/blob/cas?path=${encodeURIComponent(path)}&body=hello-v2&etag=${encodeURIComponent('"wrong-etag"')}`, + )) as { precondition?: boolean }; + expect(casWrong.precondition).toBe(true); + const casRight = (yield* getJsonReady( + `${fn.url}/blob/cas?path=${encodeURIComponent(path)}&body=hello-v2&etag=${encodeURIComponent(put.etag)}`, + )) as { etag?: string }; + expect(casRight.etag).toBeDefined(); + expect(casRight.etag).not.toEqual(put.etag); + + // List sees the blob; the read-only client reads the new content. + const listed = (yield* getJsonReady( + `${fn.url}/blob/list?prefix=${encodeURIComponent("cap/")}`, + )) as { pathnames: string[] }; + expect(listed.pathnames).toContain(path); + const readOnly = (yield* getJsonReady( + `${fn.url}/blob/read?path=${encodeURIComponent(path)}`, + )) as { text: string }; + expect(readOnly.text).toEqual("hello-v2"); + + // Out-of-band from the TEST process: the async promise client pointed + // at the emulator via the store's dev attributes reads the same data + // plane the Function wrote to (also proves `@vercel/blob`-style env + // clients work against the emulator). + const outOfBand = Vercel.readWriteBlobFromEnv({ + token: Redacted.value(store.localToken!), + apiUrl: store.localApiUrl, + access: "public", + }); + const oob = yield* Effect.promise(() => outOfBand.get(path)); + expect(oob.text).toEqual("hello-v2"); + + // Delete, then the typed NotFound surfaces on get. + const del = (yield* getJsonReady( + `${fn.url}/blob/del?path=${encodeURIComponent(path)}`, + )) as { ok: boolean }; + expect(del.ok).toBe(true); + const gone = (yield* getJsonReady( + `${fn.url}/blob/get?path=${encodeURIComponent(path)}`, + )) as { notFound?: boolean }; + expect(gone.notFound).toBe(true); + + yield* stack.destroy(); + }).pipe(logLevel), + { timeout: 240_000 }, +); + +test.provider( + "Alchemy.remote() store + Function deploy live during dev", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const { fn, store } = yield* stack.deploy( + Effect.gen(function* () { + // The platform injects the token through the store↔project + // connection, so a live store needs a live project — pipe BOTH + // through remote(). (A local Function against a remote store is + // structurally impossible: the platform cannot inject the token + // into a project that doesn't exist.) + const store = yield* Vercel.BlobStore("RemoteBlobStore", { + access: "private", + }).pipe(Alchemy.remote()); + // The env ref creates the fn → store dependency edge, so the + // connection (and its injected `BLOB_READ_WRITE_TOKEN`) lands + // BEFORE the deployment is created — project env only takes + // effect on new deployments. Safe in the cycle: `precreate` + // strips the env channel before its resolved-props gate, so the + // stub still creates the real project for the store to connect. + // (Same shape as the live BlobBinding.test.ts async case.) + const fn = yield* Vercel.Function("RemoteBlobFn", { + main: asyncFixtureMain, + env: { BLOB_STORE_ID: store.storeId }, + }).pipe(Alchemy.remote()); + yield* store.bind("RemoteBlobFn", { projects: [fn.projectId] }); + return { fn, store }; + }), + ); + + // Real cloud identity — not the local emulator. + expect(store.storeId).toMatch(/^store_/); + expect(store.localApiUrl).toBeUndefined(); + expect(fn.url).toMatch(/^https:\/\//); + expect(store.projectIds).toContain(fn.projectId); + + // Round-trip against the real data plane through the deployment. + const path = "remote/p.txt"; + const put = (yield* getJsonReady( + `${fn.url}/put?path=${encodeURIComponent(path)}&body=remote-hello`, + )) as { etag: string; url: string }; + expect(put.url).toContain(".private.blob.vercel-storage.com"); + const got = (yield* getJsonReady( + `${fn.url}/get?path=${encodeURIComponent(path)}`, + )) as { text: string }; + expect(got.text).toEqual("remote-hello"); + + // Out-of-band via distilled: the store exists on real Vercel. + const { teamId } = yield* Vercel.VercelEnvironment.current; + const found = yield* getStorageStoresById({ + id: store.storeId, + teamId, + }); + expect(found.store.id).toEqual(store.storeId); + + // Clean the blob so the store delete does not 409 `not_empty`. + const del = (yield* getJsonReady( + `${fn.url}/del?path=${encodeURIComponent(path)}`, + )) as { ok: boolean }; + expect(del.ok).toBe(true); + + yield* stack.destroy(); + + // The live store AND project were deleted from the cloud on destroy + // (rows stamped live, so the live provider handles the deletes even + // in a dev run) — typed wait-until-gone. + const storeGone = yield* getStorageStoresById({ + id: store.storeId, + teamId, + }).pipe( + Effect.map(() => false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (g) => g, + times: 10, + }), + ); + expect(storeGone).toBe(true); + const projectGone = yield* getProject({ + idOrName: fn.projectId, + teamId, + }).pipe( + Effect.map(() => false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (g) => g, + times: 10, + }), + ); + expect(projectGone).toBe(true); + }).pipe(logLevel), + { timeout: 240_000 }, +); diff --git a/packages/alchemy/test/Vercel/Blob/BlobStore.test.ts b/packages/alchemy/test/Vercel/Blob/BlobStore.test.ts new file mode 100644 index 0000000000..3ab33953b1 --- /dev/null +++ b/packages/alchemy/test/Vercel/Blob/BlobStore.test.ts @@ -0,0 +1,303 @@ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import { + createProject, + deleteProject, + filterProjectEnvs, + getProjectEnv, +} from "@distilled.cloud/vercel/projects"; +import { + getStorageStoreConnections, + getStorageStores, + getStorageStoresById, +} from "@distilled.cloud/vercel/storage"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +const BLOB_API = "https://blob.vercel-storage.com"; + +/** + * Create a host project out-of-band (deterministic name), run `use`, and + * always delete the project afterwards. A leftover from an interrupted run + * is deleted up-front so the fixture is self-healing. + */ +const withHostProject = ( + name: string, + use: (projectId: string) => Effect.Effect, +) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + yield* deleteProject({ idOrName: name, teamId }).pipe( + Effect.catchTag("NotFound", () => Effect.void), + ); + const project = yield* createProject({ name, teamId }); + return yield* use(project.id).pipe( + Effect.ensuring( + deleteProject({ idOrName: name, teamId }).pipe( + Effect.catchTag("NotFound", () => Effect.void), + Effect.ignore, + ), + ), + ); + }); + +test.provider( + "blob store lifecycle: create, connect, data plane, disconnect, destroy", + (stack) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + yield* stack.destroy(); + + yield* withHostProject("alchemy-test-blobstore-host", (projectId) => + Effect.gen(function* () { + const store = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.BlobStore("Store", { + access: "private", + region: "iad1", + projects: [projectId], + }); + }), + ); + expect(store.storeId).toMatch(/^store_/); + expect(store.access).toEqual("private"); + expect(store.region).toEqual("iad1"); + expect(store.projectIds).toEqual([projectId]); + + // out-of-band: the store and its connection exist + const fetched = yield* getStorageStoresById({ + id: store.storeId, + teamId, + }); + expect(fetched.store.name).toEqual(store.name); + expect(fetched.store.access).toEqual("private"); + const conns = yield* getStorageStoreConnections({ + storeId: store.storeId, + teamId, + }); + expect(conns.connections.map((c) => c.projectId)).toEqual([ + projectId, + ]); + + // the connection injected the token env var into the project + // (bounded retry: the injected var can lag the connect by a beat) + const tokenEnv = yield* filterProjectEnvs({ + idOrName: projectId, + teamId, + }).pipe( + Effect.map((envs) => + "envs" in envs + ? envs.envs.find((env) => env.key === "BLOB_READ_WRITE_TOKEN") + : undefined, + ), + Effect.repeat({ + schedule: Schedule.spaced("1 second"), + until: (env) => env !== undefined, + times: 8, + }), + ); + expect(tokenEnv).toBeDefined(); + + // the decrypted token drives the Blob data plane (settles the + // D6 token-acquisition path live) + const decrypted = yield* getProjectEnv({ + idOrName: projectId, + id: tokenEnv!.id!, + teamId, + }); + const token = "value" in decrypted ? decrypted.value : undefined; + expect(token).toMatch(/^vercel_blob_rw_/); + + const headers = { + authorization: `Bearer ${token}`, + "x-api-version": "12", + "x-vercel-blob-store-id": token!.split("_")[3] ?? "", + "x-vercel-blob-access": "private", + }; + const putUrl = `${BLOB_API}/?pathname=${encodeURIComponent("alchemy/probe.txt")}`; + const putRes = yield* HttpClient.execute( + HttpClientRequest.put(putUrl).pipe( + HttpClientRequest.setHeaders({ + ...headers, + "x-allow-overwrite": "1", + "x-add-random-suffix": "0", + }), + HttpClientRequest.bodyText("hello from alchemy", "text/plain"), + ), + ); + expect(putRes.status).toBe(200); + const putBody = (yield* putRes.json) as { + url: string; + pathname: string; + etag?: string; + }; + expect(putBody.pathname).toEqual("alchemy/probe.txt"); + + // read-after-write on the private blob URL (authenticated) + const getRes = yield* HttpClient.execute( + HttpClientRequest.get(putBody.url).pipe( + HttpClientRequest.setHeaders({ + authorization: `Bearer ${token}`, + }), + ), + ); + expect(getRes.status).toBe(200); + expect(yield* getRes.text).toEqual("hello from alchemy"); + + // conditional write with a wrong etag is rejected (CAS) + const casRes = yield* HttpClient.execute( + HttpClientRequest.put(putUrl).pipe( + HttpClientRequest.setHeaders({ + ...headers, + "x-allow-overwrite": "1", + "x-add-random-suffix": "0", + "x-if-match": '"deadbeef"', + }), + HttpClientRequest.bodyText("cas write", "text/plain"), + ), + ); + expect(casRes.status).toBe(412); + + // conditional create: a second put without allowOverwrite fails + const overwriteRes = yield* HttpClient.execute( + HttpClientRequest.put(putUrl).pipe( + HttpClientRequest.setHeaders({ + ...headers, + "x-allow-overwrite": "0", + "x-add-random-suffix": "0", + }), + HttpClientRequest.bodyText("second write", "text/plain"), + ), + ); + expect(overwriteRes.status).toBe(400); + + // clean up the blob so the store deletes empty + const delRes = yield* HttpClient.execute( + HttpClientRequest.post(`${BLOB_API}/delete`).pipe( + HttpClientRequest.setHeaders(headers), + HttpClientRequest.bodyJsonUnsafe({ urls: [putBody.url] }), + ), + ); + expect(delRes.status).toBe(200); + + // disconnect: drop the project from the desired list + const updated = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.BlobStore("Store", { + access: "private", + region: "iad1", + projects: [], + }); + }), + ); + expect(updated.storeId).toEqual(store.storeId); + expect(updated.projectIds).toEqual([]); + + const connsAfter = yield* getStorageStoreConnections({ + storeId: store.storeId, + teamId, + }); + expect(connsAfter.connections).toEqual([]); + const envsAfter = yield* filterProjectEnvs({ + idOrName: projectId, + teamId, + }); + const tokenEnvAfter = + "envs" in envsAfter + ? envsAfter.envs.find( + (env) => env.key === "BLOB_READ_WRITE_TOKEN", + ) + : undefined; + expect(tokenEnvAfter).toBeUndefined(); + + // destroy and verify the store is gone (typed NotFound) + yield* stack.destroy(); + const gone = yield* getStorageStoresById({ + id: store.storeId, + teamId, + }).pipe( + Effect.map(() => false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + ); + expect(gone).toBe(true); + }), + ); + }).pipe(logLevel), + { timeout: 120_000 }, +); + +test.provider("blob store is stable across a no-op update", (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const deploy = stack.deploy( + Vercel.BlobStore("NoopStore", { access: "public", region: "iad1" }), + ); + const created = yield* deploy; + expect(created.storeId).toMatch(/^store_/); + expect(created.access).toEqual("public"); + + const updated = yield* deploy; + expect(updated.storeId).toEqual(created.storeId); + expect(updated.name).toEqual(created.name); + + yield* stack.destroy(); + }).pipe(logLevel), +); + +test.provider("changing access forces a replacement", (stack) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + yield* stack.destroy(); + + const created = yield* stack.deploy( + Vercel.BlobStore("ReplStore", { access: "public", region: "iad1" }), + ); + expect(created.access).toEqual("public"); + + const replaced = yield* stack.deploy( + Vercel.BlobStore("ReplStore", { access: "private", region: "iad1" }), + ); + expect(replaced.access).toEqual("private"); + expect(replaced.storeId).not.toEqual(created.storeId); + + yield* stack.destroy(); + const gone = yield* getStorageStoresById({ + id: replaced.storeId, + teamId, + }).pipe( + Effect.map(() => false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + ); + expect(gone).toBe(true); + }).pipe(logLevel), +); + +test.provider("list enumerates the deployed store", (stack) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + yield* stack.destroy(); + + const deployed = yield* stack.deploy( + Vercel.BlobStore("ListStore", { access: "public", region: "iad1" }), + ); + + const { stores } = yield* getStorageStores({ teamId }); + const found = stores.find((s) => s.id === deployed.storeId); + expect(found).toBeDefined(); + expect(found!.type).toEqual("blob"); + + yield* stack.destroy(); + }).pipe(logLevel), +); diff --git a/packages/alchemy/test/Vercel/Blob/fixtures/async-blob-handler.ts b/packages/alchemy/test/Vercel/Blob/fixtures/async-blob-handler.ts new file mode 100644 index 0000000000..74c2a1730b --- /dev/null +++ b/packages/alchemy/test/Vercel/Blob/fixtures/async-blob-handler.ts @@ -0,0 +1,43 @@ +/** + * Async-mode (no Effect runtime) Vercel Function handler using the + * promise-based `readWriteBlobFromEnv` client against a PRIVATE store — + * the platform-injected `BLOB_READ_WRITE_TOKEN` is the only credential. + */ +import { readWriteBlobFromEnv } from "@/Vercel/Blob/BlobFromEnv.ts"; + +const uploads = readWriteBlobFromEnv({ access: "private" }); + +export default { + async fetch(request: Request): Promise { + const url = new URL(request.url); + const path = url.searchParams.get("path") ?? ""; + const body = url.searchParams.get("body") ?? ""; + try { + if (url.pathname === "/put") { + const put = await uploads.put(path, body, { + contentType: "text/plain", + }); + return Response.json({ etag: put.etag, url: put.url }); + } + if (url.pathname === "/get") { + const blob = await uploads.get(path); + return Response.json({ text: blob.text }); + } + if (url.pathname === "/list") { + const listed = await uploads.list({ + prefix: url.searchParams.get("prefix") ?? undefined, + }); + return Response.json({ + pathnames: listed.blobs.map((row) => row.pathname).sort(), + }); + } + if (url.pathname === "/del") { + await uploads.del(path); + return Response.json({ ok: true }); + } + return Response.json({ ok: true }); + } catch (error) { + return Response.json({ error: String(error) }, { status: 500 }); + } + }, +}; diff --git a/packages/alchemy/test/Vercel/Blob/fixtures/blob-worker.ts b/packages/alchemy/test/Vercel/Blob/fixtures/blob-worker.ts new file mode 100644 index 0000000000..eb0edc7926 --- /dev/null +++ b/packages/alchemy/test/Vercel/Blob/fixtures/blob-worker.ts @@ -0,0 +1,131 @@ +/** + * Effect-mode Vercel Function fixture for the Blob capability bindings: + * `ReadWriteBlob` drives put/CAS/conditional-create/del, `ReadBlob` is the + * least-privilege sibling (its client type has no `put`/`del`) used by the + * `/blob/read` route. + */ +import * as Vercel from "@/Vercel/index.ts"; +import * as Effect from "effect/Effect"; +import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import { Uploads } from "./uploads-store.ts"; + +export default class BlobFn extends Vercel.Function()( + "BlobFn", + { + main: import.meta.url, + }, + Effect.gen(function* () { + const blob = yield* Vercel.ReadWriteBlob(Uploads); + // Least-privilege: `reader.put` / `reader.del` do not exist on the type. + const reader = yield* Vercel.ReadBlob(Uploads); + + return { + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; + const url = yield* Effect.sync( + () => new globalThis.URL(request.url, "http://localhost"), + ); + const path = url.searchParams.get("path") ?? ""; + const body = url.searchParams.get("body") ?? ""; + const etag = url.searchParams.get("etag") ?? ""; + + switch (url.pathname) { + case "/blob/put": { + const put = yield* blob + .put(path, body, { contentType: "text/plain" }) + .pipe(Effect.orDie); + return yield* HttpServerResponse.json({ + etag: put.etag, + url: put.url, + pathname: put.pathname, + }); + } + case "/blob/create": { + const result = yield* blob + .put(path, body, { + contentType: "text/plain", + allowOverwrite: false, + }) + .pipe( + Effect.map((put) => ({ etag: put.etag })), + Effect.catchTag("Vercel.Blob.AlreadyExists", () => + Effect.succeed({ alreadyExists: true }), + ), + Effect.orDie, + ); + return yield* HttpServerResponse.json(result); + } + case "/blob/cas": { + const result = yield* blob + .put(path, body, { contentType: "text/plain", ifMatch: etag }) + .pipe( + Effect.map((put) => ({ etag: put.etag })), + Effect.catchTag("Vercel.Blob.PreconditionFailed", () => + Effect.succeed({ precondition: true }), + ), + Effect.orDie, + ); + return yield* HttpServerResponse.json(result); + } + case "/blob/get": { + const result = yield* blob.get(path).pipe( + Effect.flatMap((got) => + Effect.map(got.text, (text) => ({ + text, + contentType: got.contentType, + })), + ), + Effect.catchTag("Vercel.Blob.NotFound", () => + Effect.succeed({ notFound: true }), + ), + Effect.orDie, + ); + return yield* HttpServerResponse.json(result); + } + case "/blob/head": { + const result = yield* blob.head(path).pipe( + Effect.map((meta) => ({ + size: meta.size, + etag: meta.etag, + pathname: meta.pathname, + })), + Effect.catchTag("Vercel.Blob.NotFound", () => + Effect.succeed({ notFound: true }), + ), + Effect.orDie, + ); + return yield* HttpServerResponse.json(result); + } + case "/blob/list": { + const listed = yield* blob + .list({ prefix: url.searchParams.get("prefix") ?? undefined }) + .pipe(Effect.orDie); + return yield* HttpServerResponse.json({ + pathnames: listed.blobs.map((row) => row.pathname).sort(), + }); + } + case "/blob/read": { + // Through the read-only client. + const result = yield* reader.get(path).pipe( + Effect.flatMap((got) => + Effect.map(got.text, (text) => ({ text })), + ), + Effect.catchTag("Vercel.Blob.NotFound", () => + Effect.succeed({ notFound: true }), + ), + Effect.orDie, + ); + return yield* HttpServerResponse.json(result); + } + case "/blob/del": { + yield* blob.del(path).pipe(Effect.orDie); + return yield* HttpServerResponse.json({ ok: true }); + } + default: + return yield* HttpServerResponse.json({ ok: true }); + } + }), + }; + }).pipe(Effect.provide([Vercel.ReadWriteBlobHttp, Vercel.ReadBlobHttp])), +) {} diff --git a/packages/alchemy/test/Vercel/Blob/fixtures/uploads-store.ts b/packages/alchemy/test/Vercel/Blob/fixtures/uploads-store.ts new file mode 100644 index 0000000000..5bbfcfaa13 --- /dev/null +++ b/packages/alchemy/test/Vercel/Blob/fixtures/uploads-store.ts @@ -0,0 +1,8 @@ +import * as Vercel from "@/Vercel/index.ts"; + +/** + * Public blob store bound by the Effect-mode fixture Function. The binding + * (not a `projects:` prop) is what connects the Function's project — the + * test asserts the connection materialized from the capability alone. + */ +export const Uploads = Vercel.BlobStore("Uploads", { access: "public" }); diff --git a/packages/alchemy/test/Vercel/Chains/AdoptionDrift.test.ts b/packages/alchemy/test/Vercel/Chains/AdoptionDrift.test.ts new file mode 100644 index 0000000000..8d47e7bea8 --- /dev/null +++ b/packages/alchemy/test/Vercel/Chains/AdoptionDrift.test.ts @@ -0,0 +1,320 @@ +/** + * DEPTH chain 6 — adoption + drift healing (live, doppler alchemy-v2/dev). + * + * EdgeConfig + Function with EXPLICIT deterministic physical names (cold + * reads look resources up by name, so auto-generated names — which embed a + * per-instance random suffix — cannot be re-found after state loss). + * + * Cycles: + * 1. greenfield deploy on the durable scratch state + * 2. STATE LOSS — a second stack instance with the SAME stack name but a + * fresh in-memory state store: + * a. plan WITHOUT adopt → the Edge Config (unownable: Vercel has no + * tags) gates with `OwnedBySomeoneElse` + * b. deploy WITH `adopt(true)` → same physical ids (no recreation); + * the token — documented un-adoptable (plaintext disclosed once, + * lives only in state) — mints a successor, which is asserted + * honestly + * 3. OUT-OF-BAND DRIFT — raw-API item tamper + foreign item + deleted + * managed env row → the next reconcile heals all three (converged + * values asserted out-of-band); sensitive row re-asserted per D5 + * (write-only, fingerprint comment, runtime still serves the value) + * 4. destroy through BOTH state stores — census-clean. + */ +import { adopt } from "@/AdoptPolicy"; +import * as Core from "@/Test/Core.ts"; +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as globalConfig from "@distilled.cloud/vercel/global_config"; +import { + filterProjectEnvs, + getProject, + removeProjectEnv, +} from "@distilled.cloud/vercel/projects"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; +import * as HttpClient from "effect/unstable/http/HttpClient"; + +const OPTIONS = { + providers: Vercel.providers(), +} satisfies Test.MakeOptions; + +const { test } = Test.make(OPTIONS); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +const fixtureMain = new URL("./fixtures/drift-fn.ts", import.meta.url).pathname; + +// Deterministic (per-test-case constant) physical names — the whole point +// of the chain: cold reads after state loss must be able to find them. +const SLUG = "alchemy_chain_adopt_drift"; +const FN_NAME = "alchemy-chain-adopt-drift-fn"; + +const ITEMS_V1 = { greeting: "drift-v1", rollout: 5 }; +const ITEMS_HEAL = { greeting: "drift-v1", healed: true }; +const SECRET = "drift-secret-one"; + +const program = (items: Record, mode: string) => + Effect.gen(function* () { + const flags = yield* Vercel.EdgeConfig("DriftFlags", { + slug: SLUG, + items: { ...items }, + }); + const token = yield* Vercel.EdgeConfigToken("DriftToken", { + edgeConfigId: flags.edgeConfigId, + }); + const fn = yield* Vercel.Function("DriftFn", { + name: FN_NAME, + main: fixtureMain, + env: { + FLAGS: token.connectionString, + APP_MODE: mode, + SECRET_VALUE: Redacted.make(SECRET), + }, + }); + return { flags, token, fn }; + }); + +const readiness = Schedule.max([ + Schedule.exponential("500 millis"), + Schedule.recurs(20), +]); + +const getJson = (url: string) => + HttpClient.get(url).pipe( + Effect.flatMap((response) => + response.status === 200 + ? response.json + : Effect.fail(new Error(`status ${response.status}`)), + ), + Effect.retry({ schedule: readiness }), + ); + +const getJsonUntil = (url: string, until: (body: A) => boolean) => + getJson(url).pipe( + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (body) => until(body as A), + times: 20, + }), + Effect.map((body) => body as A), + ); + +const currentTeamId = Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + return teamId; +}); + +/** Read the config's items out-of-band as a plain record. */ +const fetchItems = (edgeConfigId: string) => + Effect.gen(function* () { + const teamId = yield* currentTeamId; + const items = yield* globalConfig.getEdgeConfigItems({ + edgeConfigId, + teamId, + }); + return Object.fromEntries(items.map((item) => [item.key, item.value])); + }); + +const envRows = (projectId: string) => + Effect.gen(function* () { + const teamId = yield* currentTeamId; + const envs = yield* filterProjectEnvs({ + idOrName: projectId, + teamId, + decrypt: "true", + }); + return ( + Array.isArray(envs) + ? envs + : typeof envs === "object" && envs !== null && "envs" in envs + ? (envs as { envs: unknown[] }).envs + : [] + ) as Array<{ + id?: string; + key: string; + type: string; + value?: string; + comment?: string; + }>; + }); + +/** Poll (bounded) until the project is gone. */ +const expectProjectGone = (projectId: string) => + Effect.gen(function* () { + const teamId = yield* currentTeamId; + const gone = yield* getProject({ idOrName: projectId, teamId }).pipe( + Effect.map(() => false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (g) => g, + times: 10, + }), + ); + expect(gone).toBe(true); + }); + +/** Poll (bounded) until the Edge Config is gone (cascades its tokens). */ +const expectEdgeConfigGone = (edgeConfigId: string) => + Effect.gen(function* () { + const teamId = yield* currentTeamId; + const gone = yield* globalConfig + .getEdgeConfig({ edgeConfigId, teamId }) + .pipe( + Effect.as(false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + Effect.repeat({ + schedule: Schedule.spaced("1 second"), + until: (g) => g, + times: 8, + }), + ); + expect(gone).toBe(true); + }); + +test.provider( + "adoption after state loss reuses physical resources; out-of-band drift heals", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + const teamId = yield* currentTeamId; + + // ── Cycle 1: greenfield on the durable scratch state ─────────────── + const c1 = yield* stack.deploy(program(ITEMS_V1, "a")); + expect(c1.flags.edgeConfigId).toMatch(/^ecfg_/); + expect(c1.flags.slug).toEqual(SLUG); + expect(c1.fn.projectName).toEqual(FN_NAME); + + const flag1 = yield* getJsonUntil<{ value: unknown }>( + `${c1.fn.url}/item/greeting`, + (body) => body.value === ITEMS_V1.greeting, + ); + expect(flag1.value).toEqual("drift-v1"); + const env1 = (yield* getJson(`${c1.fn.url}/env`)) as { + appMode: string | null; + secretValue: string | null; + }; + expect(env1.appMode).toEqual("a"); + expect(env1.secretValue).toEqual(SECRET); + + const tokens1 = yield* globalConfig.getEdgeConfigTokens({ + edgeConfigId: c1.flags.edgeConfigId, + teamId, + }); + expect(tokens1.length).toEqual(1); + + // ── Cycle 2: state loss — same stack name, fresh in-memory state ─── + const fresh = Core.scratchStack(OPTIONS, stack.name); + expect(fresh.name).toEqual(stack.name); + + // 2a. WITHOUT adopt: the plan's adoption probe finds the Edge Config + // by slug but cannot prove ownership (Vercel has no tags) — + // Unowned gates the takeover. + const gated = yield* Effect.flip(fresh.plan(program(ITEMS_V1, "a"))); + expect(gated._tag).toEqual("OwnedBySomeoneElse"); + expect(String(gated.message)).toContain("Vercel.EdgeConfig"); + + // 2b. WITH adopt: the deploy converges onto the SAME physical + // resources — no recreation. + const adopted = yield* fresh.deploy( + program(ITEMS_V1, "a").pipe(adopt(true)), + ); + expect(adopted.flags.edgeConfigId).toEqual(c1.flags.edgeConfigId); + expect(adopted.fn.projectId).toEqual(c1.fn.projectId); + expect(adopted.fn.projectName).toEqual(FN_NAME); + // The declared items were untouched by adoption (still v1). + expect(yield* fetchItems(c1.flags.edgeConfigId)).toEqual(ITEMS_V1); + + // HONEST pin of the documented un-adoptable resource: the read + // token's plaintext lives only in state (Vercel discloses it once), + // so state loss forces a successor mint — the original token remains + // until the config cascade removes it. + const tokens2 = yield* globalConfig.getEdgeConfigTokens({ + edgeConfigId: c1.flags.edgeConfigId, + teamId, + }); + expect(tokens2.length).toEqual(2); + expect(adopted.token.tokenId).not.toEqual(tokens1[0]!.id); + // The FLAGS row rotated to the successor token ⇒ a new deployment + // (env only takes effect on new deployments) that still serves reads. + const flagAdopted = yield* getJsonUntil<{ value: unknown }>( + `${adopted.fn.url}/item/greeting`, + (body) => body.value === ITEMS_V1.greeting, + ); + expect(flagAdopted.value).toEqual("drift-v1"); + + // ── Cycle 3: out-of-band drift → reconcile heals ─────────────────── + // (a) Tamper a managed item + plant a foreign one. + yield* globalConfig.patchEdgeConfigItems({ + edgeConfigId: c1.flags.edgeConfigId, + teamId, + items: [ + { operation: "upsert", key: "greeting", value: "tampered" }, + { operation: "upsert", key: "intruder", value: "foreign" }, + ], + }); + // (b) Delete a managed env row (the observable drift class — value + // tampering on encrypted rows is undetectable by design, see D5). + const rowsDrift = yield* envRows(c1.fn.projectId); + const appModeRow = rowsDrift.find((r) => r.key === "APP_MODE"); + expect(appModeRow?.id).toBeDefined(); + yield* removeProjectEnv({ + idOrName: c1.fn.projectId, + id: appModeRow!.id!, + teamId, + }); + + // Reconcile with a declared change on BOTH resources (identical + // declarations short-circuit at plan time — reconcile only runs when + // props differ). + const healed = yield* fresh.deploy(program(ITEMS_HEAL, "b")); + // STABLE: physical identities. + expect(healed.flags.edgeConfigId).toEqual(c1.flags.edgeConfigId); + expect(healed.fn.projectId).toEqual(c1.fn.projectId); + // CHANGED/HEALED: items converged to the declaration — tampered value + // reverted, foreign key removed, declared change applied. + expect(yield* fetchItems(c1.flags.edgeConfigId)).toEqual(ITEMS_HEAL); + // The deleted managed row was re-created with the declared value. + const rowsHealed = yield* envRows(c1.fn.projectId); + const appModeHealed = rowsHealed.find((r) => r.key === "APP_MODE"); + expect(appModeHealed).toBeDefined(); + // D5 re-assertion: the sensitive row is still write-only with its + // fingerprint comment, and the redeployed runtime serves both the + // healed env and the unchanged secret. + const secretRow = rowsHealed.find((r) => r.key === "SECRET_VALUE"); + expect(secretRow).toBeDefined(); + expect(secretRow!.type).toEqual("sensitive"); + expect(secretRow!.value).toEqual(""); + expect(secretRow!.comment).toMatch(/^alchemy:sha256:/); + const envHealed = yield* getJsonUntil<{ + appMode: string | null; + secretValue: string | null; + }>(`${healed.fn.url}/env`, (body) => body.appMode === "b"); + expect(envHealed.appMode).toEqual("b"); + expect(envHealed.secretValue).toEqual(SECRET); + const flagHealed = yield* getJsonUntil<{ value: unknown }>( + `${healed.fn.url}/item/greeting`, + (body) => body.value === "drift-v1", + ); + expect(flagHealed.value).toEqual("drift-v1"); + + // ── Cycle 4: destroy through BOTH stores — census-clean ──────────── + // `fresh` owns the current rows (successor token included); the + // original durable scratch still tracks cycle-1's rows against the + // same physical resources — both destroys are idempotent. + yield* fresh.destroy(); + yield* stack.destroy(); + yield* expectEdgeConfigGone(c1.flags.edgeConfigId); + yield* expectProjectGone(c1.fn.projectId); + }).pipe(logLevel), + // Three deploy cycles (greenfield, adopt, heal) + a gated plan over a + // real Function — justified above the 120s single-resource budget. + { timeout: 360_000 }, +); diff --git a/packages/alchemy/test/Vercel/Chains/DevMixed.local.test.ts b/packages/alchemy/test/Vercel/Chains/DevMixed.local.test.ts new file mode 100644 index 0000000000..32fee122aa --- /dev/null +++ b/packages/alchemy/test/Vercel/Chains/DevMixed.local.test.ts @@ -0,0 +1,496 @@ +/** + * DEPTH chain 9 — dev-mode mixed stack: a LOCAL `Vercel.Function` binding a + * LIVE `Vercel.BlobStore` (`Alchemy.remote()`), then the provider-mode FLIP + * of the Function itself, cycled both directions. + * + * Runs under `Test.make({ dev: true })` (RPC sidecar topology, same as the + * real `alchemy dev`). Four full deploy cycles over one stack: + * + * 1. **Mixed deploy** — local fn (`dev:` markers) + live store (real + * `store_` id) + a mode-agnostic donor `Vercel.Project` connected via + * the store's `projects` prop. The fn's binding contributes its `dev:` + * project id, which the store reconciler must FILTER (never handed to + * the connections API); without a token the fn's blob client reports + * the documented missing-token contract. + * 2. **Hot restart** — env change (greeting + the donor-harvested real + * `BLOB_READ_WRITE_TOKEN`) restarts the local instance: URL and + * `dev:` project id STABLE, deploymentId CHANGED, and the local fn now + * round-trips against the REAL data plane (out-of-band verified from + * the test process with the same token). + * 3. **Mode flip → remote** — `Alchemy.remote()` on the fn: the plan + * classifies a REPLACEMENT (ProviderMode doctrine), the deploy yields a + * real project/deployment, the old local instance is deleted (its port + * stops serving — stamped-local delete routed through the sidecar), the + * store gains the real project connection (platform token injection), + * and the blob written by the LOCAL fn in cycle 2 is served by the LIVE + * fn — the store is the stable data spine across the flip. + * 4. **Flip back → local** — replacement again: `dev:` identity returns, + * the live project is deleted from the cloud (stamped-live delete in a + * dev run), the store's connection set shrinks back to the donor, and + * both cycle-2 and cycle-3 blobs remain readable through the local fn. + * + * Destroy at start and end; census: store + donor project gone from the + * cloud, no local instance still serving. + */ +import * as Alchemy from "@/index.ts"; +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import { + filterProjectEnvs, + getProject, + getProjectEnv, +} from "@distilled.cloud/vercel/projects"; +import { + getStorageStoreConnections, + getStorageStoresById, +} from "@distilled.cloud/vercel/storage"; +import { expect } from "alchemy-test"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; +import * as HttpClient from "effect/unstable/http/HttpClient"; + +const { test } = Test.make({ + providers: Vercel.providers(), + dev: true, +}); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +const fixtureMain = new URL("./fixtures/mixed-blob-fn.ts", import.meta.url) + .pathname; + +const BLOB_TOKEN_ENV = "BLOB_READ_WRITE_TOKEN"; + +class NotReady extends Data.TaggedError("NotReady")<{ + status: number; + message: string; +}> {} + +// First requests race the dev bundle's first build (local) or fresh +// .vercel.app URL propagation (live) — bounded retry either way. +const readiness = Schedule.max([ + Schedule.min([ + Schedule.exponential("500 millis"), + Schedule.spaced("2 seconds"), + ]), + Schedule.recurs(45), +]); + +const getJsonReady = (url: string) => + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const res = yield* client.get(url).pipe( + Effect.flatMap((res) => + res.status === 200 + ? Effect.succeed(res) + : Effect.fail( + new NotReady({ + status: res.status, + message: `GET ${url} -> ${res.status}`, + }), + ), + ), + Effect.retry({ + while: (e): e is NotReady => e instanceof NotReady, + schedule: readiness, + }), + ); + return yield* res.json; + }).pipe(Effect.orDie); + +/** Status of a GET, or -1 when the transport itself fails (dead port). */ +const statusOf = (url: string) => + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + return yield* client.get(url).pipe( + Effect.map((res) => res.status), + Effect.catch(() => Effect.succeed(-1)), + ); + }); + +/** + * Harvest the platform-injected `BLOB_READ_WRITE_TOKEN` from a connected + * project via the single-env GET (the injected row is `encrypted`; the + * single-env GET returns plaintext — PROBES.md D6). Bounded retry: + * injection can lag the connect call. + */ +const harvestBlobToken = (projectId: string) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + const envsBody = yield* filterProjectEnvs({ idOrName: projectId, teamId }); + const rows = ( + Array.isArray(envsBody) + ? envsBody + : typeof envsBody === "object" && + envsBody !== null && + "envs" in envsBody + ? (envsBody as { envs: unknown[] }).envs + : [] + ) as Array<{ key?: string; id?: string }>; + const tokenRow = rows.find((row) => row.key === BLOB_TOKEN_ENV); + if (tokenRow?.id === undefined) return undefined; + const decrypted = yield* getProjectEnv({ + idOrName: projectId, + id: tokenRow.id, + teamId, + }); + return typeof decrypted === "object" && + decrypted !== null && + "value" in decrypted && + typeof decrypted.value === "string" && + decrypted.value !== "" + ? decrypted.value + : undefined; + }).pipe( + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (token) => token !== undefined, + times: 15, + }), + Effect.flatMap((token) => + token === undefined + ? Effect.die( + `${BLOB_TOKEN_ENV} was never injected into the donor project`, + ) + : Effect.succeed(token), + ), + ); + +/** Poll (bounded) until the project is gone from the cloud. */ +const expectProjectGone = (projectId: string) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + const gone = yield* getProject({ idOrName: projectId, teamId }).pipe( + Effect.map(() => false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (g) => g, + times: 10, + }), + ); + expect(gone).toBe(true); + }); + +/** Poll (bounded) until the blob store is gone from the cloud. */ +const expectStoreGone = (storeId: string) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + const gone = yield* getStorageStoresById({ id: storeId, teamId }).pipe( + Effect.map(() => false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (g) => g, + times: 10, + }), + ); + expect(gone).toBe(true); + }); + +/** + * Best-effort purge of every blob in the chain store via the harvested + * data-plane token — crash-safety: `Effect.ensuring` this behind the + * cycles so a mid-test failure can never strand a non-empty store (whose + * delete would 409 `not_empty` forever; the delete's disconnect phase + * would ALSO revoke the only token, wedging cleanup — observed live). + */ +const purgeChainBlobs = (token: string) => + Effect.tryPromise(async () => { + const client = Vercel.readWriteBlobFromEnv({ token, access: "public" }); + const listed = await client.list(); + if (listed.blobs.length > 0) { + await client.del(listed.blobs.map((blob) => blob.pathname)); + } + }).pipe(Effect.ignore); + +/** Engine-level plan action for a logical id. */ +const actionOf = (plan: any, logicalId: string) => + (Object.values(plan.resources) as any[]).find( + (node: any) => node.resource.LogicalId === logicalId, + )?.action; + +interface CycleOptions { + greeting: string; + remote: boolean; + token?: string | undefined; +} + +/** + * The chain's one program, re-deployed each cycle with different options. + * The store is ALWAYS live (`Alchemy.remote()`); only the Function's mode + * flips. The store↔fn edge is circular on purpose (fn env captures + * `store.storeId`; the store's binding contract receives `fn.projectId`) — + * the Function's precreate stub breaks the cycle, exactly like the live + * capability suite. + */ +const program = (opts: CycleOptions) => + Effect.gen(function* () { + const donor = yield* Vercel.Project("TokenDonor", {}); + const store = yield* Vercel.BlobStore("MixedStore", { + access: "public", + projects: [donor.projectId], + }).pipe(Alchemy.remote()); + const fn = yield* Vercel.Function("MixedFn", { + main: fixtureMain, + env: { + GREETING: opts.greeting, + BLOB_STORE_ID: store.storeId, + ...(opts.token !== undefined ? { [BLOB_TOKEN_ENV]: opts.token } : {}), + }, + }).pipe(Alchemy.remote(opts.remote)); + yield* store.bind("MixedFn", { projects: [fn.projectId] }); + return { donor, store, fn }; + }); + +test.provider( + "dev-mixed chain: local fn + live store, hot restart, mode flips both ways, stamped deletes", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + const { teamId } = yield* Vercel.VercelEnvironment.current; + + // ── Cycle 1: mixed deploy — local fn, live store, donor project. + const c1 = yield* stack.deploy( + program({ greeting: "hello", remote: false }), + ); + + // dev identity markers on the fn — proof its lifecycle never left the + // local provider. + expect(c1.fn.url).toMatch(/^http:\/\/localhost:\d+$/); + expect(c1.fn.projectId).toMatch(/^dev:/); + expect(c1.fn.deploymentId).toMatch(/^dev:/); + + // The store is REAL (remote() in a dev run) and the donor is real too + // (Project is mode-agnostic → always live). + expect(c1.store.storeId).toMatch(/^store_/); + expect(c1.store.localApiUrl).toBeUndefined(); + expect(c1.donor.projectId).toMatch(/^prj_/); + + // The fn's binding contributed its dev: project id — the store + // reconciler must FILTER it: only the donor is connected, both in the + // returned attributes and out-of-band on the cloud API. + expect(c1.store.projectIds).toEqual([c1.donor.projectId]); + const connections1 = yield* getStorageStoreConnections({ + storeId: c1.store.storeId, + teamId, + }); + expect(connections1.connections.map((c) => c.projectId).sort()).toEqual([ + c1.donor.projectId, + ]); + const found = yield* getStorageStoresById({ + id: c1.store.storeId, + teamId, + }); + expect(found.store.id).toBe(c1.store.storeId); + + // Local runtime env: platform dev env + the store id capture, but NO + // token (a live store's token only exists as project env of a real + // connected project — the documented mixed-stack contract). + const env1 = (yield* getJsonReady(`${c1.fn.url}/env`)) as { + greeting: string | null; + vercelEnv: string | null; + storeId: string | null; + hasToken: boolean; + }; + expect(env1.greeting).toBe("hello"); + expect(env1.vercelEnv).toBe("development"); + expect(env1.storeId).toBe(c1.store.storeId); + expect(env1.hasToken).toBe(false); + const putNoToken = (yield* getJsonReady( + `${c1.fn.url}/blob/put?path=chain%2Fmsg.txt&body=x`, + )) as { tokenMissing?: boolean }; + expect(putNoToken.tokenMissing).toBe(true); + + // Harvest the donor's platform-injected token — the supported route + // to a live data plane from a local process. + const token = yield* harvestBlobToken(c1.donor.projectId); + expect(token).toMatch(/^vercel_blob_rw_/); + + // From here on, blobs exist in the live store — ensure they are + // purged even if a cycle fails, so teardown's store delete cannot + // 409 `not_empty` (see purgeChainBlobs). + yield* Effect.gen(function* () { + // ── Cycle 2: env change → hot restart. URL + dev project STABLE, + // deploymentId CHANGED, and the local fn now reaches the REAL data + // plane with the injected token. + const c2 = yield* stack.deploy( + program({ greeting: "bonjour", remote: false, token }), + ); + expect(c2.fn.url).toBe(c1.fn.url); + expect(c2.fn.projectId).toBe(c1.fn.projectId); + expect(c2.fn.deploymentId).not.toBe(c1.fn.deploymentId); + expect(c2.store.storeId).toBe(c1.store.storeId); + expect(c2.store.projectIds).toEqual([c1.donor.projectId]); + + // Make-before-break: poll until the restarted child serves the new + // env. + const env2 = yield* Effect.gen(function* () { + return (yield* getJsonReady(`${c2.fn.url}/env`)) as { + greeting: string | null; + hasToken: boolean; + }; + }).pipe( + Effect.repeat({ + schedule: Schedule.spaced("500 millis"), + until: (env) => env.greeting === "bonjour", + times: 60, + }), + ); + expect(env2.greeting).toBe("bonjour"); + expect(env2.hasToken).toBe(true); + + // LOCAL fn → REAL data plane round trip. + const put2 = (yield* getJsonReady( + `${c2.fn.url}/blob/put?path=chain%2Fmsg.txt&body=via-local`, + )) as { etag: string; url: string; pathname: string }; + expect(put2.pathname).toBe("chain/msg.txt"); + expect(put2.url).toContain(".public.blob.vercel-storage.com"); + const got2 = (yield* getJsonReady( + `${c2.fn.url}/blob/get?path=chain%2Fmsg.txt`, + )) as { text: string }; + expect(got2.text).toBe("via-local"); + + // Out-of-band from the TEST process with the same harvested token: + // the write really landed in the live store. + const outOfBand = Vercel.readWriteBlobFromEnv({ + token, + access: "public", + }); + const oob = yield* Effect.promise(() => outOfBand.get("chain/msg.txt")); + expect(oob.text).toBe("via-local"); + + // ── Cycle 3: mode flip → remote. The stamped-mode switch must plan a + // REPLACEMENT (ProviderMode doctrine) — never an in-place update. + const plan3 = yield* stack.plan( + program({ greeting: "bonjour", remote: true }), + ); + expect(actionOf(plan3, "MixedFn")).toBe("replace"); + expect(actionOf(plan3, "TokenDonor")).toBe("noop"); + + const c3 = yield* stack.deploy( + program({ greeting: "bonjour", remote: true }), + ); + + // CHANGED: real cloud identity. + expect(c3.fn.projectId).toMatch(/^prj_/); + expect(c3.fn.url).toMatch(/^https:\/\//); + // STABLE: the store and donor survived the fn replacement untouched. + expect(c3.store.storeId).toBe(c1.store.storeId); + expect(c3.donor.projectId).toBe(c1.donor.projectId); + + // The binding now contributes a REAL project id → the store gained + // the connection (donor + fn), and the platform injected the token + // into the fn's project. + expect(c3.store.projectIds).toEqual( + [c1.donor.projectId, c3.fn.projectId].sort(), + ); + const fnEnvs = yield* filterProjectEnvs({ + idOrName: c3.fn.projectId, + teamId, + }); + const fnEnvRows = ( + Array.isArray(fnEnvs) + ? fnEnvs + : typeof fnEnvs === "object" && fnEnvs !== null && "envs" in fnEnvs + ? (fnEnvs as { envs: unknown[] }).envs + : [] + ) as Array<{ key?: string }>; + expect( + fnEnvRows.find((row) => row.key === BLOB_TOKEN_ENV), + ).toBeDefined(); + + // The old LOCAL instance is GONE — the replacement's old-generation + // delete routed to the stamped-local provider through the sidecar and + // its port stopped serving. + const deadLocal = yield* statusOf(`${c1.fn.url}/env`).pipe( + Effect.repeat({ + schedule: Schedule.spaced("1 second"), + until: (status) => status !== 200, + times: 10, + }), + ); + expect(deadLocal).not.toBe(200); + + // LIVE fn round trip: platform-injected token (no explicit env), and + // the blob written by the LOCAL fn in cycle 2 is served — the store + // is the stable data spine across the mode flip. + const env3 = (yield* getJsonReady(`${c3.fn.url}/env`)) as { + greeting: string | null; + vercelEnv: string | null; + hasToken: boolean; + }; + expect(env3.greeting).toBe("bonjour"); + expect(env3.vercelEnv).toBe("production"); + expect(env3.hasToken).toBe(true); + const got3 = (yield* getJsonReady( + `${c3.fn.url}/blob/get?path=chain%2Fmsg.txt`, + )) as { text: string }; + expect(got3.text).toBe("via-local"); + const put3 = (yield* getJsonReady( + `${c3.fn.url}/blob/put?path=chain%2Flive.txt&body=via-live`, + )) as { etag: string }; + expect(put3.etag).toBeDefined(); + + // ── Cycle 4: flip back → local. Replacement again; dev: identity + // returns; the live project is deleted from the CLOUD even though the + // run is dev (stamped-live delete routing); the store's connections + // shrink back to the donor. + const plan4 = yield* stack.plan( + program({ greeting: "bonjour", remote: false, token }), + ); + expect(actionOf(plan4, "MixedFn")).toBe("replace"); + + const c4 = yield* stack.deploy( + program({ greeting: "bonjour", remote: false, token }), + ); + expect(c4.fn.url).toMatch(/^http:\/\/localhost:\d+$/); + expect(c4.fn.projectId).toMatch(/^dev:/); + expect(c4.store.storeId).toBe(c1.store.storeId); + expect(c4.store.projectIds).toEqual([c1.donor.projectId]); + yield* expectProjectGone(c3.fn.projectId); + + // Both blobs (cycle-2 local write, cycle-3 live write) survive into + // the fourth cycle through the local fn. + const got4 = (yield* getJsonReady( + `${c4.fn.url}/blob/get?path=chain%2Fmsg.txt`, + )) as { text: string }; + expect(got4.text).toBe("via-local"); + const got4live = (yield* getJsonReady( + `${c4.fn.url}/blob/get?path=chain%2Flive.txt`, + )) as { text: string }; + expect(got4live.text).toBe("via-live"); + + // Empty the store through the local fn so the destroy's store delete + // cannot 409 `not_empty`. + for (const path of ["chain%2Fmsg.txt", "chain%2Flive.txt"]) { + const del = (yield* getJsonReady( + `${c4.fn.url}/blob/del?path=${path}`, + )) as { ok: boolean }; + expect(del.ok).toBe(true); + } + + // ── Destroy + census: store and donor gone from the cloud, and no + // local instance still serving. + yield* stack.destroy(); + yield* expectStoreGone(c1.store.storeId); + yield* expectProjectGone(c1.donor.projectId); + const deadAfterDestroy = yield* statusOf(`${c4.fn.url}/env`).pipe( + Effect.repeat({ + schedule: Schedule.spaced("1 second"), + until: (status) => status !== 200, + times: 10, + }), + ); + expect(deadAfterDestroy).not.toBe(200); + }).pipe(Effect.ensuring(purgeChainBlobs(token))); + }).pipe(logLevel), + // Four full cycles, two of which create/delete a REAL Vercel deployment — + // justified per the chain doctrine. + { timeout: 300_000 }, +); diff --git a/packages/alchemy/test/Vercel/Chains/DomainsChain.test.ts b/packages/alchemy/test/Vercel/Chains/DomainsChain.test.ts new file mode 100644 index 0000000000..7af7daaa20 --- /dev/null +++ b/packages/alchemy/test/Vercel/Chains/DomainsChain.test.ts @@ -0,0 +1,443 @@ +/** + * Wave E depth chain (DEPTH.md row 4) — Domains chain: + * + * Domain (x2, `.example` reserved-TLD names) → DnsRecord → ProjectDomain + * on a Website.StaticSite (x2). + * + * Cycles: deploy full chain → update the record's value (in-place update; + * Vercel re-mints the id but nothing else churns) → MOVE the record to the + * second domain (replacement, both domains stay deployed per the engine + * deadlock rule) → re-point the ProjectDomain at the second site's project + * (replacement, both sites stay deployed) → destroy → census. + * + * Every cycle asserts BOTH what changed and what stayed stable + * (domainIds, projectIds, deploymentIds, attachment identity). + * + * `.example` is a reserved TLD — the names can never be registered, so the + * domains stay unresolvable (nameservers never point at Vercel). The + * verification-state assertions are honest about that: team-level `.example` + * domains report `verified: true` with pending `intendedNameservers`, and a + * ProjectDomain whose apex lives on the same team verifies immediately. + */ +import * as Output from "@/Output"; +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as dns from "@distilled.cloud/vercel/dns"; +import * as domains from "@distilled.cloud/vercel/domains"; +import * as projects from "@distilled.cloud/vercel/projects"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +const teamScope = Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + return teamId !== undefined ? { teamId } : {}; +}); + +/** Out-of-band read of a team domain via distilled. */ +const getDomain = (name: string) => + Effect.gen(function* () { + const team = yield* teamScope; + return yield* domains.getDomain({ domain: name, ...team }).pipe( + Effect.map((res) => res.domain as typeof res.domain | undefined), + Effect.catchTag("NotFound", () => Effect.succeed(undefined)), + ); + }); + +/** Out-of-band read of a DNS record by id (id endpoint is team-agnostic). */ +const getRecord = (recordId: string) => + dns.getDomainsRecordsByRecordId({ recordId }).pipe( + Effect.map((r): dns.GetDomainsRecordsByRecordIdResponse | undefined => r), + Effect.catchTag("NotFound", () => Effect.succeed(undefined)), + ); + +/** Out-of-band: every user-created record in a zone with the given name. */ +const listRecordsNamed = (domain: string, name: string) => + Effect.gen(function* () { + const team = yield* teamScope; + const body = yield* dns + .getRecords({ domain, limit: "100", ...team }) + .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))); + if (body === undefined || typeof body === "string") return []; + return body.records.filter( + (r) => r.name === name && r.creator !== "system", + ); + }); + +/** Out-of-band: the project-domain attachment row, if present. */ +const findProjectDomain = (projectId: string, name: string) => + Effect.gen(function* () { + const team = yield* teamScope; + const body = yield* projects + .getProjectDomains({ idOrName: projectId, limit: 100, ...team }) + .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))); + return body?.domains.find((d) => d.name === name); + }); + +/** Typed bounded wait until a record id no longer resolves. */ +const expectRecordGone = (recordId: string) => + Effect.gen(function* () { + const gone = yield* getRecord(recordId).pipe( + Effect.map((r) => + r === undefined ? ("gone" as const) : ("found" as const), + ), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (s) => s === "gone", + times: 10, + }), + ); + expect(gone).toEqual("gone"); + }); + +/** Typed bounded wait until a team domain no longer resolves. */ +const expectDomainGone = (name: string) => + Effect.gen(function* () { + const gone = yield* getDomain(name).pipe( + Effect.map((d) => + d === undefined ? ("gone" as const) : ("found" as const), + ), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (s) => s === "gone", + times: 10, + }), + ); + expect(gone).toEqual("gone"); + }); + +/** Typed bounded wait until a project no longer resolves. */ +const expectProjectGone = (projectId: string) => + Effect.gen(function* () { + const team = yield* teamScope; + const gone = yield* projects + .getProject({ idOrName: projectId, ...team }) + .pipe( + Effect.as("found" as const), + Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (s) => s === "gone", + times: 10, + }), + ); + expect(gone).toEqual("gone"); + }); + +/** Static-site fixture: src/index.html + a cp build script in a temp dir. */ +const makeSiteFixture = (marker: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectory({ + prefix: `alchemy-vercel-chain-${marker}-`, + }); + yield* Effect.addFinalizer(() => + Effect.ignore(fs.remove(dir, { recursive: true })), + ); + yield* fs.makeDirectory(path.join(dir, "src"), { recursive: true }); + // dist is build output — keep it out of the memo input hash. + yield* fs.writeFileString(path.join(dir, ".gitignore"), "dist\n"); + const buildSh = path.join(dir, "build.sh"); + yield* fs.writeFileString( + buildSh, + "#!/bin/sh\nmkdir -p dist\ncp src/index.html dist/index.html\n", + ); + yield* fs.chmod(buildSh, 0o755); + yield* fs.writeFileString( + path.join(dir, "src", "index.html"), + `\n

${marker}

\n`, + ); + return dir; + }); + +test.provider( + "domains chain: Domain -> DnsRecord -> ProjectDomain on StaticSite across 4 reconciliation cycles", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const APEX_A = "alchemy-vercel-chain-dom-a.example"; + const APEX_B = "alchemy-vercel-chain-dom-b.example"; + const ATTACHED = `app.${APEX_A}`; + + const dirA = yield* makeSiteFixture("site-a"); + const dirB = yield* makeSiteFixture("site-b"); + + /** + * One parameterized chain shape. Both domains and both sites stay + * deployed in EVERY cycle — a deploy must never replace a resource + * while simultaneously removing its old dependency (engine deadlock). + */ + const deployChain = (opts: { + readonly recordOn: "a" | "b"; + readonly recordValue: string; + readonly attachTo: "a" | "b"; + }) => + stack.deploy( + Effect.gen(function* () { + const domainA = yield* Vercel.Domain("DomainA", { name: APEX_A }); + const domainB = yield* Vercel.Domain("DomainB", { name: APEX_B }); + const record = yield* Vercel.DnsRecord("ChainProbe", { + domain: opts.recordOn === "a" ? domainA : domainB, + type: "TXT", + name: "chain-probe", + value: opts.recordValue, + comment: "alchemy DomainsChain depth test", + }); + const siteA = yield* Vercel.Website.StaticSite("SiteA", { + command: "./build.sh", + cwd: dirA, + outdir: "dist", + }); + const siteB = yield* Vercel.Website.StaticSite("SiteB", { + command: "./build.sh", + cwd: dirB, + outdir: "dist", + }); + const attached = yield* Vercel.ProjectDomain("Attached", { + project: opts.attachTo === "a" ? siteA : siteB, + // Derived from the Domain output so the attachment has a real + // dependency edge on DomainA (detach before domain delete). + name: Output.interpolate`app.${domainA.name}`, + }); + return { domainA, domainB, record, siteA, siteB, attached }; + }), + ); + + // ── Cycle 1: greenfield deploy of the whole chain ──────────────── + const c1 = yield* deployChain({ + recordOn: "a", + recordValue: "chain-v1", + attachTo: "a", + }); + + expect(c1.domainA.name).toEqual(APEX_A); + expect(c1.domainB.name).toEqual(APEX_B); + expect(c1.domainA.domainId.length).toBeGreaterThan(0); + // Honest verification shape for reserved `.example` names: the team + // domain reports verified, but the zone stays pending forever — the + // name can never be registered, so Vercel reports NO nameservers + // (observed live: both `nameservers` and `intendedNameservers` are + // empty for reserved TLDs) and the domain never resolves. + expect(c1.domainA.verified).toEqual(true); + expect(c1.domainA.nameservers).toEqual([]); + expect(c1.domainA.intendedNameservers).toEqual([]); + + expect(c1.record.domain).toEqual(APEX_A); + expect(c1.record.type).toEqual("TXT"); + expect(c1.record.value).toEqual("chain-v1"); + + expect(c1.siteA.projectId).toBeDefined(); + expect(c1.siteB.projectId).toBeDefined(); + expect(c1.siteA.projectId).not.toEqual(c1.siteB.projectId); + expect(c1.siteA.deploymentId).not.toEqual(""); + + expect(c1.attached.projectId).toEqual(c1.siteA.projectId); + expect(c1.attached.name).toEqual(ATTACHED); + expect(c1.attached.apexName).toEqual(APEX_A); + // The apex is a domain-of-record on the SAME team, so the attachment + // verifies immediately and carries no challenge. + expect(c1.attached.verified).toEqual(true); + expect(c1.attached.verification ?? []).toEqual([]); + + // Out-of-band verification via distilled. + const obsDomainA = yield* getDomain(APEX_A); + expect(obsDomainA?.id).toEqual(c1.domainA.domainId); + const obsDomainB = yield* getDomain(APEX_B); + expect(obsDomainB?.id).toEqual(c1.domainB.domainId); + const obsRecord1 = yield* getRecord(c1.record.recordId); + expect(obsRecord1?.value).toEqual("chain-v1"); + expect(obsRecord1?.domain).toEqual(APEX_A); + const obsAttached1 = yield* findProjectDomain( + c1.siteA.projectId, + ATTACHED, + ); + expect(obsAttached1).toBeDefined(); + expect(obsAttached1!.verified).toEqual(true); + + // ── Cycle 2: record VALUE update — in-place, nothing else churns ─ + const c2 = yield* deployChain({ + recordOn: "a", + recordValue: "chain-v2", + attachTo: "a", + }); + + // Changed: the record value (classified as an update — the record + // stays in zone A; Vercel re-mints the id on every update, which is + // platform behavior, not a replacement). + expect(c2.record.value).toEqual("chain-v2"); + expect(c2.record.domain).toEqual(APEX_A); + expect(c2.record.recordId).not.toEqual(c1.record.recordId); + + // Stable: domains, sites (no rebuild/redeploy), attachment. + expect(c2.domainA.domainId).toEqual(c1.domainA.domainId); + expect(c2.domainB.domainId).toEqual(c1.domainB.domainId); + expect(c2.siteA.projectId).toEqual(c1.siteA.projectId); + expect(c2.siteA.deploymentId).toEqual(c1.siteA.deploymentId); + expect(c2.siteB.deploymentId).toEqual(c1.siteB.deploymentId); + expect(c2.attached.projectId).toEqual(c1.attached.projectId); + expect(c2.attached.name).toEqual(ATTACHED); + expect(c2.attached.verified).toEqual(true); + + // Out-of-band: the old record id is gone, the new one carries v2, and + // the zone holds exactly ONE chain-probe row (update left no orphan). + const obsRecord2 = yield* getRecord(c2.record.recordId); + expect(obsRecord2?.value).toEqual("chain-v2"); + yield* expectRecordGone(c1.record.recordId); + const zoneARows2 = yield* listRecordsNamed(APEX_A, "chain-probe"); + expect(zoneARows2.length).toEqual(1); + expect(zoneARows2[0]!.id).toEqual(c2.record.recordId); + + // ── Cycle 3: MOVE the record to domain B — replacement ─────────── + // Both domains remain deployed across the move (deadlock rule). + const c3 = yield* deployChain({ + recordOn: "b", + recordValue: "chain-v2", + attachTo: "a", + }); + + // Changed: the record now lives in zone B under a new id. + expect(c3.record.domain).toEqual(APEX_B); + expect(c3.record.value).toEqual("chain-v2"); + expect(c3.record.recordId).not.toEqual(c2.record.recordId); + + // Stable: both domains, both sites, the attachment. + expect(c3.domainA.domainId).toEqual(c1.domainA.domainId); + expect(c3.domainB.domainId).toEqual(c1.domainB.domainId); + expect(c3.siteA.deploymentId).toEqual(c1.siteA.deploymentId); + expect(c3.siteB.deploymentId).toEqual(c1.siteB.deploymentId); + expect(c3.attached.projectId).toEqual(c1.siteA.projectId); + + // Out-of-band: record present in zone B, fully gone from zone A. + const obsRecord3 = yield* getRecord(c3.record.recordId); + expect(obsRecord3?.domain).toEqual(APEX_B); + yield* expectRecordGone(c2.record.recordId); + const zoneARows3 = yield* listRecordsNamed(APEX_A, "chain-probe"); + expect(zoneARows3.length).toEqual(0); + const zoneBRows3 = yield* listRecordsNamed(APEX_B, "chain-probe"); + expect(zoneBRows3.length).toEqual(1); + + // ── Cycle 4: re-point the ProjectDomain at SiteB's project ─────── + // Both sites remain deployed across the move (deadlock rule). The + // same domain name cannot be attached to two projects at once, so + // this replacement must be delete-first. + const c4 = yield* deployChain({ + recordOn: "b", + recordValue: "chain-v2", + attachTo: "b", + }); + + // Changed: the attachment now points at SiteB's project. + expect(c4.attached.projectId).toEqual(c1.siteB.projectId); + expect(c4.attached.name).toEqual(ATTACHED); + expect(c4.attached.apexName).toEqual(APEX_A); + expect(c4.attached.verified).toEqual(true); + + // Stable: both sites survive the move untouched; the record and both + // domains do not churn. + expect(c4.siteA.projectId).toEqual(c1.siteA.projectId); + expect(c4.siteA.deploymentId).toEqual(c1.siteA.deploymentId); + expect(c4.siteB.deploymentId).toEqual(c1.siteB.deploymentId); + expect(c4.record.recordId).toEqual(c3.record.recordId); + expect(c4.domainA.domainId).toEqual(c1.domainA.domainId); + expect(c4.domainB.domainId).toEqual(c1.domainB.domainId); + + // Out-of-band: attachment present on SiteB's project, gone from + // SiteA's; SiteA's project itself still exists. + const obsAttachedB = yield* findProjectDomain( + c1.siteB.projectId, + ATTACHED, + ); + expect(obsAttachedB).toBeDefined(); + const obsAttachedA = yield* findProjectDomain( + c1.siteA.projectId, + ATTACHED, + ); + expect(obsAttachedA).toBeUndefined(); + const team = yield* teamScope; + const siteAProject = yield* projects.getProject({ + idOrName: c1.siteA.projectId, + ...team, + }); + expect(siteAProject.id).toEqual(c1.siteA.projectId); + + // ── Destroy + census ───────────────────────────────────────────── + yield* stack.destroy(); + + yield* expectRecordGone(c3.record.recordId); + yield* expectDomainGone(APEX_A); + yield* expectDomainGone(APEX_B); + yield* expectProjectGone(c1.siteA.projectId); + yield* expectProjectGone(c1.siteB.projectId); + + // Destroy again — the whole chain's delete path is idempotent. + yield* stack.destroy(); + }).pipe(logLevel, Effect.scoped), + { timeout: 300_000 }, +); + +test.provider( + "delete-first Domain replacement heals the child DnsRecord", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const APEX = "alchemy-vercel-chain-dom-repl.example"; + + // `cdnEnabled` is immutable on the platform, so flipping it is a + // delete-first replacement of the Domain under the SAME name. The + // child record's domain NAME does not change, so the record is not + // replaced — but deleting the zone physically destroys it, and the + // record's reconcile must observe the loss and recreate it. + const deployWith = (cdnEnabled: boolean | undefined) => + stack.deploy( + Effect.gen(function* () { + const domain = yield* Vercel.Domain("ReplDomain", { + name: APEX, + ...(cdnEnabled !== undefined ? { cdnEnabled } : {}), + }); + const record = yield* Vercel.DnsRecord("Survivor", { + domain, + type: "TXT", + name: "survivor", + value: "still-here", + comment: "alchemy DomainsChain parent-replacement test", + }); + return { domain, record }; + }), + ); + + const c1 = yield* deployWith(undefined); + expect(c1.record.domain).toEqual(APEX); + const obs1 = yield* getRecord(c1.record.recordId); + expect(obs1?.value).toEqual("still-here"); + + // Flip the immutable flag — delete-first replacement of the parent. + const c2 = yield* deployWith(false); + expect(c2.domain.name).toEqual(APEX); + + // The child record must exist live after the parent was replaced. + const survivors = yield* listRecordsNamed(APEX, "survivor"); + expect(survivors.length).toEqual(1); + expect(survivors[0]!.value).toEqual("still-here"); + // And the state row must point at the live record. + const obs2 = yield* getRecord(c2.record.recordId); + expect(obs2).toBeDefined(); + expect(obs2!.value).toEqual("still-here"); + + yield* stack.destroy(); + yield* expectDomainGone(APEX); + }).pipe(logLevel), + { timeout: 180_000 }, +); diff --git a/packages/alchemy/test/Vercel/Chains/InvokeReplacement.test.ts b/packages/alchemy/test/Vercel/Chains/InvokeReplacement.test.ts new file mode 100644 index 0000000000..76dd307a10 --- /dev/null +++ b/packages/alchemy/test/Vercel/Chains/InvokeReplacement.test.ts @@ -0,0 +1,283 @@ +/** + * DEPTH chain 2 — upstream replacement propagation through InvokeFunction + * (live, doppler alchemy-v2/dev env). + * + * Two chains, each ≥3 full deploy cycles asserting BOTH what changed and + * what stayed stable: + * + * 1. One-way: async-mode `ChainUpstreamA` (declared inline so its `name` + * prop can vary) ← `invoke({ LogicalId })` ← Effect-mode `ChainCallerB`. + * Cycle 1 greenfield + round trip; cycle 2 identical redeploy (all ids + * stable, skip-on-hash); cycle 3 renames A — an explicit `name` change + * is a project REPLACEMENT — while B stays deployed, asserting B's + * bound URL/BYPASS env re-resolved to the successor, B redeployed in + * place (same project), B reaches A's successor over HTTP, and A's old + * project is gone. + * + * 2. Circular: `ChainEchoA` ↔ `ChainEchoB` (fresh logical ids mirroring + * the Functions suite's invoke pair). Cycle 3 renames B — a replacement + * INSIDE the cycle, exercising the pre-create story under replacement: + * the successor B's project (URL + bypass secret) must exist before + * either side's redeploy binds it. + * + * Engine-deadlock rule respected: every replacement keeps the dependent's + * binding deployed — nothing removes a dependency in the same deploy that + * replaces its target. + */ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as projects from "@distilled.cloud/vercel/projects"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import * as Schedule from "effect/Schedule"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import { MinimumLogLevel } from "effect/References"; +import ChainCallerB from "./fixtures/caller-b.ts"; +import ChainEchoA from "./fixtures/chain-a.ts"; +import ChainEchoB, { chainBName } from "./fixtures/chain-b.ts"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +const upstreamMain = new URL("./fixtures/upstream-a.ts", import.meta.url) + .pathname; + +/** Deterministic successor names (constants — never `Date.now()`). */ +const RENAMED_A = "alch-chain-invrepl-upstream-two"; +const RENAMED_B = "alch-chain-invrepl-echo-b-two"; + +// Fresh .vercel.app URLs take a few seconds to start serving 200s — always +// retry the first request (bounded: `times` is the hard cap). +const readiness = Schedule.min([ + Schedule.exponential("500 millis"), + Schedule.spaced("2 seconds"), +]); + +const getJson = (url: string) => + Effect.gen(function* () { + const response = yield* HttpClient.get(url); + const body = yield* response.text; + if (response.status !== 200) { + return yield* Effect.fail( + new Error(`status ${response.status}: ${body.slice(0, 300)}`), + ); + } + // Fresh production aliases can transiently serve a 200 HTML edge + // placeholder before the deployment is routed — treat an unparseable + // body as retryable, not a crash. + return yield* Effect.try({ + try: () => JSON.parse(body) as unknown, + catch: () => + new Error(`status 200 but non-JSON body: ${body.slice(0, 300)}`), + }); + }).pipe(Effect.retry({ schedule: readiness, times: 40 })); + +/** Poll (bounded) until a project is gone; asserts it. */ +const expectProjectGone = (projectId: string) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + const gone = yield* projects + .getProject({ idOrName: projectId, teamId }) + .pipe( + Effect.map(() => false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (g) => g, + times: 10, + }), + ); + expect(gone).toBe(true); + }); + +const bypassValue = (attrs: { + protectionBypass: Redacted.Redacted | undefined; +}) => { + expect(attrs.protectionBypass).toBeDefined(); + return Redacted.value(attrs.protectionBypass!); +}; + +interface CallerResponse { + fn: string; + targetUrl: string; + hasBypass: boolean; + target: { fn: string; echo: string }; +} + +interface CircularResponse { + fn: string; + targetUrl: string; + target: { fn: string; echo: string }; +} + +test.provider( + "one-way: renaming upstream A replaces its project and repoints B's invoke env", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const makeStack = (nameA?: string) => + Effect.gen(function* () { + const a = yield* Vercel.Function("ChainUpstreamA", { + main: upstreamMain, + ...(nameA !== undefined ? { name: nameA } : {}), + }); + const b = yield* ChainCallerB; + return { a, b }; + }); + + // ── Cycle 1: greenfield (engine-generated name for A). ────────────── + const c1 = yield* stack.deploy(makeStack()); + expect(c1.a.url).toMatch(/^https:\/\/.+\.vercel\.app$/); + expect(c1.b.url).toMatch(/^https:\/\/.+\.vercel\.app$/); + expect(c1.a.projectId).not.toEqual(c1.b.projectId); + const r1 = (yield* getJson(`${c1.b.url}/call-a`)) as CallerResponse; + expect(r1.fn).toEqual("caller-b"); + expect(r1.targetUrl).toEqual(c1.a.url); + expect(r1.hasBypass).toBe(true); + expect(r1.target.fn).toEqual("upstream-a"); + expect(r1.target.echo).toContain("from=caller-b"); + + // ── Cycle 2: identical redeploy — EVERYTHING stable (skip-on-hash + // across the whole bound pair). ───────────────────────────────── + const c2 = yield* stack.deploy(makeStack()); + expect(c2.a.projectId).toEqual(c1.a.projectId); + expect(c2.a.deploymentId).toEqual(c1.a.deploymentId); + expect(c2.a.url).toEqual(c1.a.url); + expect(c2.b.projectId).toEqual(c1.b.projectId); + expect(c2.b.deploymentId).toEqual(c1.b.deploymentId); + expect(c2.b.url).toEqual(c1.b.url); + expect(bypassValue(c2.b)).toEqual(bypassValue(c1.b)); + + // ── Cycle 3: explicit name ⇒ project REPLACEMENT of A; B stays + // deployed (its invoke binding is never removed). ─────────────── + const c3 = yield* stack.deploy(makeStack(RENAMED_A)); + // Changed: A is a new project under the explicit name, new URL, new + // bypass secret (minted at successor-project ensure). + expect(c3.a.projectId).not.toEqual(c1.a.projectId); + expect(c3.a.projectName).toEqual(RENAMED_A); + expect(c3.a.url).not.toEqual(c1.a.url); + expect(bypassValue(c3.a)).not.toEqual(bypassValue(c1.a)); + // Stable: B keeps its project/URL — but was REDEPLOYED (the successor + // URL/bypass flowed into B's env, and env only takes effect on new + // deployments). + expect(c3.b.projectId).toEqual(c1.b.projectId); + expect(c3.b.url).toEqual(c1.b.url); + expect(c3.b.deploymentId).not.toEqual(c2.b.deploymentId); + expect(bypassValue(c3.b)).toEqual(bypassValue(c1.b)); + // Create-first replacement: the predecessor project is gone AFTER the + // successor deploy (typed wait-until-gone). + yield* expectProjectGone(c1.a.projectId); + // Out-of-band via distilled: the successor exists under the explicit + // name. + const { teamId } = yield* Vercel.VercelEnvironment.current; + const successor = yield* projects.getProject({ + idOrName: c3.a.projectId, + teamId, + }); + expect(successor.name).toEqual(RENAMED_A); + // B reaches A's successor through its re-bound env. B's production + // alias may briefly serve the previous deployment — poll (bounded) + // until the fresh env is live. + const r3 = (yield* getJson(`${c3.b.url}/call-a`).pipe( + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (r) => (r as CallerResponse).targetUrl === c3.a.url, + times: 20, + }), + )) as CallerResponse; + expect(r3.targetUrl).toEqual(c3.a.url); + expect(r3.hasBypass).toBe(true); + expect(r3.target.fn).toEqual("upstream-a"); + expect(r3.target.echo).toContain("from=caller-b"); + + // ── Teardown: census-clean. ────────────────────────────────────── + yield* stack.destroy(); + yield* expectProjectGone(c3.a.projectId); + yield* expectProjectGone(c3.b.projectId); + }).pipe(logLevel), + { timeout: 240_000 }, +); + +test.provider( + "circular: renaming B replaces one side of the A<->B invoke cycle in place", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + chainBName.current = undefined; + + const program = Effect.gen(function* () { + const a = yield* ChainEchoA; + const b = yield* ChainEchoB; + return { a, b }; + }); + + // ── Cycle 1: greenfield circular pair; both directions round-trip. ─ + const c1 = yield* stack.deploy(program); + expect(c1.a.url).toMatch(/^https:\/\/.+\.vercel\.app$/); + expect(c1.b.url).toMatch(/^https:\/\/.+\.vercel\.app$/); + expect(c1.a.projectId).not.toEqual(c1.b.projectId); + const viaA1 = (yield* getJson(`${c1.a.url}/call-b`)) as CircularResponse; + expect(viaA1.fn).toEqual("a"); + expect(viaA1.targetUrl).toEqual(c1.b.url); + expect(viaA1.target.fn).toEqual("b"); + const viaB1 = (yield* getJson(`${c1.b.url}/call-a`)) as CircularResponse; + expect(viaB1.fn).toEqual("b"); + expect(viaB1.targetUrl).toEqual(c1.a.url); + expect(viaB1.target.fn).toEqual("a"); + + // ── Cycle 2: identical redeploy — both sides fully stable. ──────── + const c2 = yield* stack.deploy(program); + expect(c2.a.projectId).toEqual(c1.a.projectId); + expect(c2.a.deploymentId).toEqual(c1.a.deploymentId); + expect(c2.b.projectId).toEqual(c1.b.projectId); + expect(c2.b.deploymentId).toEqual(c1.b.deploymentId); + + // ── Cycle 3: rename B ⇒ replacement INSIDE the cycle. The successor + // B project must be pre-created (URL + bypass minted) before + // either side deploys against it. A's binding stays deployed + // throughout (deadlock rule). ──────────────────────────────────── + chainBName.current = RENAMED_B; + const c3 = yield* stack.deploy(program); + // Changed: B is a new project; A was redeployed with B's successor + // env. + expect(c3.b.projectId).not.toEqual(c1.b.projectId); + expect(c3.b.projectName).toEqual(RENAMED_B); + expect(c3.b.url).not.toEqual(c1.b.url); + expect(c3.a.deploymentId).not.toEqual(c2.a.deploymentId); + // Stable: A's project identity and URL survive its partner's + // replacement. + expect(c3.a.projectId).toEqual(c1.a.projectId); + expect(c3.a.url).toEqual(c1.a.url); + expect(bypassValue(c3.a)).toEqual(bypassValue(c1.a)); + // Predecessor B project is gone. + yield* expectProjectGone(c1.b.projectId); + // Both directions round-trip against the successor. A's production + // alias may briefly serve its previous deployment — poll until the + // re-bound target URL is live. + const viaA3 = (yield* getJson(`${c3.a.url}/call-b`).pipe( + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (r) => (r as CircularResponse).targetUrl === c3.b.url, + times: 20, + }), + )) as CircularResponse; + expect(viaA3.targetUrl).toEqual(c3.b.url); + expect(viaA3.target.fn).toEqual("b"); + const viaB3 = (yield* getJson(`${c3.b.url}/call-a`)) as CircularResponse; + expect(viaB3.targetUrl).toEqual(c3.a.url); + expect(viaB3.target.fn).toEqual("a"); + + // ── Teardown: census-clean. ────────────────────────────────────── + yield* stack.destroy(); + chainBName.current = undefined; + yield* expectProjectGone(c3.a.projectId); + yield* expectProjectGone(c3.b.projectId); + }).pipe(logLevel), + { timeout: 240_000 }, +); diff --git a/packages/alchemy/test/Vercel/Chains/QueueEvolution.test.ts b/packages/alchemy/test/Vercel/Chains/QueueEvolution.test.ts new file mode 100644 index 0000000000..55138d299f --- /dev/null +++ b/packages/alchemy/test/Vercel/Chains/QueueEvolution.test.ts @@ -0,0 +1,375 @@ +/** + * DEPTH.md row 3 — Queue web + schema evolution chain, live against the + * standing Vercel test team (doppler alchemy-v2/dev env). + * + * One logical Function (`QueueEvoFn`) is reconciled through FOUR cycles, + * each a full deploy of a different fixture version of the same resource: + * + * C1 schema v1, default subscribe options — send → push-consume → echo + * C1b identical redeploy — deploymentId MUST NOT change (skip-on-hash) + * C2 subscribe option change (retryAfterSeconds: 45) — redeploy, trigger + * config verified OUT-OF-BAND in the consumer function's + * `.vc-config.json` (deployment files API), delivery still works + * C3 Topic schema EVOLVED (optional `note`) — both ends redeploy; a + * NEW-shape message round-trips AND an OLD-shape raw-JSON message + * (no schema encode, no `note`) still decodes through the consumer + * C4 subscribe removed — `_alchemy-queue.func` gone from the deployment + * files, public HTTP keeps serving, and the platform's verdict on + * producing into the now trigger-less topic is pinned + * + * Every cycle asserts what CHANGED (deploymentId on real changes) and what + * STAYED STABLE (projectId, production URL, deploymentId on no-ops). + * + * Out-of-band verification is fully typed distilled: `listDeploymentFiles` + * + `getDeploymentFileContents` (response schema patched — the OpenAPI doc + * declares no 200 body but the live API returns `{ data: base64 }`). + */ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as deployments from "@distilled.cloud/vercel/deployments"; +import * as projects from "@distilled.cloud/vercel/projects"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import { MinimumLogLevel } from "effect/References"; +import { type Echo } from "./fixtures/queue-evo-shared.ts"; +import QueueEvoV1, { Orders } from "./fixtures/queue-evo-v1.ts"; +import QueueEvoV2 from "./fixtures/queue-evo-v2.ts"; +import QueueEvoV3 from "./fixtures/queue-evo-v3.ts"; +import QueueEvoV4 from "./fixtures/queue-evo-v4.ts"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +// Fresh .vercel.app URLs take a few seconds to start serving 200s — always +// retry the first request (bounded). +const readiness = Schedule.max([ + Schedule.exponential("500 millis"), + Schedule.recurs(20), +]); + +const getJson = (url: string) => + HttpClient.get(url).pipe( + Effect.flatMap((response) => + response.status === 200 + ? response.json + : Effect.fail(new Error(`status ${response.status}`)), + ), + Effect.retry({ schedule: readiness }), + ); + +/** Poll (bounded) until the function's project is gone. */ +const expectProjectGone = (projectId: string) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + const gone = yield* projects + .getProject({ idOrName: projectId, teamId }) + .pipe( + Effect.map(() => false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (g) => g, + times: 10, + }), + ); + expect(gone).toBe(true); + }); + +/** + * Gate a cycle on the production alias actually serving the freshly + * promoted deployment (the alias can lag the deploy by a few seconds). + * Driving traffic before the flip would pin queue sends to the PREVIOUS + * deployment's partition, making their deliveries invisible to this + * cycle's consumers — queue visibility is per (deployment, environment). + */ +const awaitStage = (url: string, stage: number) => + getJson(`${url}/`).pipe( + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (body) => (body as { stage?: number }).stage === stage, + times: 30, + }), + Effect.map((body) => body as { ok: boolean; stage: number }), + ); + +/** Poll `/received` (bounded) until an echo for `runId` shows up. */ +const pollForEcho = (url: string, runId: string) => + Effect.gen(function* () { + const drained = yield* getJson(`${url}/received`).pipe( + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (body) => + (body as unknown as { received: Echo[] }).received.some( + (echo) => echo.runId === runId, + ), + times: 30, + }), + ); + const echo = (drained as unknown as { received: Echo[] }).received.find( + (e) => e.runId === runId, + ); + expect(echo).toBeDefined(); + return echo!; + }); + +// ─── Out-of-band deployment-file inspection (typed distilled) ─────────────── + +/** Resolve an exact child path from `nodes` (undefined when any segment is missing). */ +const findExactPath = ( + nodes: ReadonlyArray, + path: readonly string[], +): deployments.FileTree | undefined => { + let current = nodes; + let node: deployments.FileTree | undefined; + for (const segment of path) { + node = current.find((n) => n.name === segment); + if (node === undefined) return undefined; + current = node.children ?? []; + } + return node; +}; + +/** Depth-first search for a node reachable by `suffix` from any subtree. */ +const findByPathSuffix = ( + nodes: ReadonlyArray, + suffix: readonly string[], +): deployments.FileTree | undefined => { + const direct = findExactPath(nodes, suffix); + if (direct !== undefined) return direct; + for (const node of nodes) { + if (node.children !== undefined) { + const found = findByPathSuffix(node.children, suffix); + if (found !== undefined) return found; + } + } + return undefined; +}; + +interface ConsumerVcConfig { + readonly experimentalTriggers?: ReadonlyArray<{ + readonly type: string; + readonly topic: string; + readonly consumer: string; + readonly retryAfterSeconds?: number; + readonly initialDelaySeconds?: number; + }>; +} + +const CONSUMER_FUNC = "_alchemy-queue.func"; + +/** Fetch + parse a deployed function's `.vc-config.json` (undefined if the function is absent). */ +const readVcConfig = (deploymentId: string, funcName: string) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + const tree = yield* deployments.listDeploymentFiles({ + id: deploymentId, + teamId, + }); + // Guard: the tree is real (the public function is always present) so an + // "absent consumer" can never be a trivially-empty response. + expect( + findByPathSuffix(tree, ["functions", "index.func", ".vc-config.json"]), + ).toBeDefined(); + const file = findByPathSuffix(tree, [ + "functions", + funcName, + ".vc-config.json", + ]); + if (file?.uid === undefined) return undefined; + const contents = yield* deployments.getDeploymentFileContents({ + id: deploymentId, + fileId: file.uid, + teamId, + }); + return yield* Effect.sync( + () => + JSON.parse( + Buffer.from(contents.data, "base64").toString("utf8"), + ) as ConsumerVcConfig, + ); + }); + +test.provider( + "queue chain: subscribe tuning, schema evolution, consumer removal across 4 reconcile cycles", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + // ── C1: greenfield deploy, schema v1, default subscribe options ────── + const v1 = yield* stack.deploy( + Effect.gen(function* () { + return yield* QueueEvoV1; + }), + ); + expect(v1.projectId).toBeTruthy(); + expect(v1.deploymentId).toBeTruthy(); + expect(v1.url).toBeTruthy(); + + const root1 = yield* awaitStage(v1.url!, 1); + expect(root1).toEqual({ ok: true, stage: 1 }); + + const run1 = yield* Effect.sync(() => crypto.randomUUID()); + const queued1 = (yield* getJson( + `${v1.url}/send?runId=${run1}&orderId=evo-1`, + )) as { queued: boolean }; + expect(queued1.queued).toBe(true); + + const echo1 = yield* pollForEcho(v1.url!, run1); + expect(echo1.orderId).toEqual("evo-1"); + expect(echo1.note).toBeUndefined(); + expect(echo1.topicName).toEqual(Orders.topicName); + expect(echo1.consumerGroup).toEqual("alchemy-QueueEvoFn"); + expect(echo1.deliveryCount).toBeGreaterThanOrEqual(1); + + // Out-of-band: the consumer function carries BOTH triggers, and the + // orders trigger has no retry tuning yet. + const vc1 = yield* readVcConfig(v1.deploymentId!, CONSUMER_FUNC); + expect(vc1).toBeDefined(); + const orders1 = vc1!.experimentalTriggers?.find( + (t) => t.topic === Orders.topicName, + ); + expect(orders1).toBeDefined(); + expect(orders1!.type).toEqual("queue/v2beta"); + expect(orders1!.consumer).toEqual("alchemy-QueueEvoFn"); + expect(orders1!.retryAfterSeconds).toBeUndefined(); + expect( + vc1!.experimentalTriggers?.some( + (t) => t.topic === "alchemy-qevo-echoes", + ), + ).toBe(true); + + // ── C1b: identical redeploy — nothing may change ───────────────────── + const v1b = yield* stack.deploy( + Effect.gen(function* () { + return yield* QueueEvoV1; + }), + ); + expect(v1b.projectId).toEqual(v1.projectId); + expect(v1b.deploymentId).toEqual(v1.deploymentId); + expect(v1b.url).toEqual(v1.url); + + // ── C2: subscribe option change (retryAfterSeconds: 45) ────────────── + const v2 = yield* stack.deploy( + Effect.gen(function* () { + return yield* QueueEvoV2; + }), + ); + // Changed: the deployment (trigger config is part of the artifact). + expect(v2.deploymentId).not.toEqual(v1.deploymentId); + // Stable: project identity and the production alias. + expect(v2.projectId).toEqual(v1.projectId); + expect(v2.url).toEqual(v1.url); + + // Wait for the alias to flip to the new deployment before driving + // queue traffic (sends are deployment-pinned). + yield* awaitStage(v2.url!, 2); + + // Out-of-band: the trigger config update LANDED. + const vc2 = yield* readVcConfig(v2.deploymentId!, CONSUMER_FUNC); + const orders2 = vc2?.experimentalTriggers?.find( + (t) => t.topic === Orders.topicName, + ); + expect(orders2).toBeDefined(); + expect(orders2!.retryAfterSeconds).toEqual(45); + expect(orders2!.consumer).toEqual("alchemy-QueueEvoFn"); + + // Delivery still works on the new deployment partition. + const run2 = yield* Effect.sync(() => crypto.randomUUID()); + const queued2 = (yield* getJson( + `${v2.url}/send?runId=${run2}&orderId=evo-2`, + )) as { queued: boolean }; + expect(queued2.queued).toBe(true); + const echo2 = yield* pollForEcho(v2.url!, run2); + expect(echo2.orderId).toEqual("evo-2"); + + // ── C3: Topic schema evolution (optional `note`) ───────────────────── + const v3 = yield* stack.deploy( + Effect.gen(function* () { + return yield* QueueEvoV3; + }), + ); + expect(v3.deploymentId).not.toEqual(v2.deploymentId); + expect(v3.projectId).toEqual(v1.projectId); + expect(v3.url).toEqual(v1.url); + + yield* awaitStage(v3.url!, 3); + + // Trigger config unchanged by the schema evolution. + const vc3 = yield* readVcConfig(v3.deploymentId!, CONSUMER_FUNC); + const orders3 = vc3?.experimentalTriggers?.find( + (t) => t.topic === Orders.topicName, + ); + expect(orders3?.retryAfterSeconds).toEqual(45); + + // NEW-shape message: the added field round-trips end-to-end. + const run3new = yield* Effect.sync(() => crypto.randomUUID()); + const queued3 = (yield* getJson( + `${v3.url}/send?runId=${run3new}&orderId=evo-3¬e=evolved`, + )) as { queued: boolean }; + expect(queued3.queued).toBe(true); + const echo3 = yield* pollForEcho(v3.url!, run3new); + expect(echo3.orderId).toEqual("evo-3"); + expect(echo3.note).toEqual("evolved"); + + // OLD-shape message: raw v1 JSON (no `note`, no schema encode) still + // decodes through the push consumer after the evolution. + const run3old = yield* Effect.sync(() => crypto.randomUUID()); + const queuedRaw = (yield* getJson( + `${v3.url}/send-raw?runId=${run3old}&orderId=evo-3-old`, + )) as { queued: boolean; raw: boolean }; + expect(queuedRaw).toMatchObject({ queued: true, raw: true }); + const echoOld = yield* pollForEcho(v3.url!, run3old); + expect(echoOld.orderId).toEqual("evo-3-old"); + expect(echoOld.note).toBeUndefined(); + + // ── C4: remove subscribe — consumer function must vanish ───────────── + const v4 = yield* stack.deploy( + Effect.gen(function* () { + return yield* QueueEvoV4; + }), + ); + expect(v4.deploymentId).not.toEqual(v3.deploymentId); + expect(v4.projectId).toEqual(v1.projectId); + expect(v4.url).toEqual(v1.url); + + // Public HTTP keeps serving (and the alias flipped to the new + // deployment). + const root4 = yield* awaitStage(v4.url!, 4); + expect(root4).toEqual({ ok: true, stage: 4 }); + + // Out-of-band: `_alchemy-queue.func` is gone from the deployment + // (readVcConfig asserts index.func is still present, so this is not a + // trivially-empty tree). + const vc4 = yield* readVcConfig(v4.deploymentId!, CONSUMER_FUNC); + expect(vc4).toBeUndefined(); + + // Producing into the now trigger-less topic: DEPTH expects sends to + // keep succeeding (messages just accumulate). The fixture surfaces + // the typed rejection if the platform disagrees. + const run4 = yield* Effect.sync(() => crypto.randomUUID()); + const send4 = (yield* getJson( + `${v4.url}/send?runId=${run4}&orderId=evo-4`, + )) as { queued: boolean; error?: string; message?: string }; + expect(send4.queued).toBe(true); + + // The poll-mode receive path (no trigger involved) also keeps working + // — fresh deployment partition, so there is nothing to drain. + const received4 = (yield* getJson(`${v4.url}/received`)) as { + ok: boolean; + count: number; + }; + expect(received4.ok).toBe(true); + expect(received4.count).toEqual(0); + + // ── Teardown + census ──────────────────────────────────────────────── + yield* stack.destroy(); + yield* expectProjectGone(v1.projectId!); + }).pipe(logLevel), + { timeout: 240_000 }, +); diff --git a/packages/alchemy/test/Vercel/Chains/Smoke.test.ts b/packages/alchemy/test/Vercel/Chains/Smoke.test.ts new file mode 100644 index 0000000000..7bb63cd792 --- /dev/null +++ b/packages/alchemy/test/Vercel/Chains/Smoke.test.ts @@ -0,0 +1,575 @@ +/** + * DEPTH.md chain 7 — the aggregate smoke (§1.1 showcase), live against the + * standing Vercel test team (doppler alchemy-v2/dev env). + * + * ONE stack composing every major surface, driven through SIX reconciliation + * cycles plus the promote/rollback runtime actions: + * + * SmokeFlags (EdgeConfig) ─┐ + * SmokeUploads (BlobStore) ┼─▶ SmokeApi (Effect Function: ReadEdgeConfig + + * SmokeJobs/SmokeEchoes ───┘ ReadWriteBlob + SendMessage + subscribe + + * cron, rotated Redacted release secret) + * │ url / protectionBypass (env channel) + * ▼ + * SmokeSurface (async fn) — /call proxies + * the Api's /version through env-bound URL + * SmokeSite (Website.StaticSite) — independent content deploys + * Pinned (Alias) — stable hostname pinned to the Api's CURRENT deployment + * + * Cycles (each asserts BOTH what changed AND what stayed stable): + * 1. greenfield — every route/binding live end-to-end (flags, blob, queue + * push delivery, cron guard, cross-fn env URL, static content, alias) + * 2. no-op redeploy — every deploymentId/uid/digest/token identical + * 2b. EdgeConfig item flip ONLY — served flag changes while the bound + * Api's deploymentId stays STABLE (data-plane-only propagation) + * 3. multi-prop mutation in ONE deploy — items + Redacted rotation + + * static content: changed resources redeploy, siblings stay stable, + * blob data survives, queue path works on the successor deployment + * 4. rollbackProduction → old release served WITHOUT rebuild (deployment + * count frozen), EdgeConfig reads on the rolled-back deployment serve + * the NEW items (data plane is deployment-independent), and the pinned + * alias is SWEPT to the rollback target (live-verified platform + * semantics — aliases riding the outgoing production deployment move + * with it) → promoteToProduction sweeps everything forward again + * 5. post-action convergence — identical redeploy after the traffic + * actions is a full no-op (the actions never confuse reconcile) + * 6. destroy — census-clean via typed distilled reads (projects, config, + * store, alias all gone; queue messages expire via 300s retention — + * the platform has no topic-delete API) + */ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as aliases from "@distilled.cloud/vercel/aliases"; +import * as deployments from "@distilled.cloud/vercel/deployments"; +import * as globalConfig from "@distilled.cloud/vercel/global_config"; +import * as projects from "@distilled.cloud/vercel/projects"; +import * as storage from "@distilled.cloud/vercel/storage"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Redacted from "effect/Redacted"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import SmokeApi, { type JobEcho, setApiRelease } from "./fixtures/smoke-api.ts"; +import { SmokeUploads } from "./fixtures/smoke-store.ts"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +const surfaceMain = new URL("./fixtures/smoke-surface.ts", import.meta.url) + .pathname; + +// Deterministic stable hostname (global .vercel.app namespace — owned by +// the standing test team after the first run). +const SMOKE_ALIAS = "alchemy-vrc-smoke-e2e.vercel.app"; + +// Fresh .vercel.app URLs take a few seconds to start serving 200s — always +// retry the first request. Bounded: `times` is the hard cap (the schedule +// alone never terminates). +const readiness = Schedule.min([ + Schedule.exponential("500 millis"), + Schedule.spaced("2 seconds"), +]); + +const getJson = (url: string, headers?: Record) => + Effect.gen(function* () { + const response = yield* HttpClient.get( + url, + headers !== undefined ? { headers } : undefined, + ); + const body = yield* response.text; + if (response.status !== 200) { + return yield* Effect.fail( + new Error(`status ${response.status}: ${body.slice(0, 300)}`), + ); + } + // Fresh production aliases can transiently serve an HTML edge + // placeholder — treat an unparseable body as retryable, not a crash. + return yield* Effect.try({ + try: () => JSON.parse(body) as unknown, + catch: () => + new Error(`status 200 but non-JSON body: ${body.slice(0, 300)}`), + }); + }).pipe(Effect.retry({ schedule: readiness, times: 40 })); + +/** Bounded poll of a JSON route until the body matches. */ +const pollJson =
( + url: string, + until: (body: A) => boolean, + options?: { headers?: Record; times?: number }, +) => + getJson(url, options?.headers).pipe( + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (body) => until(body as A), + times: options?.times ?? 20, + }), + Effect.map((body) => body as A), + ); + +const getText = (url: string) => + HttpClient.get(url).pipe( + Effect.flatMap((response) => + response.status === 200 + ? response.text + : Effect.fail(new Error(`status ${response.status}`)), + ), + Effect.retry({ schedule: readiness, times: 40 }), + ); + +const getStatus = (url: string, headers?: Record) => + HttpClient.get(url, headers !== undefined ? { headers } : undefined).pipe( + Effect.map((response) => response.status), + ); + +/** Poll (bounded) until the API serves the given release version. */ +const expectVersion = ( + url: string, + version: string, + headers?: Record, +) => + Effect.gen(function* () { + const body = yield* pollJson<{ version: string | null }>( + `${url}/version`, + (b) => b.version === version, + { headers }, + ); + expect(body.version).toEqual(version); + }); + +/** Poll (bounded) until a project is gone (typed NotFound). */ +const expectProjectGone = (projectId: string) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + const gone = yield* projects + .getProject({ idOrName: projectId, teamId }) + .pipe( + Effect.map(() => false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (g) => g, + times: 10, + }), + ); + expect(gone).toBe(true); + }); + +const htmlPage = (marker: string) => + `\n

${marker}

\n`; + +test.provider( + "aggregate smoke: 5 reconcile cycles + rollback/promote across the whole surface", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + const { teamId } = yield* Vercel.VercelEnvironment.current; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const runId = yield* Effect.sync(() => crypto.randomUUID()); + + // ── Static-site fixture in a temp dir (deterministic content). + const dir = yield* fs.makeTempDirectory({ + prefix: "alchemy-vercel-chain-smoke-", + }); + yield* Effect.addFinalizer(() => + Effect.ignore(fs.remove(dir, { recursive: true })), + ); + yield* fs.makeDirectory(path.join(dir, "src"), { recursive: true }); + yield* fs.writeFileString(path.join(dir, ".gitignore"), "dist\n"); + const buildSh = path.join(dir, "build.sh"); + yield* fs.writeFileString( + buildSh, + "#!/bin/sh\nmkdir -p dist\ncp src/index.html dist/index.html\n", + ); + yield* fs.chmod(buildSh, 0o755); + const writeSiteMarker = (marker: string) => + fs.writeFileString( + path.join(dir, "src", "index.html"), + htmlPage(marker), + ); + yield* writeSiteMarker("smoke-site-v1"); + + // ── The whole-stack program. The EdgeConfig registers FIRST with the + // cycle's declarative items, so the fixture Api's binding resolves + // the same instance (FQN-idempotent registration). + const program = (items: Record) => + Effect.gen(function* () { + const flags = yield* Vercel.EdgeConfig("SmokeFlags", { items }); + const store = yield* SmokeUploads; + const api = yield* SmokeApi; + const surface = yield* Vercel.Function("SmokeSurface", { + main: surfaceMain, + env: { + API_URL: api.url, + API_BYPASS: api.protectionBypass, + }, + }); + const site = yield* Vercel.Website.StaticSite("SmokeSite", { + command: "./build.sh", + cwd: dir, + outdir: "dist", + }); + const alias = yield* Vercel.Alias("Pinned", { + alias: SMOKE_ALIAS, + deployment: api.deploymentId, + }); + return { flags, store, api, surface, site, alias }; + }); + + const itemsV1 = { + greeting: "hello", + enableCheckout: true, + limits: { maxItems: 3 }, + }; + + // ═══ CYCLE 1 — greenfield: everything deploys and every surface is + // live end-to-end. + setApiRelease("v1", "smoke-secret-v1"); + const c1 = yield* stack.deploy(program(itemsV1)); + + expect(c1.flags.edgeConfigId).toMatch(/^ecfg_/); + expect(c1.api.projectId).toBeDefined(); + expect(c1.api.deploymentId).toBeTruthy(); + expect(c1.api.url).toMatch(/^https:\/\/.+\.vercel\.app$/); + expect(c1.api.cronSecret).toBeDefined(); + expect(c1.api.protectionBypass).toBeDefined(); + expect(c1.surface.deploymentId).toBeTruthy(); + expect(c1.site.deploymentId).toBeTruthy(); + expect(c1.alias.uid).toBeDefined(); + expect(c1.alias.deploymentId).toEqual(c1.api.deploymentId); + expect(c1.alias.url).toEqual(`https://${SMOKE_ALIAS}`); + const bypass = { + "x-vercel-protection-bypass": Redacted.value(c1.api.protectionBypass!), + }; + + // 1a. Release route serves v1 (production alias). + yield* expectVersion(c1.api.url!, "v1"); + + // 1b. EdgeConfig binding: declared items served through the deployed + // client (data plane is eventually consistent — bounded poll). + const greeting1 = yield* pollJson<{ value: unknown }>( + `${c1.api.url}/flag/greeting`, + (b) => b.value === "hello", + ); + expect(greeting1.value).toEqual("hello"); + + // 1c. Blob binding: write once here, read back NOW and again after + // the cycle-3 redeploy (data continuity across reconciles). + const blobPath = `chain-smoke/${runId}.txt`; + const put = (yield* getJson( + `${c1.api.url}/blob/put?path=${encodeURIComponent(blobPath)}&body=cycle1-${runId}`, + )) as { etag: string; pathname: string }; + expect(put.etag).toBeTruthy(); + expect(put.pathname).toEqual(blobPath); + const got1 = (yield* getJson( + `${c1.api.url}/blob/get?path=${encodeURIComponent(blobPath)}`, + )) as { text?: string }; + expect(got1.text).toEqual(`cycle1-${runId}`); + // …and the capability binding (not a projects: prop) is what + // connected the store to the Api's project. + expect(c1.store.storeId).toMatch(/^store_/); + const conns = yield* storage.getStorageStoreConnections({ + storeId: c1.store.storeId, + teamId, + }); + expect(conns.connections.map((c) => c.projectId)).toContain( + c1.api.projectId, + ); + + // 1d. Queue: produce through the public route (deployment-pinned + // ambient OIDC), platform push-delivers to the generated consumer + // func, which echoes into SmokeEchoes; /received drains it. + const queued = (yield* getJson( + `${c1.api.url}/send?runId=${runId}&jobId=smoke-c1`, + )) as { queued: boolean }; + expect(queued.queued).toBe(true); + const drained = yield* pollJson<{ received: JobEcho[] }>( + `${c1.api.url}/received`, + (b) => + b.received.some((e) => e.runId === runId && e.jobId === "smoke-c1"), + { times: 40 }, + ); + const echo = drained.received.find((e) => e.jobId === "smoke-c1")!; + expect(echo.deliveryCount).toBeGreaterThanOrEqual(1); + expect(echo.topicName).toEqual("alchemy-smoke-jobs"); + expect(echo.consumerGroup).toEqual("alchemy-SmokeApi"); + + // 1e. Cron: the schedule landed on the deployment (distilled read), + // and the guarded route rejects/accepts correctly. + const deployment1 = yield* deployments.getDeployment({ + idOrUrl: c1.api.deploymentId, + teamId, + }); + const crons = + "crons" in deployment1 && Array.isArray(deployment1.crons) + ? deployment1.crons + : []; + expect(crons).toEqual([ + { schedule: "0 3 * * *", path: "/_alchemy/cron/0" }, + ]); + const cronUrl = `${c1.api.url}/_alchemy/cron/0`; + expect(yield* getStatus(cronUrl)).toBe(401); + expect( + yield* getStatus(cronUrl, { + authorization: `Bearer ${Redacted.value(c1.api.cronSecret!)}`, + }), + ).toBe(200); // 200 ⇒ the handler ran (a failing cron handler answers 500) + + // 1f. Cross-function env channel: the async surface fn reaches the + // Api's CURRENT production release through its env-bound URL. + const call1 = yield* pollJson<{ + status: number; + body: { version: string | null }; + }>(`${c1.surface.url}/call`, (b) => b.status === 200); + expect(call1.body.version).toEqual("v1"); + + // 1g. Static site serves its content. + const site1 = yield* getText(`${c1.site.url}/index.html`).pipe( + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (t) => t.includes("smoke-site-v1"), + times: 20, + }), + ); + expect(site1).toContain("smoke-site-v1"); + + // 1h. The pinned alias serves the Api (SSO-gated deployment alias — + // bypass secret, minted before the first deploy, opens it). + yield* expectVersion(c1.alias.url, "v1", bypass); + + // ═══ CYCLE 2 — no-op redeploy: EVERYTHING stays stable. + const c2 = yield* stack.deploy(program(itemsV1)); + expect(c2.api.deploymentId).toEqual(c1.api.deploymentId); + expect(c2.surface.deploymentId).toEqual(c1.surface.deploymentId); + expect(c2.site.deploymentId).toEqual(c1.site.deploymentId); + expect(c2.flags.edgeConfigId).toEqual(c1.flags.edgeConfigId); + expect(c2.flags.digest).toEqual(c1.flags.digest); + expect(c2.alias.uid).toEqual(c1.alias.uid); + expect(c2.alias.deploymentId).toEqual(c1.api.deploymentId); + + // ═══ CYCLE 2b — EdgeConfig item flip ONLY: the served flag changes + // while the bound Api's deployment is untouched. THE §1.1 story: + // config propagation is data-plane-only — no rebuild, no redeploy. + const c2b = yield* stack.deploy( + program({ ...itemsV1, greeting: "howdy" }), + ); + expect(c2b.api.deploymentId).toEqual(c1.api.deploymentId); // STABLE + expect(c2b.surface.deploymentId).toEqual(c1.surface.deploymentId); + expect(c2b.site.deploymentId).toEqual(c1.site.deploymentId); + expect(c2b.flags.edgeConfigId).toEqual(c1.flags.edgeConfigId); // same config + expect(c2b.flags.digest).not.toEqual(c1.flags.digest); // new content + const greeting2b = yield* pollJson<{ value: unknown }>( + `${c1.api.url}/flag/greeting`, + (b) => b.value === "howdy", + ); + expect(greeting2b.value).toEqual("howdy"); + + // ═══ CYCLE 3 — multi-prop mutation in ONE deploy: EdgeConfig items + // evolve + the Api's Redacted secret rotates (v2 release) + the + // static site's content changes. All converge; siblings stable. + const itemsV2 = { + greeting: "hola", + enableCheckout: false, + rolloutPercent: 25, + }; + setApiRelease("v2", "smoke-secret-v2"); + yield* writeSiteMarker("smoke-site-v2"); + const c3 = yield* stack.deploy(program(itemsV2)); + + // Changed: the Api minted a NEW immutable deployment (rotation)… + expect(c3.api.projectId).toEqual(c1.api.projectId); + expect(c3.api.deploymentId).not.toEqual(c1.api.deploymentId); + // …the site rebuilt and redeployed… + expect(c3.site.projectId).toEqual(c1.site.projectId); + expect(c3.site.deploymentId).not.toEqual(c1.site.deploymentId); + // …the config content moved again… + expect(c3.flags.digest).not.toEqual(c2b.flags.digest); + // …and the pinned alias re-pointed to the new deployment IN PLACE. + expect(c3.alias.uid).toEqual(c1.alias.uid); + expect(c3.alias.deploymentId).toEqual(c3.api.deploymentId); + // Stable: the surface fn (its env — the Api's URL + bypass — did not + // change), the config identity, the Api's URL itself. + expect(c3.surface.deploymentId).toEqual(c1.surface.deploymentId); + expect(c3.flags.edgeConfigId).toEqual(c1.flags.edgeConfigId); + expect(c3.api.url).toEqual(c1.api.url); + + // The new release + rotated secret are served… + const v2body = yield* pollJson<{ + version: string | null; + secretTail: string | null; + }>(`${c1.api.url}/version`, (b) => b.version === "v2"); + expect(v2body.secretTail).toEqual("v2"); + // …the evolved items too… + const greeting3 = yield* pollJson<{ value: unknown }>( + `${c1.api.url}/flag/greeting`, + (b) => b.value === "hola", + ); + expect(greeting3.value).toEqual("hola"); + // …cycle-1 blob data SURVIVED the redeploy (same store, same token)… + const got3 = (yield* getJson( + `${c1.api.url}/blob/get?path=${encodeURIComponent(blobPath)}`, + )) as { text?: string }; + expect(got3.text).toEqual(`cycle1-${runId}`); + // …the queue path works on the successor deployment (sends pin to the + // NEW deployment partition; its consumer trigger receives them)… + yield* getJson(`${c1.api.url}/send?runId=${runId}&jobId=smoke-c3`); + const drained3 = yield* pollJson<{ received: JobEcho[] }>( + `${c1.api.url}/received`, + (b) => + b.received.some((e) => e.runId === runId && e.jobId === "smoke-c3"), + { times: 40 }, + ); + expect(drained3.received.some((e) => e.jobId === "smoke-c3")).toBe(true); + // …the surface fn now reports v2 through the SAME deployment of its + // own (env-bound URL tracks production)… + yield* pollJson<{ status: number; body: { version: string | null } }>( + `${c1.surface.url}/call`, + (b) => b.status === 200 && b.body.version === "v2", + ); + // …and the site serves the new marker. + const site3 = yield* getText(`${c1.site.url}/index.html`).pipe( + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (t) => t.includes("smoke-site-v2"), + times: 20, + }), + ); + expect(site3).toContain("smoke-site-v2"); + + // ═══ CYCLE 4 — rollback/promote runtime actions (pure traffic ops). + const countBefore = (yield* deployments.getDeployments({ + teamId, + projectId: c1.api.projectId, + limit: 20, + })).deployments.length; + + // Roll production back to the v1 release: served WITHOUT rebuild. + yield* Vercel.rollbackProduction(c3.api.projectId, c1.api.deploymentId, { + description: "chain-smoke rollback", + }); + yield* expectVersion(c1.api.url!, "v1"); + // The rolled-back deployment carries the OLD secret snapshot (env is + // per-deployment)… + const rolled = (yield* getJson(`${c1.api.url}/version`)) as { + secretTail: string | null; + }; + expect(rolled.secretTail).toEqual("v1"); + // …but EdgeConfig reads on it serve the NEW items — the data plane is + // deployment-independent (flag flips survive rollbacks instantly). + const rolledFlag = (yield* getJson(`${c1.api.url}/flag/greeting`)) as { + value: unknown; + }; + expect(rolledFlag.value).toEqual("hola"); + // The pinned alias is SWEPT by instant rollback (live-verified, + // .probes/chain7-rollback-alias-sweep.ts): rollback re-points EVERY + // alias riding the outgoing production deployment to the rollback + // target, so an alias pinned to the ACTIVE production deployment + // tracks production through rollback/promote rather than staying put. + const sweptTo = yield* aliases + .getAlias({ idOrAlias: SMOKE_ALIAS, teamId }) + .pipe( + Effect.map((a) => a.deploymentId ?? undefined), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (d) => d === c1.api.deploymentId, + times: 10, + }), + ); + expect(sweptTo).toEqual(c1.api.deploymentId); + yield* expectVersion(c1.alias.url, "v1", bypass); + // No rebuild happened: the project's deployment count is frozen. + const countAfter = (yield* deployments.getDeployments({ + teamId, + projectId: c1.api.projectId, + limit: 20, + })).deployments.length; + expect(countAfter).toEqual(countBefore); + + // Promote forward again — the sweep works in both directions: the + // pinned alias rides back to the promoted deployment. + yield* Vercel.promoteToProduction(c3.api.projectId, c3.api.deploymentId); + yield* expectVersion(c1.api.url!, "v2"); + const sweptBack = yield* aliases + .getAlias({ idOrAlias: SMOKE_ALIAS, teamId }) + .pipe( + Effect.map((a) => a.deploymentId ?? undefined), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (d) => d === c3.api.deploymentId, + times: 10, + }), + ); + expect(sweptBack).toEqual(c3.api.deploymentId); + yield* expectVersion(c1.alias.url, "v2", bypass); + // The surface fn tracked production through both flips (same env). + yield* pollJson<{ status: number; body: { version: string | null } }>( + `${c1.surface.url}/call`, + (b) => b.status === 200 && b.body.version === "v2", + ); + + // ═══ CYCLE 5 — post-action convergence: an identical redeploy after + // the out-of-band traffic actions is a full no-op. + const c5 = yield* stack.deploy(program(itemsV2)); + expect(c5.api.deploymentId).toEqual(c3.api.deploymentId); + expect(c5.surface.deploymentId).toEqual(c1.surface.deploymentId); + expect(c5.site.deploymentId).toEqual(c3.site.deploymentId); + expect(c5.flags.digest).toEqual(c3.flags.digest); + expect(c5.alias.uid).toEqual(c1.alias.uid); + expect(c5.alias.deploymentId).toEqual(c3.api.deploymentId); + + // ═══ CYCLE 6 — destroy: census-clean via typed distilled reads. + yield* stack.destroy(); + + yield* expectProjectGone(c1.api.projectId); + yield* expectProjectGone(c1.surface.projectId); + yield* expectProjectGone(c1.site.projectId); + const configGone = yield* globalConfig + .getEdgeConfig({ edgeConfigId: c1.flags.edgeConfigId, teamId }) + .pipe( + Effect.as(false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + Effect.repeat({ + schedule: Schedule.spaced("1 second"), + until: (g) => g, + times: 8, + }), + ); + expect(configGone).toBe(true); + const storeGone = yield* storage + .getStorageStoresById({ id: c1.store.storeId, teamId }) + .pipe( + Effect.as(false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + Effect.repeat({ + schedule: Schedule.spaced("1 second"), + until: (g) => g, + times: 8, + }), + ); + expect(storeGone).toBe(true); + const aliasGone = yield* aliases + .getAlias({ idOrAlias: SMOKE_ALIAS, teamId }) + .pipe( + Effect.as(false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + Effect.repeat({ + schedule: Schedule.spaced("1 second"), + until: (g) => g, + times: 8, + }), + ); + expect(aliasGone).toBe(true); + // Queue topics: the platform has no topic-delete API — messages on + // both topics expire on their own (retentionSeconds: 300). + }).pipe(logLevel), + // Six reconcile cycles + platform queue push delivery + rollback/promote + // + census in ONE sequential chain (measured ~80s green; 3x headroom). + { timeout: 240_000 }, +); diff --git a/packages/alchemy/test/Vercel/Chains/StateStoreCycles.test.ts b/packages/alchemy/test/Vercel/Chains/StateStoreCycles.test.ts new file mode 100644 index 0000000000..b580351c3c --- /dev/null +++ b/packages/alchemy/test/Vercel/Chains/StateStoreCycles.test.ts @@ -0,0 +1,388 @@ +/** + * DEPTH chain 8 — self-hosted state cycles: a real resource chain + * (EdgeConfig + EdgeConfigToken + Function binding it via connection + * string) whose stack state lives in the self-hosted Vercel state store, + * driven through multiple reconciliation cycles while the state rows are + * observed OUT-OF-BAND over the store's raw HTTP API. + * + * Cycle map: + * bootstrap → store deployed, /version pinned over raw HTTP + * cycle 1 (deploy) → rows for every chain resource appear; stack output + * row matches; the fn serves the bound item + * cycle 2 (no-op) → deploymentId / edgeConfigId / tokenId stable AND + * the persisted rows are byte-identical + * cycle 3 (mutate) → EdgeConfig items change: ONLY the EdgeConfig row's + * content changes (digest moves), fn + token rows and + * deploymentId stay stable (data-plane-only update) + * destroy → rows vanish from the store; cloud resources gone + * recovery → credentials file deleted; loginWithVercel re-derives + * the bearer from the encrypted project env + * teardown → state project + blob store gone (census clean) + * + * NOTE ON ISOLATION: the state-store project name is a process-global + * constant (`STATE_STORE_PROJECT_NAME`, override via + * `ALCHEMY_VERCEL_STATE_PROJECT` before process start), so this suite and + * `test/Vercel/StateStore/State.test.ts` necessarily target the same + * physical store name when run in one process. This test takes the + * whole-process write lock (`exclusive: true`) so it can never interleave + * with that suite (both own bootstrap/teardown of the store). Credentials + * are isolated under a dedicated profile either way. + * + * The lock does NOT protect against a SECOND runner process executing + * this file (or State.test.ts) concurrently — the store is a + * team-singleton, and two owners sabotage each other (one side's + * teardown/bootstrap makes the other's rows invisible mid-deploy; its + * greenfield re-create then leaks a full second generation — observed + * live, see the 2026-08-14 blob-consistency rows in + * processes/Vercel/PROBES.md). Never run two processes over this suite + * at once. + */ +import { CredentialsStore } from "@/Auth/Credentials"; +import { deploy } from "@/Deploy"; +import { destroy } from "@/Destroy"; +import * as Alchemy from "@/index.ts"; +import { StateApi } from "@/State/HttpStateApi"; +import { State } from "@/State/State"; +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import { + STATE_STORE_PROJECT_NAME, + STATE_STORE_VERSION, +} from "@/Vercel/StateStore/Api"; +import { CREDENTIALS_FILE } from "@/Vercel/StateStore/CredentialsFile"; +import { + bootstrap, + loginWithVercel, + teardownStateStore, +} from "@/Vercel/StateStore/State"; +import * as globalConfig from "@distilled.cloud/vercel/global_config"; +import { getProject } from "@distilled.cloud/vercel/projects"; +import { getStorageStores } from "@distilled.cloud/vercel/storage"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +/** Dedicated credentials profile — never touches another suite's cache. */ +const PROFILE = "vercel-state-cycles"; + +const STACK = "VercelStateCycles"; +const STAGE = "cycles"; + +const ITEMS_V1 = { greeting: "cycles-v1", flag: true }; +const ITEMS_V2 = { greeting: "cycles-v2", flag: true, extra: [1, 2] }; + +const fixtureMain = new URL("./fixtures/state-cycles-fn.ts", import.meta.url) + .pathname; + +/** Raw HTTP API client against the deployed store (out-of-band checks). */ +const rawClient = (credentials: { url: string; authToken: string }) => + HttpApiClient.make(StateApi, { + baseUrl: credentials.url, + transformClient: HttpClient.mapRequest((req) => + HttpClientRequest.bearerToken(req, credentials.authToken), + ), + }); + +/** Fetch one JSON body with first-request readiness retries (bounded). */ +const getJsonUntil =
(url: string, until: (body: A) => boolean) => + HttpClient.get(url).pipe( + Effect.flatMap((response) => + response.status === 200 + ? response.json + : Effect.fail(new Error(`status ${response.status}`)), + ), + Effect.retry({ + schedule: Schedule.exponential("500 millis"), + times: 10, + }), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (body) => until(body as A), + times: 15, + }), + Effect.map((body) => body as A), + ); + +/** Poll (bounded, typed) until a project is gone. */ +const expectProjectGone = (idOrName: string) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + const gone = yield* getProject({ idOrName, teamId }).pipe( + Effect.map(() => false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (g) => g, + times: 10, + }), + ); + expect(gone).toBe(true); + }); + +/** Poll (bounded, typed) until the Edge Config is gone. */ +const expectEdgeConfigGone = (edgeConfigId: string) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + const gone = yield* globalConfig + .getEdgeConfig({ edgeConfigId, teamId }) + .pipe( + Effect.as(false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + Effect.repeat({ + schedule: Schedule.spaced("1 second"), + until: (g) => g, + times: 8, + }), + ); + expect(gone).toBe(true); + }); + +/** The state blob store must be gone from the team listing. */ +const expectStoreGone = Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + const gone = yield* getStorageStores({ teamId }).pipe( + Effect.map( + ({ stores }) => + !stores.some( + (row) => row.type === "blob" && row.name === STATE_STORE_PROJECT_NAME, + ), + ), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (g) => g, + times: 10, + }), + ); + expect(gone).toBe(true); +}); + +/** Persisted state-row shape (only the fields the chain asserts on). */ +interface StateRow { + readonly resourceType?: string; + readonly status?: string; + readonly props?: { items?: Record }; + readonly attr?: Record; +} + +test.provider( + "state cycles: bootstrap → deploy chain → no-op → mutate → destroy → recovery → teardown", + () => { + // Failure-path cleanup: a mid-body failure must still destroy the + // chain (which needs the store alive) and then tear the store down — + // the harness's scratch-stack auto-teardown does not cover stacks + // deployed with a custom state layer. Set once the store exists; + // `finished` skips the duplicate work on the happy path. + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- the + // destroy effect's leftover requirements are satisfied by the test + // harness context the ensuring runs in. + let destroyChain: Effect.Effect | undefined; + let finished = false; + return Effect.gen(function* () { + // Clean slate: reclaim whatever a previously crashed run left behind + // (store project, blob store, cached credentials, local stack). + yield* teardownStateStore({ profile: PROFILE }).pipe(Effect.ignore); + + // ── Bootstrap the self-hosted store. + const store = yield* bootstrap({ profile: PROFILE }); + + // /version honored — both through the StateService surface and the + // raw unauthenticated HTTP route. + expect(yield* store.getVersion()).toBe(STATE_STORE_VERSION); + const credentials = yield* loginWithVercel(PROFILE, false); + const client = yield* rawClient(credentials); + const version = yield* client.version.getVersion(); + expect(version.version).toBe(STATE_STORE_VERSION); + + const stateLayer = Layer.succeed(State, Effect.succeed(store)); + + /** The chain under test — items parametrized per cycle. */ + const makeStack = (items: Record) => + Alchemy.Stack( + STACK, + { providers: Vercel.providers(), state: stateLayer }, + Effect.gen(function* () { + const flags = yield* Vercel.EdgeConfig("CyclesFlags", { items }); + const token = yield* Vercel.EdgeConfigToken("CyclesToken", { + edgeConfigId: flags.edgeConfigId, + }); + const fn = yield* Vercel.Function("CyclesFn", { + main: fixtureMain, + env: { + // binding-as-env-value form: Redacted attribute Output ⇒ + // synced as a sensitive env var on the function's project. + FLAGS: token.connectionString, + }, + }); + return { + edgeConfigId: flags.edgeConfigId, + digest: flags.digest, + tokenId: token.tokenId, + projectId: fn.projectId, + deploymentId: fn.deploymentId, + url: fn.url, + }; + }), + ); + + destroyChain = destroy({ + stack: makeStack(ITEMS_V2), + stage: STAGE, + }).pipe(Effect.provide(stateLayer), Effect.ignore); + + const readRow = (fqn: string) => + client.state + .getState({ + params: { + stack: STACK, + stage: STAGE, + fqn: encodeURIComponent(fqn), + }, + }) + .pipe(Effect.map((row) => row as StateRow | undefined)); + + const listRows = client.state + .listResources({ params: { stack: STACK, stage: STAGE } }) + .pipe(Effect.map((rows) => [...rows])); + + // ── Cycle 1: greenfield deploy of the chain, state on Vercel. + const c1 = yield* deploy({ + stack: makeStack(ITEMS_V1), + stage: STAGE, + }).pipe(Effect.provide(stateLayer)); + expect(c1.edgeConfigId).toMatch(/^ecfg_/); + expect(c1.deploymentId).toBeDefined(); + expect(c1.url).toBeDefined(); + + // Rows for every chain resource exist in the store, out-of-band. + const fqns1 = yield* listRows; + const fqnOf = (logicalId: string) => { + const fqn = fqns1.find((row) => row.includes(logicalId)); + expect(fqn).toBeDefined(); + return fqn!; + }; + const flagsFqn = fqnOf("CyclesFlags"); + const tokenFqn = fqnOf("CyclesToken"); + const fnFqn = fqnOf("CyclesFn"); + + // The stack-output row round-trips the deploy outputs. + const out1 = (yield* client.state.getStackOutput({ + params: { stack: STACK, stage: STAGE }, + })) as typeof c1 | undefined; + expect(out1?.edgeConfigId).toBe(c1.edgeConfigId); + expect(out1?.deploymentId).toBe(c1.deploymentId); + + // Row content: the EdgeConfig row persists the V1 desired items. + const flagsRow1 = yield* readRow(flagsFqn); + expect(flagsRow1?.status).toBe("created"); + expect(flagsRow1?.props?.items).toEqual(ITEMS_V1); + const fnRow1 = yield* readRow(fnFqn); + expect(fnRow1).toBeDefined(); + const tokenRow1 = yield* readRow(tokenFqn); + expect(tokenRow1).toBeDefined(); + + // The chain actually works: the fn serves the bound item. + const served1 = yield* getJsonUntil<{ value: unknown }>( + `${c1.url}/item/greeting`, + (body) => body.value === ITEMS_V1.greeting, + ); + expect(served1.value).toBe(ITEMS_V1.greeting); + + // ── Cycle 2: identical redeploy — a no-op end to end. + const c2 = yield* deploy({ + stack: makeStack(ITEMS_V1), + stage: STAGE, + }).pipe(Effect.provide(stateLayer)); + // Stable: every physical identity survives untouched. + expect(c2.edgeConfigId).toBe(c1.edgeConfigId); + expect(c2.tokenId).toBe(c1.tokenId); + expect(c2.projectId).toBe(c1.projectId); + expect(c2.deploymentId).toBe(c1.deploymentId); + expect(c2.digest).toBe(c1.digest); + // Stable: the persisted rows did not change at all. + expect(yield* readRow(flagsFqn)).toEqual(flagsRow1); + expect(yield* readRow(fnFqn)).toEqual(fnRow1); + expect(yield* readRow(tokenFqn)).toEqual(tokenRow1); + expect((yield* listRows).sort()).toEqual([...fqns1].sort()); + + // ── Cycle 3: mutate the EdgeConfig items (data-plane-only change). + const c3 = yield* deploy({ + stack: makeStack(ITEMS_V2), + stage: STAGE, + }).pipe(Effect.provide(stateLayer)); + // Stable: nothing about the function or token was touched. + expect(c3.edgeConfigId).toBe(c1.edgeConfigId); + expect(c3.tokenId).toBe(c1.tokenId); + expect(c3.projectId).toBe(c1.projectId); + expect(c3.deploymentId).toBe(c1.deploymentId); + // Changed: the config content moved (digest is content-addressed). + expect(c3.digest).not.toBe(c1.digest); + // Changed: ONLY the EdgeConfig row's content changed in the store. + const flagsRow3 = yield* readRow(flagsFqn); + expect(flagsRow3?.props?.items).toEqual(ITEMS_V2); + expect(flagsRow3).not.toEqual(flagsRow1); + // Stable: sibling rows byte-identical. + expect(yield* readRow(fnFqn)).toEqual(fnRow1); + expect(yield* readRow(tokenFqn)).toEqual(tokenRow1); + // The mutation propagated to the served data plane. + const served3 = yield* getJsonUntil<{ value: unknown }>( + `${c1.url}/item/greeting`, + (body) => body.value === ITEMS_V2.greeting, + ); + expect(served3.value).toBe(ITEMS_V2.greeting); + + // ── Destroy: rows vanish from the store; cloud resources gone. + yield* destroy({ + stack: makeStack(ITEMS_V2), + stage: STAGE, + }).pipe(Effect.provide(stateLayer)); + expect(yield* listRows).toEqual([]); + expect(yield* readRow(flagsFqn)).toBeUndefined(); + yield* expectEdgeConfigGone(c1.edgeConfigId); + yield* expectProjectGone(c1.projectId); + + // ── Credential-loss recovery: drop the cached credentials file and + // re-derive the bearer token out-of-band from the state project's + // `encrypted` env row. + const credStore = yield* CredentialsStore; + yield* credStore.delete(PROFILE, CREDENTIALS_FILE); + const recovered = yield* loginWithVercel(PROFILE, true); + expect(recovered.url).toBe(credentials.url); + expect(recovered.authToken).toBe(credentials.authToken); + const recoveredClient = yield* rawClient(recovered); + const stacks = yield* recoveredClient.state.listStacks(); + expect([...stacks]).toContain("VercelStateStore"); + + // ── Teardown: state project + blob store gone — census clean. + yield* teardownStateStore({ profile: PROFILE }); + yield* expectProjectGone(STATE_STORE_PROJECT_NAME); + yield* expectStoreGone; + finished = true; + }).pipe( + Effect.ensuring( + Effect.suspend(() => + finished + ? Effect.void + : (destroyChain ?? Effect.void).pipe( + Effect.andThen( + teardownStateStore({ profile: PROFILE }).pipe(Effect.ignore), + ), + ), + ), + ), + logLevel, + ); + }, + { timeout: 420_000, exclusive: true }, +); diff --git a/packages/alchemy/test/Vercel/Chains/StorageCompute.test.ts b/packages/alchemy/test/Vercel/Chains/StorageCompute.test.ts new file mode 100644 index 0000000000..3f4d23b6c9 --- /dev/null +++ b/packages/alchemy/test/Vercel/Chains/StorageCompute.test.ts @@ -0,0 +1,431 @@ +/** + * DEPTH chain 1 — storage-compute propagation (live, doppler alchemy-v2/dev). + * + * One Effect-mode Function ("ChainFn") binds BOTH an Edge Config + * (`ReadEdgeConfig`) and a blob store (`ReadWriteBlob`). The chain drives + * five full reconciliation cycles over the same stack and asserts, per + * cycle, what CHANGED and what STAYED STABLE: + * + * 1. greenfield deploy — connection + token + both data paths live + * 2. Edge Config item update — data-plane only: served values change, + * deploymentId / token / connection stable + * 3. Redacted secret rotation — Function redeploys, binding artifacts + * (token id, token value, connection id, + * BLOB_READ_WRITE_TOKEN row) stay stable + * 4. BlobStore REPLACEMENT — access flip public→private: successor + * store, connection re-established, token + * env re-injected, Function redeployed, + * data path works on the successor, old + * store gone + * 5. blob binding REMOVAL — store stays deployed but disconnects: + * BLOB_READ_WRITE_TOKEN + store captures + * removed from project env, Edge Config + * path keeps serving + * 6. destroy — census-clean (project, store, config all + * verified gone out-of-band) + * + * Cycle-varying props ride on FQN-idempotent registration: each deploy + * program registers "ChainFlags"/"ChainStore" with the cycle's props FIRST, + * so the fixture's internal yields resolve to the cycle's declaration. + * The Function generations are three fixture modules sharing one logical id. + */ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as globalConfig from "@distilled.cloud/vercel/global_config"; +import { + filterProjectEnvs, + getProject, +} from "@distilled.cloud/vercel/projects"; +import { + getStorageStoreConnections, + getStorageStoresById, +} from "@distilled.cloud/vercel/storage"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import ChainFn from "./fixtures/chain-fn.ts"; +import ChainFnNoBlob from "./fixtures/chain-fn-noblob.ts"; +import ChainFnRotated from "./fixtures/chain-fn-rotated.ts"; +import { CHAIN_ITEMS_V1, CHAIN_ITEMS_V2 } from "./fixtures/chain-flags.ts"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +// Fresh .vercel.app URLs take a few seconds to start serving 200s — always +// retry the first request (bounded). +const readiness = Schedule.max([ + Schedule.exponential("500 millis"), + Schedule.recurs(20), +]); + +const getJson = (url: string) => + HttpClient.get(url).pipe( + Effect.flatMap((response) => + response.status === 200 + ? response.json + : Effect.fail(new Error(`status ${response.status}`)), + ), + Effect.retry({ schedule: readiness }), + ); + +/** + * Data-plane reads (Edge Config propagation, alias flips after redeploys) + * are eventually consistent — poll a JSON route (bounded) until the body + * matches. + */ +const getJsonUntil = (url: string, until: (body: A) => boolean) => + getJson(url).pipe( + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (body) => until(body as A), + times: 20, + }), + Effect.map((body) => body as A), + ); + +/** Poll (bounded) until the function's project is gone. */ +const expectProjectGone = (projectId: string) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + const gone = yield* getProject({ idOrName: projectId, teamId }).pipe( + Effect.map(() => false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (g) => g, + times: 10, + }), + ); + expect(gone).toBe(true); + }); + +/** Poll (bounded) until the blob store is gone. */ +const expectStoreGone = (storeId: string) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + const gone = yield* getStorageStoresById({ id: storeId, teamId }).pipe( + Effect.map(() => false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (g) => g, + times: 10, + }), + ); + expect(gone).toBe(true); + }); + +/** Poll (bounded) until the Edge Config is gone (cascades its tokens). */ +const expectEdgeConfigGone = (edgeConfigId: string) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + const gone = yield* globalConfig + .getEdgeConfig({ edgeConfigId, teamId }) + .pipe( + Effect.as(false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + Effect.repeat({ + schedule: Schedule.spaced("1 second"), + until: (g) => g, + times: 8, + }), + ); + expect(gone).toBe(true); + }); + +const envRows = (projectId: string) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + const envs = yield* filterProjectEnvs({ idOrName: projectId, teamId }); + return ( + Array.isArray(envs) + ? envs + : typeof envs === "object" && envs !== null && "envs" in envs + ? (envs as { envs: unknown[] }).envs + : [] + ) as Array<{ key: string; type: string }>; + }); + +/** The store's connections + the single Edge Config read token, out-of-band. */ +const observeBindings = (storeId: string, edgeConfigId: string) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + const { connections } = yield* getStorageStoreConnections({ + storeId, + teamId, + }); + const tokens = yield* globalConfig.getEdgeConfigTokens({ + edgeConfigId, + teamId, + }); + return { connections, tokens }; + }); + +/** Per-cycle deploy program: cycle props first, then the fixture generation. */ +// Generic over the fixture's own Effect type — declaring a widened +// `Effect` parameter erases the class's trimorphic type and breaks +// the deploy result's attribute typing. +const cycle = >(opts: { + items: Record; + access: "public" | "private"; + fn: F; +}) => + Effect.gen(function* () { + // First-registration-wins: these declarations shadow the fixture-module + // defaults for this deploy. + const flags = yield* Vercel.EdgeConfig("ChainFlags", { + items: { ...opts.items }, + }); + const store = yield* Vercel.BlobStore("ChainStore", { + access: opts.access, + // The chain deliberately replaces + destroys the store while it still + // holds blobs — opt into the purge-on-delete path. + forceDestroy: true, + }); + const fn = yield* opts.fn; + return { flags, store, fn }; + }); + +test.provider( + "storage-compute propagation: item update / secret rotation / store replacement / unbind / destroy", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + // ── Cycle 1: greenfield ───────────────────────────────────────────── + const c1 = yield* stack.deploy( + cycle({ items: CHAIN_ITEMS_V1, access: "public", fn: ChainFn }), + ); + expect(c1.fn.url).toBeDefined(); + expect(c1.flags.edgeConfigId).toMatch(/^ecfg_/); + expect(c1.store.access).toEqual("public"); + // The ReadWriteBlob binding alone connected the Function's project. + expect(c1.store.projectIds).toContain(c1.fn.projectId); + + const b1 = yield* observeBindings( + c1.store.storeId, + c1.flags.edgeConfigId, + ); + expect(b1.connections.map((c) => c.projectId)).toContain(c1.fn.projectId); + expect(b1.tokens.length).toEqual(1); + const connectionId1 = b1.connections[0]!.id; + const tokenId1 = b1.tokens[0]!.id; + + // Both platform-injected and capability-captured env rows landed. + const rows1 = yield* envRows(c1.fn.projectId); + expect( + rows1.find((r) => r.key === "BLOB_READ_WRITE_TOKEN"), + ).toBeDefined(); + const secretRow1 = rows1.find((r) => r.key === "CHAIN_SECRET"); + expect(secretRow1).toBeDefined(); + expect(secretRow1!.type).toEqual("sensitive"); + + // Both data paths work through the deployed Function. + const flag1 = yield* getJsonUntil<{ value: unknown }>( + `${c1.fn.url}/flag/greeting`, + (body) => body.value === CHAIN_ITEMS_V1.greeting, + ); + expect(flag1.value).toEqual("chain-v1"); + const secret1 = (yield* getJson(`${c1.fn.url}/secret`)) as { + secret: string | null; + }; + expect(secret1.secret).toEqual("chain-secret-v1"); + const blobPath = "chain/data.txt"; + const put1 = (yield* getJson( + `${c1.fn.url}/blob/put?path=${encodeURIComponent(blobPath)}&body=cycle1-data`, + )) as { etag: string; url: string }; + expect(put1.url).toContain(".public.blob.vercel-storage.com"); + const got1 = (yield* getJson( + `${c1.fn.url}/blob/get?path=${encodeURIComponent(blobPath)}`, + )) as { text: string }; + expect(got1.text).toEqual("cycle1-data"); + + // ── Cycle 2: Edge Config item update (data-plane only) ───────────── + const c2 = yield* stack.deploy( + cycle({ items: CHAIN_ITEMS_V2, access: "public", fn: ChainFn }), + ); + // STABLE: physical ids and the deployment (no compute churn from an + // item write). + expect(c2.flags.edgeConfigId).toEqual(c1.flags.edgeConfigId); + expect(c2.store.storeId).toEqual(c1.store.storeId); + expect(c2.fn.projectId).toEqual(c1.fn.projectId); + expect(c2.fn.deploymentId).toEqual(c1.fn.deploymentId); + // CHANGED: the served values + digest. + expect(c2.flags.digest).not.toEqual(c1.flags.digest); + const flag2 = yield* getJsonUntil<{ value: unknown }>( + `${c2.fn.url}/flag/greeting`, + (body) => body.value === CHAIN_ITEMS_V2.greeting, + ); + expect(flag2.value).toEqual("chain-v2"); + const removed = yield* getJsonUntil<{ value: unknown }>( + `${c2.fn.url}/flag/rollout`, + (body) => body.value === null, + ); + expect(removed.value).toBeNull(); + const digest2 = yield* getJsonUntil<{ digest: string }>( + `${c2.fn.url}/digest`, + (body) => body.digest === c2.flags.digest, + ); + expect(digest2.digest).toEqual(c2.flags.digest); + // STABLE: token + connection untouched. + const b2 = yield* observeBindings( + c2.store.storeId, + c2.flags.edgeConfigId, + ); + expect(b2.tokens.length).toEqual(1); + expect(b2.tokens[0]!.id).toEqual(tokenId1); + expect(b2.connections.map((c) => c.id)).toEqual([connectionId1]); + + // ── Cycle 3: Redacted secret rotation ────────────────────────────── + const c3 = yield* stack.deploy( + cycle({ items: CHAIN_ITEMS_V2, access: "public", fn: ChainFnRotated }), + ); + // CHANGED: a fresh immutable deployment (env only takes effect on new + // deployments) serving the rotated secret. + expect(c3.fn.projectId).toEqual(c1.fn.projectId); + expect(c3.fn.deploymentId).not.toEqual(c2.fn.deploymentId); + const secret3 = yield* getJsonUntil<{ secret: string | null }>( + `${c3.fn.url}/secret`, + (body) => body.secret === "chain-secret-v2", + ); + expect(secret3.secret).toEqual("chain-secret-v2"); + // STABLE: every binding artifact — the Edge Config token was + // re-observed (never re-minted), the store connection kept its id, + // the platform token row survived, and both data paths still work. + const b3 = yield* observeBindings( + c3.store.storeId, + c3.flags.edgeConfigId, + ); + expect(b3.tokens.length).toEqual(1); + expect(b3.tokens[0]!.id).toEqual(tokenId1); + expect(b3.connections.map((c) => c.id)).toEqual([connectionId1]); + const rows3 = yield* envRows(c3.fn.projectId); + expect( + rows3.find((r) => r.key === "BLOB_READ_WRITE_TOKEN"), + ).toBeDefined(); + const got3 = (yield* getJson( + `${c3.fn.url}/blob/get?path=${encodeURIComponent(blobPath)}`, + )) as { text: string }; + expect(got3.text).toEqual("cycle1-data"); + const flag3 = (yield* getJson(`${c3.fn.url}/flag/greeting`)) as { + value: unknown; + }; + expect(flag3.value).toEqual("chain-v2"); + + // ── Cycle 4: BlobStore REPLACEMENT (access flip public→private) ──── + const c4 = yield* stack.deploy( + cycle({ + items: CHAIN_ITEMS_V2, + access: "private", + fn: ChainFnRotated, + }), + ); + // CHANGED: successor store, re-established connection, redeployed fn. + expect(c4.store.storeId).not.toEqual(c3.store.storeId); + expect(c4.store.access).toEqual("private"); + expect(c4.store.projectIds).toContain(c4.fn.projectId); + expect(c4.fn.projectId).toEqual(c1.fn.projectId); + expect(c4.fn.deploymentId).not.toEqual(c3.fn.deploymentId); + // STABLE: the Edge Config side is untouched by the store replacement. + expect(c4.flags.edgeConfigId).toEqual(c1.flags.edgeConfigId); + const b4 = yield* observeBindings( + c4.store.storeId, + c4.flags.edgeConfigId, + ); + expect(b4.tokens.length).toEqual(1); + expect(b4.tokens[0]!.id).toEqual(tokenId1); + // The successor's connection re-injected the token env. + expect(b4.connections.map((c) => c.projectId)).toContain(c4.fn.projectId); + expect(b4.connections[0]!.id).not.toEqual(connectionId1); + const rows4 = yield* envRows(c4.fn.projectId); + expect( + rows4.find((r) => r.key === "BLOB_READ_WRITE_TOKEN"), + ).toBeDefined(); + // The OLD store is gone (create-first replacement deletes it last — + // or delete-first per the provider's diff; either way it must be gone + // once the deploy converged). + yield* expectStoreGone(c3.store.storeId); + // Data path works on the successor: old content is gone with the old + // store, fresh writes land on the private store. + const gone4 = yield* getJsonUntil<{ notFound?: boolean }>( + `${c4.fn.url}/blob/get?path=${encodeURIComponent(blobPath)}`, + (body) => body.notFound === true, + ); + expect(gone4.notFound).toBe(true); + const put4 = (yield* getJson( + `${c4.fn.url}/blob/put?path=${encodeURIComponent(blobPath)}&body=cycle4-data`, + )) as { etag: string; url: string }; + expect(put4.url).toContain(".private.blob.vercel-storage.com"); + // The notFound polling above can prime a cached 404 on this pathname + // (blob content GETs are eventually consistent for recreated + // pathnames) — poll until the fresh write is visible. + const got4 = yield* getJsonUntil<{ text?: string }>( + `${c4.fn.url}/blob/get?path=${encodeURIComponent(blobPath)}`, + (body) => body.text === "cycle4-data", + ); + expect(got4.text).toEqual("cycle4-data"); + // Private store: the canonical URL rejects unauthenticated reads. + const status4 = yield* HttpClient.get(put4.url).pipe( + Effect.map((response) => response.status), + ); + expect([401, 403]).toContain(status4); + + // ── Cycle 5: remove the blob binding (store stays deployed) ──────── + const c5 = yield* stack.deploy( + cycle({ + items: CHAIN_ITEMS_V2, + access: "private", + fn: ChainFnNoBlob, + }), + ); + // STABLE: the store itself survives (kept in the program) and the + // Edge Config path keeps serving through the redeployed fn. + expect(c5.store.storeId).toEqual(c4.store.storeId); + expect(c5.flags.edgeConfigId).toEqual(c1.flags.edgeConfigId); + expect(c5.fn.projectId).toEqual(c1.fn.projectId); + expect(c5.fn.deploymentId).not.toEqual(c4.fn.deploymentId); + // CHANGED: the connection is gone… + expect(c5.store.projectIds).toEqual([]); + const b5 = yield* observeBindings( + c5.store.storeId, + c5.flags.edgeConfigId, + ); + expect(b5.connections).toEqual([]); + // …the platform token row and the store captures are removed… + const rows5 = yield* envRows(c5.fn.projectId); + expect( + rows5.find((r) => r.key === "BLOB_READ_WRITE_TOKEN"), + ).toBeUndefined(); + expect( + rows5.filter((r) => r.key.toLowerCase().includes("chainstore")), + ).toEqual([]); + // …while the Edge Config token row + data path stay intact. + expect(b5.tokens.length).toEqual(1); + expect(b5.tokens[0]!.id).toEqual(tokenId1); + const blob5 = yield* getJsonUntil<{ blob?: boolean }>( + `${c5.fn.url}/blob/put?path=x&body=y`, + (body) => body.blob === false, + ); + expect(blob5.blob).toBe(false); + const flag5 = (yield* getJson(`${c5.fn.url}/flag/greeting`)) as { + value: unknown; + }; + expect(flag5.value).toEqual("chain-v2"); + + // ── Cycle 6: destroy — census-clean ──────────────────────────────── + yield* stack.destroy(); + yield* expectProjectGone(c1.fn.projectId); + yield* expectStoreGone(c5.store.storeId); + yield* expectEdgeConfigGone(c1.flags.edgeConfigId); + }).pipe(logLevel), + // Five sequential deploy cycles over a real Function (each redeploy is an + // immutable Vercel deployment) + bounded data-plane polls — a chain this + // deep cannot fit the 120s single-resource budget. + { timeout: 420_000 }, +); diff --git a/packages/alchemy/test/Vercel/Chains/TenantCohabitation.test.ts b/packages/alchemy/test/Vercel/Chains/TenantCohabitation.test.ts new file mode 100644 index 0000000000..340b54666d --- /dev/null +++ b/packages/alchemy/test/Vercel/Chains/TenantCohabitation.test.ts @@ -0,0 +1,505 @@ +/** + * DEPTH.md row 5 — Tenant cohabitation chain. + * + * ONE `Vercel.Project` hosts four cohabiting resources: + * + * - a tenant `Vercel.Function` (preview mode, `project:` ref) with + * per-deployment env delivered via `.vc-config.json` + * - a `Vercel.ProjectEnv` row (`COHAB_FLAG`) in shared project env + * - a `Vercel.FirewallConfig` (custom WAF rule) + * - a `Vercel.Webhook` scoped to the project via `projectIds` (an + * interaction surface no isolation suite exercises) + * + * Cycles (each asserts BOTH what changed AND what stayed stable, with + * out-of-band reads via distilled and engine-level plan assertions): + * + * 1. deploy all → baseline; settled plan is all-noop + * 2. firewall rule escalation ONLY → version bumps; siblings untouched, + * no redeploy of the tenant + * 3. ProjectEnv value rotation ONLY → row updated in place; siblings + * untouched, no redeploy of the tenant (sibling project env is NOT + * part of the tenant's per-deployment env identity) + * 4. webhook event-list change ONLY → REPLACEMENT (new id + secret, old + * hook gone); siblings untouched + * 5. tenant greeting change ONLY → new immutable deployment behind the + * STABLE per-stage alias; siblings untouched + * 6. CONFLICT: tenant env key colliding with the sibling ProjectEnv row + * (`COHAB_FLAG`) → typed `Vercel.TenantEnvConflict`, no deployment + * created, recovery redeploy is a skip-on-hash noop + * 7. tenant removal ONLY → its alias + meta-stamped deployments drain + * (including the auto-promoted production one — see below); + * project + env row + firewall + webhook all intact + * 8. full destroy → project (cascading env + firewall) and webhook gone + * + * Platform finding this chain surfaced (probe-verified, + * `.probes/depth5-target-inference.ts`): a no-target (preview-intent) + * deployment into a project with NO production deployment is + * auto-promoted to production by Vercel; once a production deployment + * exists, the same call lands a true preview. The provider fix that came + * out of it: the tenant destroy refusal (`TenantProductionDeleteRefused`) + * keys on the tenant's declared `target` intent, not the observed + * auto-promoted target — a preview-intent tenant always drains cleanly. + * + * Engine-deadlock rule respected: no cycle replaces a resource while + * simultaneously removing one of its dependencies (the webhook replacement + * in cycle 4 keeps the project deployed; the tenant removal in cycle 7 + * replaces nothing). + */ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as aliases from "@distilled.cloud/vercel/aliases"; +import * as deployments from "@distilled.cloud/vercel/deployments"; +import * as projects from "@distilled.cloud/vercel/projects"; +import * as security from "@distilled.cloud/vercel/security"; +import * as webhooks from "@distilled.cloud/vercel/webhooks"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Result from "effect/Result"; +import * as Schedule from "effect/Schedule"; +import * as HttpClient from "effect/unstable/http/HttpClient"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +const fixtureMain = new URL("./fixtures/handler.ts", import.meta.url).pathname; + +// Deterministic, chain-unique host project name (constant across runs). +const HOST = "alchemy-chain-tenant-cohab"; +const HOOK_URL = "https://example.com/alchemy/chains/tenant-cohabitation"; +const ENV_KEY = "COHAB_FLAG"; + +// ── The chain program ──────────────────────────────────────────────────── + +interface Knobs { + readonly firewall: Vercel.FirewallRuleAction; + readonly envValue: string; + readonly events: string[]; + readonly greeting: string; + /** Tenant per-deployment env key; the conflict cycle points it at ENV_KEY. */ + readonly tenantKey?: string; +} + +const wafRule = (action: Vercel.FirewallRuleAction): Vercel.FirewallRule => ({ + name: "block-admin", + conditionGroup: [ + { conditions: [{ type: "path", op: "pre", value: "/admin" }] }, + ], + action: { action }, +}); + +/** The four sibling resources (everything but the tenant Function). */ +const siblings = (k: Knobs) => + Effect.gen(function* () { + const project = yield* Vercel.Project("Host", { name: HOST }); + const env = yield* Vercel.ProjectEnv("Flag", { + project: project.projectId, + key: ENV_KEY, + value: k.envValue, + comment: "tenant-cohabitation sibling", + }); + const waf = yield* Vercel.FirewallConfig("Waf", { + projectId: project.projectId, + rules: [wafRule(k.firewall)], + }); + const hook = yield* Vercel.Webhook("Hook", { + url: HOOK_URL, + events: k.events, + projectIds: [project.projectId], + }); + return { project, env, waf, hook }; + }); + +/** Siblings + the preview tenant Function. */ +const full = (k: Knobs) => + Effect.gen(function* () { + const rest = yield* siblings(k); + const tenant = yield* Vercel.Function("Tenant", { + main: fixtureMain, + project: rest.project.projectId, + env: { [k.tenantKey ?? "GREETING"]: k.greeting }, + }); + return { ...rest, tenant }; + }); + +// ── Out-of-band observation helpers (distilled) ────────────────────────── + +const teamScope = Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + return teamId !== undefined ? { teamId } : {}; +}); + +const observeProject = (projectId: string) => + Effect.gen(function* () { + const team = yield* teamScope; + return yield* projects.getProject({ idOrName: projectId, ...team }).pipe( + Effect.map((p): projects.GetProjectResponse | undefined => p), + Effect.catchTag("NotFound", () => Effect.succeed(undefined)), + ); + }); + +/** The env row by id — single-env GET returns the decrypted value. */ +const observeEnvRow = (projectId: string, envId: string) => + Effect.gen(function* () { + const team = yield* teamScope; + const row = yield* projects.getProjectEnv({ + idOrName: projectId, + id: envId, + ...team, + }); + return row as { value?: string; updatedAt?: number; key?: string }; + }); + +const observeFirewall = (projectId: string) => + Effect.gen(function* () { + const team = yield* teamScope; + return yield* security + .getFirewallConfig({ configVersion: "active", projectId, ...team }) + .pipe( + Effect.map( + (doc): security.GetFirewallConfigResponse | undefined => doc, + ), + Effect.catchTag("NotFound", () => Effect.succeed(undefined)), + ); + }); + +const observeHook = (id: string) => + Effect.gen(function* () { + const team = yield* teamScope; + return yield* webhooks.getWebhook({ id, ...team }).pipe( + Effect.map((hook): webhooks.GetWebhookResponse | undefined => hook), + Effect.catchTag("NotFound", () => Effect.succeed(undefined)), + ); + }); + +const observeAlias = (aliasName: string) => + Effect.gen(function* () { + const team = yield* teamScope; + return yield* aliases.getAlias({ idOrAlias: aliasName, ...team }).pipe( + Effect.map((a): unknown | undefined => a), + Effect.catchTag("NotFound", () => Effect.succeed(undefined)), + ); + }); + +/** Live (non-deleted) deployments on the host project. */ +const liveDeployments = (projectId: string) => + Effect.gen(function* () { + const team = yield* teamScope; + const page = yield* deployments.getDeployments({ + projectId, + limit: 100, + ...team, + }); + return page.deployments.filter((d) => d.readyState !== "DELETED"); + }); + +/** The project's automation bypass secret (minted by the tenant reconcile). */ +const readBypass = (projectId: string) => + Effect.gen(function* () { + const project = yield* observeProject(projectId); + expect(project).toBeDefined(); + const entry = Object.entries(project!.protectionBypass ?? {}).find( + ([, value]) => + (value as { scope?: string } | undefined)?.scope === + "automation-bypass", + ); + expect(entry).toBeDefined(); + return entry![0]; + }); + +/** + * Drive the tenant's per-stage alias through the SSO bypass until it serves + * the expected greeting (alias re-points + fresh deployments take a few + * seconds to settle — bounded). + */ +const pollGreeting = (url: string, bypass: string, expected: string) => + Effect.gen(function* () { + const body = yield* HttpClient.get(url, { + headers: { "x-vercel-protection-bypass": bypass }, + }).pipe( + Effect.flatMap((response) => + response.status === 200 + ? response.json + : Effect.fail(new Error(`status ${response.status}`)), + ), + Effect.retry({ schedule: Schedule.spaced("1 second"), times: 20 }), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (b) => (b as { greeting: string | null }).greeting === expected, + times: 15, + }), + ); + expect((body as { greeting: string | null }).greeting).toEqual(expected); + }); + +/** Engine-level plan action for a logical id. */ +const actionOf = (plan: any, logicalId: string) => + (Object.values(plan.resources) as any[]).find( + (node: any) => node.resource.LogicalId === logicalId, + )?.action; + +const SIBLING_IDS = ["Host", "Flag", "Waf", "Hook"] as const; +const ALL_IDS = [...SIBLING_IDS, "Tenant"] as const; + +/** Robust failure → text (aggregated causes may resist stringification). */ +const describeFailure = (failure: unknown): string => { + const base = String(failure); + try { + return `${base} ${JSON.stringify(failure)}`; + } catch { + return base; + } +}; + +/** Bounded typed wait until the host project is gone. */ +const expectProjectGone = (projectId: string) => + Effect.gen(function* () { + const gone = yield* observeProject(projectId).pipe( + Effect.map((p) => p === undefined), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (g) => g, + times: 10, + }), + ); + expect(gone).toBe(true); + }); + +// ── The chain ──────────────────────────────────────────────────────────── + +test.provider( + "tenant cohabitation: five resources on one project — independent cycles never clobber siblings, env conflict is typed, tenant destroy is surgical", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + // ═══ CYCLE 1: deploy the full cohabitation ═══════════════════════ + const v1: Knobs = { + firewall: "challenge", + envValue: "cohab-v1", + events: ["deployment.created"], + greeting: "greet-v1", + }; + const c1 = yield* stack.deploy(full(v1)); + + expect(c1.project.projectId).toBeDefined(); + expect(c1.env.projectId).toEqual(c1.project.projectId); + expect(c1.waf.projectId).toEqual(c1.project.projectId); + expect(c1.hook.projectIds).toEqual([c1.project.projectId]); + expect(c1.tenant.projectId).toEqual(c1.project.projectId); + expect(c1.tenant.stageAlias).toBeDefined(); + expect(c1.tenant.url).toEqual(`https://${c1.tenant.stageAlias!.alias}`); + + // Out-of-band baseline. + const bypass = yield* readBypass(c1.project.projectId); + const env1 = yield* observeEnvRow(c1.project.projectId, c1.env.envId); + expect(env1.value).toEqual("cohab-v1"); + const waf1 = yield* observeFirewall(c1.project.projectId); + expect(waf1!.firewallEnabled).toEqual(true); + expect(waf1!.rules).toHaveLength(1); + const hook1 = yield* observeHook(c1.hook.webhookId); + expect(hook1).toBeDefined(); + expect([...hook1!.events]).toEqual(["deployment.created"]); + const deps1 = yield* liveDeployments(c1.project.projectId); + expect(deps1).toHaveLength(1); + expect(deps1[0]!.uid).toEqual(c1.tenant.deploymentId); + // Platform fact (probe-verified, .probes/depth5-target-inference.ts): + // the shared project had NO production deployment, so Vercel + // auto-promoted this first deployment to production even though the + // tenant omitted `target` (preview intent). Cycle 5 pins the + // complementary inference (production exists → preview). + expect(deps1[0]!.target).toEqual("production"); + expect((deps1[0] as { readySubstate?: string }).readySubstate).toEqual( + "PROMOTED", + ); + yield* pollGreeting(`${c1.tenant.url}/env`, bypass, "greet-v1"); + + // Settled: replanning the identical program is all-noop. + const settled = yield* stack.plan(full(v1)); + for (const id of ALL_IDS) expect(actionOf(settled, id)).toBe("noop"); + + // ═══ CYCLE 2: firewall escalation ONLY ═══════════════════════════ + const v2: Knobs = { ...v1, firewall: "deny" }; + const plan2 = yield* stack.plan(full(v2)); + expect(actionOf(plan2, "Waf")).toBe("update"); + for (const id of ALL_IDS.filter((i) => i !== "Waf")) { + expect(actionOf(plan2, id)).toBe("noop"); + } + + const c2 = yield* stack.deploy(full(v2)); + // Changed: the firewall document (new version, escalated action). + expect(c2.waf.version).toBeGreaterThan(c1.waf.version); + const waf2 = yield* observeFirewall(c1.project.projectId); + const waf2Rule = waf2!.rules[0]! as { + action: { mitigate?: { action?: string } }; + }; + expect(waf2Rule.action.mitigate?.action).toEqual("deny"); + // Stable: tenant deployment (id AND count — no hidden redeploys), + // env row (same updatedAt), webhook (same id + secret), alias. + expect(c2.tenant.deploymentId).toEqual(c1.tenant.deploymentId); + expect(yield* liveDeployments(c1.project.projectId)).toHaveLength(1); + const env2 = yield* observeEnvRow(c1.project.projectId, c1.env.envId); + expect(env2.updatedAt).toEqual(env1.updatedAt); + expect(c2.hook.webhookId).toEqual(c1.hook.webhookId); + expect(c2.hook.secret).toEqual(c1.hook.secret); + expect(c2.tenant.stageAlias!.alias).toEqual(c1.tenant.stageAlias!.alias); + + // ═══ CYCLE 3: ProjectEnv value rotation ONLY ═════════════════════ + const v3: Knobs = { ...v2, envValue: "cohab-v2" }; + const plan3 = yield* stack.plan(full(v3)); + expect(actionOf(plan3, "Flag")).toBe("update"); + for (const id of ALL_IDS.filter((i) => i !== "Flag")) { + expect(actionOf(plan3, id)).toBe("noop"); + } + + const c3 = yield* stack.deploy(full(v3)); + // Changed: the env row, in place (same id, new value). + expect(c3.env.envId).toEqual(c1.env.envId); + const env3 = yield* observeEnvRow(c1.project.projectId, c1.env.envId); + expect(env3.value).toEqual("cohab-v2"); + expect(env3.updatedAt).not.toEqual(env1.updatedAt); + // Stable: tenant deployment (sibling project env is NOT part of the + // tenant's per-deployment identity), firewall version, webhook. + expect(c3.tenant.deploymentId).toEqual(c1.tenant.deploymentId); + expect(yield* liveDeployments(c1.project.projectId)).toHaveLength(1); + expect((yield* observeFirewall(c1.project.projectId))!.version).toEqual( + c2.waf.version, + ); + expect(c3.hook.webhookId).toEqual(c1.hook.webhookId); + + // ═══ CYCLE 4: webhook event change ONLY → REPLACEMENT ════════════ + const v4: Knobs = { + ...v3, + events: ["deployment.created", "deployment.succeeded"], + }; + const plan4 = yield* stack.plan(full(v4)); + expect(actionOf(plan4, "Hook")).toBe("replace"); + for (const id of ALL_IDS.filter((i) => i !== "Hook")) { + expect(actionOf(plan4, id)).toBe("noop"); + } + + const c4 = yield* stack.deploy(full(v4)); + // Changed: new webhook identity + fresh secret; old hook gone. + expect(c4.hook.webhookId).not.toEqual(c1.hook.webhookId); + expect(c4.hook.secret).not.toEqual(c1.hook.secret); + expect([...c4.hook.events].sort()).toEqual([ + "deployment.created", + "deployment.succeeded", + ]); + expect(yield* observeHook(c1.hook.webhookId)).toBeUndefined(); + const hook4 = yield* observeHook(c4.hook.webhookId); + expect(hook4).toBeDefined(); + // Stable: everything else. + expect(c4.tenant.deploymentId).toEqual(c1.tenant.deploymentId); + expect(yield* liveDeployments(c1.project.projectId)).toHaveLength(1); + expect((yield* observeFirewall(c1.project.projectId))!.version).toEqual( + c2.waf.version, + ); + expect( + (yield* observeEnvRow(c1.project.projectId, c1.env.envId)).updatedAt, + ).toEqual(env3.updatedAt); + + // ═══ CYCLE 5: tenant code/env change ONLY → new deployment ═══════ + const v5: Knobs = { ...v4, greeting: "greet-v2" }; + const plan5 = yield* stack.plan(full(v5)); + expect(actionOf(plan5, "Tenant")).toBe("update"); + for (const id of SIBLING_IDS) expect(actionOf(plan5, id)).toBe("noop"); + + const c5 = yield* stack.deploy(full(v5)); + // Changed: a new immutable deployment serving the new env… + expect(c5.tenant.deploymentId).not.toEqual(c1.tenant.deploymentId); + // …behind the STABLE per-stage alias (same hostname, same URL). + expect(c5.tenant.stageAlias!.alias).toEqual(c1.tenant.stageAlias!.alias); + expect(c5.tenant.url).toEqual(c1.tenant.url); + yield* pollGreeting(`${c5.tenant.url}/env`, bypass, "greet-v2"); + // Stable: siblings' observed cloud state. + expect((yield* observeFirewall(c1.project.projectId))!.version).toEqual( + c2.waf.version, + ); + expect( + (yield* observeEnvRow(c1.project.projectId, c1.env.envId)).value, + ).toEqual("cohab-v2"); + expect(c5.hook.webhookId).toEqual(c4.hook.webhookId); + expect(c5.hook.secret).toEqual(c4.hook.secret); + + const depsAfter5 = yield* liveDeployments(c1.project.projectId); + const depIdsAfter5 = depsAfter5.map((d) => d.uid).sort(); + // The complementary target inference: a production deployment now + // exists (cycle 1's auto-promoted one), so this no-target redeploy + // landed as a true PREVIEW deployment. Both live: the platform never + // demotes, alchemy never deletes what it can re-point around. + expect(depsAfter5).toHaveLength(2); + const dep5 = depsAfter5.find((d) => d.uid === c5.tenant.deploymentId); + expect(dep5).toBeDefined(); + expect(dep5!.target ?? null).toBeNull(); + + // ═══ CYCLE 6: the §5.1 conflict — tenant env key shadowed by the + // sibling ProjectEnv row (project env WINS on conflict) ════════ + const conflicted = yield* Effect.result( + stack.deploy(full({ ...v5, tenantKey: ENV_KEY })), + ); + expect(Result.isFailure(conflicted)).toBe(true); + const conflictText = Result.isFailure(conflicted) + ? describeFailure(conflicted.failure) + : ""; + expect( + conflictText.includes("TenantEnvConflict") || + conflictText.includes("already exist in shared project"), + ).toBe(true); + expect(conflictText).toContain(ENV_KEY); + + // The conflict fired BEFORE any deploy: no new deployment appeared + // and the alias still serves cycle-5 content. + const depsAfterConflict = yield* liveDeployments(c1.project.projectId); + expect(depsAfterConflict.map((d) => d.uid).sort()).toEqual(depIdsAfter5); + yield* pollGreeting(`${c5.tenant.url}/env`, bypass, "greet-v2"); + // Siblings untouched by the failed deploy. + expect((yield* observeFirewall(c1.project.projectId))!.version).toEqual( + c2.waf.version, + ); + expect(yield* observeHook(c4.hook.webhookId)).toBeDefined(); + + // Recovery: redeploying the good shape is a pure skip-on-hash noop. + const recovered = yield* stack.deploy(full(v5)); + expect(recovered.tenant.deploymentId).toEqual(c5.tenant.deploymentId); + expect(recovered.hook.webhookId).toEqual(c4.hook.webhookId); + + // ═══ CYCLE 7: tenant removal ONLY — surgical destroy ═════════════ + const c7 = yield* stack.deploy(siblings(v5)); + expect(c7.project.projectId).toEqual(c1.project.projectId); + + // The tenant's stage alias and meta-stamped deployments drain… + expect(yield* observeAlias(c1.tenant.stageAlias!.alias)).toBeUndefined(); + const tenantGone = yield* liveDeployments(c1.project.projectId).pipe( + Effect.map((deps) => deps.length === 0), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (g) => g, + times: 10, + }), + ); + expect(tenantGone).toBe(true); + // …while the shared project and every sibling survive, unchanged. + expect(yield* observeProject(c1.project.projectId)).toBeDefined(); + const env7 = yield* observeEnvRow(c1.project.projectId, c1.env.envId); + expect(env7.value).toEqual("cohab-v2"); + const waf7 = yield* observeFirewall(c1.project.projectId); + expect(waf7!.firewallEnabled).toEqual(true); + expect(waf7!.rules).toHaveLength(1); + expect(waf7!.version).toEqual(c2.waf.version); + expect(yield* observeHook(c4.hook.webhookId)).toBeDefined(); + + // ═══ CYCLE 8: full destroy — census clean ════════════════════════ + yield* stack.destroy(); + yield* expectProjectGone(c1.project.projectId); + expect(yield* observeHook(c4.hook.webhookId)).toBeUndefined(); + + // Idempotent double-destroy. + yield* stack.destroy(); + }).pipe(logLevel), + { timeout: 180_000 }, +); diff --git a/packages/alchemy/test/Vercel/Chains/fixtures/caller-b.ts b/packages/alchemy/test/Vercel/Chains/fixtures/caller-b.ts new file mode 100644 index 0000000000..5c99957001 --- /dev/null +++ b/packages/alchemy/test/Vercel/Chains/fixtures/caller-b.ts @@ -0,0 +1,50 @@ +/** + * Effect-mode downstream caller of the one-way invoke chain: binds + * `invoke({ LogicalId: "ChainUpstreamA" })` — a by-id forward reference to + * a Function the STACK PROGRAM declares inline (async mode, variable + * `name` prop), so the replacement test can rename the upstream project + * between deploy cycles without touching this fixture. `/call-a` reports + * the runtime-bound target URL and whether the bypass env var arrived, so + * the test can observe the re-bound env after the upstream is replaced. + */ +import * as Vercel from "@/Vercel/index.ts"; +import * as Effect from "effect/Effect"; +import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; + +export default class ChainCallerB extends Vercel.Function()( + "ChainCallerB", + { + main: import.meta.url, + }, + Effect.gen(function* () { + const a = yield* Vercel.invoke({ LogicalId: "ChainUpstreamA" }); + + return { + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; + if (request.url.startsWith("/call-a")) { + const res = yield* a.get("/echo?from=caller-b").pipe(Effect.orDie); + const body = yield* res.json.pipe(Effect.orDie); + return yield* HttpServerResponse.json({ + fn: "caller-b", + targetUrl: yield* a.url, + // sanitizeKey("ChainUpstreamA") is the identity — the bypass + // env key the InvokeFunction deploy half binds. + hasBypass: (process.env.ChainUpstreamA_BYPASS ?? "") !== "", + target: body, + }); + } + return yield* HttpServerResponse.json({ ok: true, fn: "caller-b" }); + }).pipe( + Effect.catchCause((cause) => + Effect.succeed( + HttpServerResponse.text(`caller-b failed: ${String(cause)}`, { + status: 500, + }), + ), + ), + ), + }; + }).pipe(Effect.provide(Vercel.InvokeFunctionHttp)), +) {} diff --git a/packages/alchemy/test/Vercel/Chains/fixtures/chain-a.ts b/packages/alchemy/test/Vercel/Chains/fixtures/chain-a.ts new file mode 100644 index 0000000000..38b061adcb --- /dev/null +++ b/packages/alchemy/test/Vercel/Chains/fixtures/chain-a.ts @@ -0,0 +1,53 @@ +/** + * Effect-mode side A of the circular invoke pair used by the + * InvokeReplacement chain (fresh logical ids — the Functions suite owns + * `InvokeEchoA`/`InvokeEchoB`). A imports B's class and binds + * `invoke(ChainEchoB)`; B closes the cycle with a `{ LogicalId }` forward + * reference (see chain-b.ts) so the module/type graph stays acyclic while + * the RUNTIME invoke topology is fully circular. + */ +import * as Vercel from "@/Vercel/index.ts"; +import * as Effect from "effect/Effect"; +import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import ChainEchoB from "./chain-b.ts"; + +export default class ChainEchoA extends Vercel.Function()( + "ChainEchoA", + { + main: import.meta.url, + }, + Effect.gen(function* () { + const b = yield* Vercel.invoke(ChainEchoB); + + return { + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; + if (request.url.startsWith("/call-b")) { + const res = yield* b.get("/echo?from=a").pipe(Effect.orDie); + const body = yield* res.json.pipe(Effect.orDie); + return yield* HttpServerResponse.json({ + fn: "a", + targetUrl: yield* b.url, + target: body, + }); + } + if (request.url.startsWith("/echo")) { + return yield* HttpServerResponse.json({ + fn: "a", + echo: request.url, + }); + } + return yield* HttpServerResponse.json({ ok: true, fn: "a" }); + }).pipe( + Effect.catchCause((cause) => + Effect.succeed( + HttpServerResponse.text(`a failed: ${String(cause)}`, { + status: 500, + }), + ), + ), + ), + }; + }).pipe(Effect.provide(Vercel.InvokeFunctionHttp)), +) {} diff --git a/packages/alchemy/test/Vercel/Chains/fixtures/chain-b.ts b/packages/alchemy/test/Vercel/Chains/fixtures/chain-b.ts new file mode 100644 index 0000000000..ce2c0a43b8 --- /dev/null +++ b/packages/alchemy/test/Vercel/Chains/fixtures/chain-b.ts @@ -0,0 +1,63 @@ +/** + * Effect-mode side B of the circular invoke pair — see chain-a.ts. B binds + * `invoke({ LogicalId: "ChainEchoA" })` (the module-cycle-free form). + * + * B's props are an EFFECT so the replacement test can rename B's project + * between deploy cycles: `chainBName.current` is a module-scoped mutable + * the test file flips before the third deploy (an explicit `name` prop + * change is a project REPLACEMENT). Props effects are re-evaluated on + * every deploy in a session, so the flip is picked up without re-importing + * anything; inside the deployed bundle the holder is `undefined`, which is + * irrelevant — props there never drive provisioning. + */ +import * as Vercel from "@/Vercel/index.ts"; +import * as Effect from "effect/Effect"; +import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; + +export const chainBName: { current: string | undefined } = { + current: undefined, +}; + +export default class ChainEchoB extends Vercel.Function()( + "ChainEchoB", + Effect.sync(() => ({ + main: import.meta.url, + ...(chainBName.current !== undefined ? { name: chainBName.current } : {}), + })), + Effect.gen(function* () { + // By-id forward reference — resolves through the engine's pre-created + // stub for ChainEchoA, never through the sibling module. + const a = yield* Vercel.invoke({ LogicalId: "ChainEchoA" }); + + return { + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; + if (request.url.startsWith("/call-a")) { + const res = yield* a.get("/echo?from=b").pipe(Effect.orDie); + const body = yield* res.json.pipe(Effect.orDie); + return yield* HttpServerResponse.json({ + fn: "b", + targetUrl: yield* a.url, + target: body, + }); + } + if (request.url.startsWith("/echo")) { + return yield* HttpServerResponse.json({ + fn: "b", + echo: request.url, + }); + } + return yield* HttpServerResponse.json({ ok: true, fn: "b" }); + }).pipe( + Effect.catchCause((cause) => + Effect.succeed( + HttpServerResponse.text(`b failed: ${String(cause)}`, { + status: 500, + }), + ), + ), + ), + }; + }).pipe(Effect.provide(Vercel.InvokeFunctionHttp)), +) {} diff --git a/packages/alchemy/test/Vercel/Chains/fixtures/chain-flags.ts b/packages/alchemy/test/Vercel/Chains/fixtures/chain-flags.ts new file mode 100644 index 0000000000..ab4bebb2c8 --- /dev/null +++ b/packages/alchemy/test/Vercel/Chains/fixtures/chain-flags.ts @@ -0,0 +1,23 @@ +import * as Vercel from "@/Vercel/index.ts"; + +/** Cycle-1 declarative item set. */ +export const CHAIN_ITEMS_V1 = { + greeting: "chain-v1", + rollout: 10, +}; + +/** Cycle-2+ item set: one changed value, one added key, one removed key. */ +export const CHAIN_ITEMS_V2 = { + greeting: "chain-v2", + featureX: true, +}; + +/** + * The chain's Edge Config. Like {@link ChainStore}, the test's deploy + * programs re-register the same logical id with the cycle's item set BEFORE + * yielding the Function fixture, so the fixture's internal yield resolves + * to the cycle's declaration (first-registration-wins). + */ +export const ChainFlags = Vercel.EdgeConfig("ChainFlags", { + items: { ...CHAIN_ITEMS_V1 }, +}); diff --git a/packages/alchemy/test/Vercel/Chains/fixtures/chain-fn-noblob.ts b/packages/alchemy/test/Vercel/Chains/fixtures/chain-fn-noblob.ts new file mode 100644 index 0000000000..75f6db47bc --- /dev/null +++ b/packages/alchemy/test/Vercel/Chains/fixtures/chain-fn-noblob.ts @@ -0,0 +1,25 @@ +/** + * Generation 3 of the chain Function — the SAME logical id ("ChainFn"), + * with the blob binding REMOVED (only `ReadEdgeConfig` remains). Deploying + * this generation is the chain's unbinding cycle: the store's reconciler + * must disconnect the project (removing the platform-injected + * `BLOB_READ_WRITE_TOKEN`) and the Function's env must drop the store + * captures, while the Edge Config path keeps serving. + */ +import * as Vercel from "@/Vercel/index.ts"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import { ChainFlags } from "./chain-flags.ts"; +import { makeChainFetch } from "./chain-routes.ts"; + +export default class ChainFn extends Vercel.Function()( + "ChainFn", + { + main: import.meta.url, + env: { CHAIN_SECRET: Redacted.make("chain-secret-v2") }, + }, + Effect.gen(function* () { + const config = yield* Vercel.ReadEdgeConfig(ChainFlags); + return { fetch: makeChainFetch(config, undefined) }; + }).pipe(Effect.provide(Vercel.ReadEdgeConfigHttp)), +) {} diff --git a/packages/alchemy/test/Vercel/Chains/fixtures/chain-fn-rotated.ts b/packages/alchemy/test/Vercel/Chains/fixtures/chain-fn-rotated.ts new file mode 100644 index 0000000000..7a4e280270 --- /dev/null +++ b/packages/alchemy/test/Vercel/Chains/fixtures/chain-fn-rotated.ts @@ -0,0 +1,28 @@ +/** + * Generation 2 of the chain Function — the SAME logical id ("ChainFn") and + * binding set as `chain-fn.ts`, with the Redacted secret rotated to v2. + * Deploying this generation over generation 1 is the chain's + * secret-rotation cycle: the Function must redeploy while every binding + * artifact (Edge Config token, store connection) stays stable. + */ +import * as Vercel from "@/Vercel/index.ts"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import { ChainFlags } from "./chain-flags.ts"; +import { makeChainFetch } from "./chain-routes.ts"; +import { ChainStore } from "./chain-store.ts"; + +export default class ChainFn extends Vercel.Function()( + "ChainFn", + { + main: import.meta.url, + env: { CHAIN_SECRET: Redacted.make("chain-secret-v2") }, + }, + Effect.gen(function* () { + const config = yield* Vercel.ReadEdgeConfig(ChainFlags); + const blob = yield* Vercel.ReadWriteBlob(ChainStore); + return { fetch: makeChainFetch(config, blob) }; + }).pipe( + Effect.provide([Vercel.ReadEdgeConfigHttp, Vercel.ReadWriteBlobHttp]), + ), +) {} diff --git a/packages/alchemy/test/Vercel/Chains/fixtures/chain-fn.ts b/packages/alchemy/test/Vercel/Chains/fixtures/chain-fn.ts new file mode 100644 index 0000000000..f7441ea8bd --- /dev/null +++ b/packages/alchemy/test/Vercel/Chains/fixtures/chain-fn.ts @@ -0,0 +1,26 @@ +/** + * Generation 1 of the chain Function: binds BOTH the Edge Config + * (`ReadEdgeConfig`) and the blob store (`ReadWriteBlob`), carries the v1 + * Redacted secret. + */ +import * as Vercel from "@/Vercel/index.ts"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import { ChainFlags } from "./chain-flags.ts"; +import { makeChainFetch } from "./chain-routes.ts"; +import { ChainStore } from "./chain-store.ts"; + +export default class ChainFn extends Vercel.Function()( + "ChainFn", + { + main: import.meta.url, + env: { CHAIN_SECRET: Redacted.make("chain-secret-v1") }, + }, + Effect.gen(function* () { + const config = yield* Vercel.ReadEdgeConfig(ChainFlags); + const blob = yield* Vercel.ReadWriteBlob(ChainStore); + return { fetch: makeChainFetch(config, blob) }; + }).pipe( + Effect.provide([Vercel.ReadEdgeConfigHttp, Vercel.ReadWriteBlobHttp]), + ), +) {} diff --git a/packages/alchemy/test/Vercel/Chains/fixtures/chain-routes.ts b/packages/alchemy/test/Vercel/Chains/fixtures/chain-routes.ts new file mode 100644 index 0000000000..a7faf96238 --- /dev/null +++ b/packages/alchemy/test/Vercel/Chains/fixtures/chain-routes.ts @@ -0,0 +1,68 @@ +/** + * Shared route table for the storage-compute chain fixtures. Each fixture + * class (`chain-fn.ts` / `chain-fn-rotated.ts` / `chain-fn-noblob.ts`) + * composes this with its own binding set, so the three "generations" of the + * Function under test serve identical routes. + */ +import type { ReadWriteBlobClient } from "@/Vercel/Blob/ReadWriteBlob.ts"; +import type { ReadEdgeConfigClient } from "@/Vercel/EdgeConfig/EdgeConfigRead.ts"; +import * as Effect from "effect/Effect"; +import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; + +export const makeChainFetch = ( + config: ReadEdgeConfigClient, + blob: ReadWriteBlobClient | undefined, +) => + Effect.gen(function* () { + const request = yield* HttpServerRequest; + const url = yield* Effect.sync( + () => new globalThis.URL(request.url, "http://localhost"), + ); + const path = url.pathname; + const q = (key: string) => url.searchParams.get(key) ?? ""; + + if (path.startsWith("/flag/")) { + const key = decodeURIComponent(path.slice("/flag/".length)); + const value = yield* config.get(key).pipe(Effect.orDie); + return yield* HttpServerResponse.json({ value: value ?? null }); + } + if (path === "/digest") { + const digest = yield* config.digest().pipe(Effect.orDie); + return yield* HttpServerResponse.json({ digest }); + } + if (path === "/secret") { + const secret = yield* Effect.sync(() => process.env.CHAIN_SECRET ?? null); + return yield* HttpServerResponse.json({ secret }); + } + if (path.startsWith("/blob/")) { + if (blob === undefined) { + return yield* HttpServerResponse.json({ blob: false }); + } + if (path === "/blob/put") { + const put = yield* blob + .put(q("path"), q("body"), { contentType: "text/plain" }) + .pipe(Effect.orDie); + return yield* HttpServerResponse.json({ + etag: put.etag, + url: put.url, + pathname: put.pathname, + }); + } + if (path === "/blob/get") { + const result = yield* blob.get(q("path")).pipe( + Effect.flatMap((got) => Effect.map(got.text, (text) => ({ text }))), + Effect.catchTag("Vercel.Blob.NotFound", () => + Effect.succeed({ notFound: true }), + ), + Effect.orDie, + ); + return yield* HttpServerResponse.json(result); + } + if (path === "/blob/del") { + yield* blob.del(q("path")).pipe(Effect.orDie); + return yield* HttpServerResponse.json({ ok: true }); + } + } + return yield* HttpServerResponse.json({ ok: true }); + }); diff --git a/packages/alchemy/test/Vercel/Chains/fixtures/chain-store.ts b/packages/alchemy/test/Vercel/Chains/fixtures/chain-store.ts new file mode 100644 index 0000000000..4351e2c85e --- /dev/null +++ b/packages/alchemy/test/Vercel/Chains/fixtures/chain-store.ts @@ -0,0 +1,18 @@ +import * as Vercel from "@/Vercel/index.ts"; + +/** + * The chain's blob store. The Effect-mode fixture Functions bind it via + * `ReadWriteBlob`, which is what connects the Function's project (and + * makes the platform inject `BLOB_READ_WRITE_TOKEN`). + * + * The chain test re-registers the SAME logical id with `access: "private"` + * in its replacement cycle — registration is FQN-idempotent and + * first-registration-wins, so the fixture's yield resolves to whatever the + * cycle's deploy program declared. + */ +export const ChainStore = Vercel.BlobStore("ChainStore", { + access: "public", + // The chain leaves blobs behind on purpose (replacement + destroy of a + // non-empty store) — opt into the purge-on-delete path. + forceDestroy: true, +}); diff --git a/packages/alchemy/test/Vercel/Chains/fixtures/drift-fn.ts b/packages/alchemy/test/Vercel/Chains/fixtures/drift-fn.ts new file mode 100644 index 0000000000..8eecfe6e5e --- /dev/null +++ b/packages/alchemy/test/Vercel/Chains/fixtures/drift-fn.ts @@ -0,0 +1,33 @@ +/** + * Async-mode Vercel Function fixture for the adoption + drift chain: reads + * the Edge Config through the `FLAGS` connection-string env row and exposes + * the raw env rows the chain tampers with out-of-band. + */ +import { readEdgeConfigFromEnv } from "@/Vercel/EdgeConfig/EdgeConfigRead.ts"; + +const flags = readEdgeConfigFromEnv("FLAGS"); + +export default { + async fetch(request: Request): Promise { + const path = new URL(request.url).pathname; + try { + if (path.startsWith("/item/")) { + const key = decodeURIComponent(path.slice("/item/".length)); + return Response.json({ value: (await flags.get(key)) ?? null }); + } + if (path.startsWith("/env")) { + return Response.json({ + appMode: process.env.APP_MODE ?? null, + secretValue: process.env.SECRET_VALUE ?? null, + flagsPresent: process.env.FLAGS !== undefined, + }); + } + return Response.json({ ok: true }); + } catch (error) { + return Response.json( + { error: error instanceof Error ? error.message : String(error) }, + { status: 500 }, + ); + } + }, +}; diff --git a/packages/alchemy/test/Vercel/Chains/fixtures/handler.ts b/packages/alchemy/test/Vercel/Chains/fixtures/handler.ts new file mode 100644 index 0000000000..5ab298dff8 --- /dev/null +++ b/packages/alchemy/test/Vercel/Chains/fixtures/handler.ts @@ -0,0 +1,18 @@ +/** + * Tenant-cohabitation chain fixture: a plain web-standard `{ fetch }` + * export (async mode, no Effect bridge). `/env` reports the per-deployment + * tenant env (`GREETING`) so the chain can prove per-deployment delivery + * and redeploy propagation over HTTP. + */ +export default { + async fetch(request: Request): Promise { + const url = new URL(request.url); + if (url.pathname === "/env") { + return Response.json({ + greeting: process.env.GREETING ?? null, + cohab: process.env.COHAB_FLAG ?? null, + }); + } + return Response.json({ ok: true, path: url.pathname }); + }, +}; diff --git a/packages/alchemy/test/Vercel/Chains/fixtures/mixed-blob-fn.ts b/packages/alchemy/test/Vercel/Chains/fixtures/mixed-blob-fn.ts new file mode 100644 index 0000000000..4a2c16171a --- /dev/null +++ b/packages/alchemy/test/Vercel/Chains/fixtures/mixed-blob-fn.ts @@ -0,0 +1,70 @@ +/** + * Async-mode Vercel Function fixture for the dev-mixed chain: drives a + * blob store through the promise `readWriteBlobFromEnv` client so the SAME + * handler file serves every provider mode in the chain — + * + * - LOCAL fn + LIVE store, no token → `/blob/*` reports `tokenMissing` + * (pins the documented mixed-stack contract: a live store's token only + * materializes as project env of a CONNECTED real project, and a `dev:` + * project can never be connected). + * - LOCAL fn + LIVE store + explicitly injected `BLOB_READ_WRITE_TOKEN` + * env → the local process round-trips against the REAL data plane. + * - LIVE fn (mode-flipped) → the platform-injected token from the + * store↔project connection takes over, same routes. + */ +import { readWriteBlobFromEnv } from "@/Vercel/Blob/BlobFromEnv.ts"; + +const uploads = readWriteBlobFromEnv({ access: "public" }); + +export default { + async fetch(request: Request): Promise { + const url = new URL(request.url); + const path = url.searchParams.get("path") ?? ""; + const body = url.searchParams.get("body") ?? ""; + try { + if (url.pathname === "/env") { + return Response.json({ + greeting: process.env.GREETING ?? null, + vercelEnv: process.env.VERCEL_ENV ?? null, + deploymentId: process.env.VERCEL_DEPLOYMENT_ID ?? null, + storeId: process.env.BLOB_STORE_ID ?? null, + hasToken: Boolean(process.env.BLOB_READ_WRITE_TOKEN), + }); + } + if (url.pathname === "/blob/put") { + const put = await uploads.put(path, body, { + contentType: "text/plain", + }); + return Response.json({ + etag: put.etag, + url: put.url, + pathname: put.pathname, + }); + } + if (url.pathname === "/blob/get") { + const blob = await uploads.get(path); + return Response.json({ text: blob.text }); + } + if (url.pathname === "/blob/list") { + const listed = await uploads.list({ + prefix: url.searchParams.get("prefix") ?? undefined, + }); + return Response.json({ + pathnames: listed.blobs.map((row) => row.pathname).sort(), + }); + } + if (url.pathname === "/blob/del") { + await uploads.del(path); + return Response.json({ ok: true }); + } + return Response.json({ ok: true }); + } catch (error) { + const message = String(error); + if (message.includes("BLOB_READ_WRITE_TOKEN")) { + // The documented missing-token guidance from the env client. + return Response.json({ tokenMissing: true }); + } + return Response.json({ error: message }, { status: 500 }); + } + }, +}; diff --git a/packages/alchemy/test/Vercel/Chains/fixtures/queue-evo-shared.ts b/packages/alchemy/test/Vercel/Chains/fixtures/queue-evo-shared.ts new file mode 100644 index 0000000000..ede2e6299f --- /dev/null +++ b/packages/alchemy/test/Vercel/Chains/fixtures/queue-evo-shared.ts @@ -0,0 +1,97 @@ +/** + * Shared pieces of the QueueEvolution chain fixtures (DEPTH.md row 3). + * + * The chain deploys FOUR versions of the same logical Function + * (`QueueEvoFn`), one per reconciliation cycle: + * + * v1 Topic schema v1 + default subscribe options + * v2 same schema, subscribe option change (retryAfterSeconds) + * v3 Topic schema EVOLVED (optional `note` field) + a raw-JSON send + * route that emits an OLD-shape (v1) wire message + * v4 subscribe removed entirely — no consumer function in the deployment + * + * Each version lives in its own module because the deployed bundle is built + * from the fixture file itself (`main: import.meta.url`) — a schema change + * must change the code that ships to BOTH ends. This module holds what stays + * identical across all four: the echo topic (readback channel), the `Echo` + * row type, and the raw data-plane send helper used to simulate an + * un-evolved producer. + * + * The echo topic's schema carries the optional `note` from the start so the + * readback channel itself never confounds the Orders-schema evolution under + * test. + */ +import { ambientOidcToken } from "@/Vercel/Queues/OidcToken.ts"; +import { sendMessageRaw } from "@/Vercel/Queues/QueueData.ts"; +import * as Vercel from "@/Vercel/index.ts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +export class Echoes extends Vercel.Topic()("alchemy-qevo-echoes", { + schema: Schema.Struct({ + orderId: Schema.String, + runId: Schema.String, + note: Schema.optional(Schema.String), + deliveryCount: Schema.Int, + topicName: Schema.String, + consumerGroup: Schema.String, + }), + region: "iad1", + retentionSeconds: 300, +}) {} + +export interface Echo { + readonly orderId: string; + readonly runId: string; + readonly note?: string; + readonly deliveryCount: number; + readonly topicName: string; + readonly consumerGroup: string; +} + +/** Stable readback consumer group for the public `/received` drains. */ +export const READBACK_GROUP = "alchemy-qevo-readback"; + +/** JSON diagnostics for a typed queue/schema error surfaced over HTTP. */ +export const errorInfo = (error: { + readonly _tag: string; + readonly message?: unknown; +}): { readonly error: string; readonly message: string } => ({ + error: error._tag, + message: String(error.message ?? ""), +}); + +/** + * Send RAW JSON bytes into a topic from inside the deployed function — + * ambient OIDC token, ambient deployment pin, NO schema encode. This is how + * the chain proves an old-shape (pre-evolution) wire message still decodes + * after the schema gains a field: the bytes on the wire are exactly what a + * v1 producer would have sent. + */ +export const sendRawJson = ( + topic: { readonly topicName: string; readonly region: string }, + json: unknown, +) => + Effect.gen(function* () { + const token = yield* ambientOidcToken; + const deploymentId = yield* Effect.sync( + () => process.env.VERCEL_DEPLOYMENT_ID, + ); + if (deploymentId === undefined || deploymentId === "") { + return yield* Effect.die( + "sendRawJson: no ambient VERCEL_DEPLOYMENT_ID to pin the send", + ); + } + const body = yield* Effect.sync(() => + new TextEncoder().encode(JSON.stringify(json)), + ); + return yield* sendMessageRaw({ + region: topic.region, + topic: topic.topicName, + token, + deploymentId, + body, + contentType: "application/json", + retentionSeconds: 300, + }); + }); diff --git a/packages/alchemy/test/Vercel/Chains/fixtures/queue-evo-v1.ts b/packages/alchemy/test/Vercel/Chains/fixtures/queue-evo-v1.ts new file mode 100644 index 0000000000..d077bc21cc --- /dev/null +++ b/packages/alchemy/test/Vercel/Chains/fixtures/queue-evo-v1.ts @@ -0,0 +1,103 @@ +/** + * QueueEvolution chain — CYCLE 1 fixture: Topic schema v1 (no `note`), + * default subscribe options. Same two-function shape as the flagship + * Subscribe.test.ts fixture: public routes produce/drain, the platform + * push-delivers to the generated consumer function, which echoes into + * `Echoes` for cross-function readback. + */ +import * as Vercel from "@/Vercel/index.ts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import { + Echoes, + READBACK_GROUP, + errorInfo, + type Echo, +} from "./queue-evo-shared.ts"; + +export class Orders extends Vercel.Topic()("alchemy-qevo-orders", { + schema: Schema.Struct({ + orderId: Schema.String, + amountCents: Schema.Int, + runId: Schema.String, + }), + region: "iad1", + retentionSeconds: 300, +}) {} + +// Public-instance readback state, filled by the `/received` route's drains. +const received: Echo[] = []; + +export default class QueueEvoFn extends Vercel.Function()( + "QueueEvoFn", + { + main: import.meta.url, + }, + Effect.gen(function* () { + const orders = yield* Vercel.SendMessage(Orders); + const echoes = yield* Vercel.SendMessage(Echoes); + const echoReader = yield* Vercel.ReceiveMessages(Echoes, { + consumerGroup: READBACK_GROUP, + }); + + yield* Vercel.subscribe(Orders, (order, meta) => + echoes + .send({ + orderId: order.orderId, + runId: order.runId, + deliveryCount: meta.deliveryCount, + topicName: meta.topicName, + consumerGroup: meta.consumerGroup, + }) + .pipe(Effect.orDie, Effect.asVoid), + ); + + // Sends into a topic with no trigger fail 503 — give the echo topic its + // own no-op trigger (acks under this group; readback keeps its own). + yield* Vercel.subscribe(Echoes, () => Effect.void); + + return { + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; + const url = new URL(request.url, "http://localhost"); + if (url.pathname === "/send") { + const runId = url.searchParams.get("runId") ?? "missing-run-id"; + const orderId = url.searchParams.get("orderId") ?? "order-1"; + const outcome = yield* orders + .send({ orderId, amountCents: 4200, runId }) + .pipe( + Effect.map((receipt) => ({ + queued: true as const, + orderId, + runId, + messageId: receipt.messageId, + })), + Effect.catch((error) => + Effect.succeed({ queued: false as const, ...errorInfo(error) }), + ), + ); + return yield* HttpServerResponse.json(outcome); + } + if (url.pathname === "/received") { + const batch = yield* echoReader + .receive({ maxMessages: 10, visibilityTimeoutSeconds: 60 }) + .pipe(Effect.orDie); + for (const message of batch) { + received.push(message.payload as Echo); + yield* echoReader.ack(message.receiptHandle).pipe(Effect.orDie); + } + return yield* HttpServerResponse.json({ received }); + } + return yield* HttpServerResponse.json({ ok: true, stage: 1 }); + }), + }; + }).pipe( + Effect.provide([ + Vercel.SendMessageHttp, + Vercel.ReceiveMessagesHttp, + Vercel.QueueEventSourceLive, + ]), + ), +) {} diff --git a/packages/alchemy/test/Vercel/Chains/fixtures/queue-evo-v2.ts b/packages/alchemy/test/Vercel/Chains/fixtures/queue-evo-v2.ts new file mode 100644 index 0000000000..b74c65d923 --- /dev/null +++ b/packages/alchemy/test/Vercel/Chains/fixtures/queue-evo-v2.ts @@ -0,0 +1,104 @@ +/** + * QueueEvolution chain — CYCLE 2 fixture: identical to v1 except the + * `subscribe` options change (`retryAfterSeconds: 45` on the Orders + * trigger). The trigger config lives in the consumer function's + * `.vc-config.json`, so this MUST force a redeploy even though the schema + * and handler code are unchanged. + */ +import * as Vercel from "@/Vercel/index.ts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import { + Echoes, + READBACK_GROUP, + errorInfo, + type Echo, +} from "./queue-evo-shared.ts"; + +export class Orders extends Vercel.Topic()("alchemy-qevo-orders", { + schema: Schema.Struct({ + orderId: Schema.String, + amountCents: Schema.Int, + runId: Schema.String, + }), + region: "iad1", + retentionSeconds: 300, +}) {} + +const received: Echo[] = []; + +export default class QueueEvoFn extends Vercel.Function()( + "QueueEvoFn", + { + main: import.meta.url, + }, + Effect.gen(function* () { + const orders = yield* Vercel.SendMessage(Orders); + const echoes = yield* Vercel.SendMessage(Echoes); + const echoReader = yield* Vercel.ReceiveMessages(Echoes, { + consumerGroup: READBACK_GROUP, + }); + + // The cycle-2 change: retry tuning on the Orders trigger. + yield* Vercel.subscribe( + Orders, + (order, meta) => + echoes + .send({ + orderId: order.orderId, + runId: order.runId, + deliveryCount: meta.deliveryCount, + topicName: meta.topicName, + consumerGroup: meta.consumerGroup, + }) + .pipe(Effect.orDie, Effect.asVoid), + { retryAfterSeconds: 45 }, + ); + + yield* Vercel.subscribe(Echoes, () => Effect.void); + + return { + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; + const url = new URL(request.url, "http://localhost"); + if (url.pathname === "/send") { + const runId = url.searchParams.get("runId") ?? "missing-run-id"; + const orderId = url.searchParams.get("orderId") ?? "order-1"; + const outcome = yield* orders + .send({ orderId, amountCents: 4200, runId }) + .pipe( + Effect.map((receipt) => ({ + queued: true as const, + orderId, + runId, + messageId: receipt.messageId, + })), + Effect.catch((error) => + Effect.succeed({ queued: false as const, ...errorInfo(error) }), + ), + ); + return yield* HttpServerResponse.json(outcome); + } + if (url.pathname === "/received") { + const batch = yield* echoReader + .receive({ maxMessages: 10, visibilityTimeoutSeconds: 60 }) + .pipe(Effect.orDie); + for (const message of batch) { + received.push(message.payload as Echo); + yield* echoReader.ack(message.receiptHandle).pipe(Effect.orDie); + } + return yield* HttpServerResponse.json({ received }); + } + return yield* HttpServerResponse.json({ ok: true, stage: 2 }); + }), + }; + }).pipe( + Effect.provide([ + Vercel.SendMessageHttp, + Vercel.ReceiveMessagesHttp, + Vercel.QueueEventSourceLive, + ]), + ), +) {} diff --git a/packages/alchemy/test/Vercel/Chains/fixtures/queue-evo-v3.ts b/packages/alchemy/test/Vercel/Chains/fixtures/queue-evo-v3.ts new file mode 100644 index 0000000000..bbece920dd --- /dev/null +++ b/packages/alchemy/test/Vercel/Chains/fixtures/queue-evo-v3.ts @@ -0,0 +1,134 @@ +/** + * QueueEvolution chain — CYCLE 3 fixture: the Orders Topic schema EVOLVES + * (optional `note` field added), both ends redeploy together. Keeps the + * cycle-2 retry tuning so the only diff vs v2 is the schema (+ the raw-send + * route). + * + * `/send-raw` emits an OLD-shape (v1) wire message — raw JSON bytes with no + * `note` and no schema encode, exactly what a not-yet-evolved producer + * would put on the wire — proving backward-compatible decode through the + * push consumer. + */ +import * as Vercel from "@/Vercel/index.ts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import { + Echoes, + READBACK_GROUP, + errorInfo, + sendRawJson, + type Echo, +} from "./queue-evo-shared.ts"; + +export class Orders extends Vercel.Topic()("alchemy-qevo-orders", { + schema: Schema.Struct({ + orderId: Schema.String, + amountCents: Schema.Int, + runId: Schema.String, + // The evolution: new OPTIONAL field — old-shape messages must still + // decode. + note: Schema.optional(Schema.String), + }), + region: "iad1", + retentionSeconds: 300, +}) {} + +const received: Echo[] = []; + +export default class QueueEvoFn extends Vercel.Function()( + "QueueEvoFn", + { + main: import.meta.url, + }, + Effect.gen(function* () { + const orders = yield* Vercel.SendMessage(Orders); + const echoes = yield* Vercel.SendMessage(Echoes); + const echoReader = yield* Vercel.ReceiveMessages(Echoes, { + consumerGroup: READBACK_GROUP, + }); + + yield* Vercel.subscribe( + Orders, + (order, meta) => + echoes + .send({ + orderId: order.orderId, + runId: order.runId, + ...(order.note !== undefined ? { note: order.note } : {}), + deliveryCount: meta.deliveryCount, + topicName: meta.topicName, + consumerGroup: meta.consumerGroup, + }) + .pipe(Effect.orDie, Effect.asVoid), + { retryAfterSeconds: 45 }, + ); + + yield* Vercel.subscribe(Echoes, () => Effect.void); + + return { + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; + const url = new URL(request.url, "http://localhost"); + if (url.pathname === "/send") { + const runId = url.searchParams.get("runId") ?? "missing-run-id"; + const orderId = url.searchParams.get("orderId") ?? "order-1"; + const note = url.searchParams.get("note") ?? undefined; + const outcome = yield* orders + .send({ + orderId, + amountCents: 4200, + runId, + ...(note !== undefined ? { note } : {}), + }) + .pipe( + Effect.map((receipt) => ({ + queued: true as const, + orderId, + runId, + messageId: receipt.messageId, + })), + Effect.catch((error) => + Effect.succeed({ queued: false as const, ...errorInfo(error) }), + ), + ); + return yield* HttpServerResponse.json(outcome); + } + if (url.pathname === "/send-raw") { + // OLD-shape wire message: v1 fields only, raw JSON, no encode. + const runId = url.searchParams.get("runId") ?? "missing-run-id"; + const orderId = url.searchParams.get("orderId") ?? "order-raw"; + yield* sendRawJson(Orders, { + orderId, + amountCents: 1100, + runId, + }).pipe(Effect.orDie); + return yield* HttpServerResponse.json({ + queued: true, + raw: true, + orderId, + runId, + }); + } + if (url.pathname === "/received") { + const batch = yield* echoReader + .receive({ maxMessages: 10, visibilityTimeoutSeconds: 60 }) + .pipe(Effect.orDie); + for (const message of batch) { + received.push(message.payload as Echo); + yield* echoReader.ack(message.receiptHandle).pipe(Effect.orDie); + } + return yield* HttpServerResponse.json({ received }); + } + return yield* HttpServerResponse.json({ ok: true, stage: 3 }); + }), + }; + }).pipe( + Effect.provide([ + Vercel.SendMessageHttp, + Vercel.ReceiveMessagesHttp, + Vercel.QueueEventSourceLive, + ]), + ), +) {} diff --git a/packages/alchemy/test/Vercel/Chains/fixtures/queue-evo-v4.ts b/packages/alchemy/test/Vercel/Chains/fixtures/queue-evo-v4.ts new file mode 100644 index 0000000000..0346ea4ccd --- /dev/null +++ b/packages/alchemy/test/Vercel/Chains/fixtures/queue-evo-v4.ts @@ -0,0 +1,91 @@ +/** + * QueueEvolution chain — CYCLE 4 fixture: ALL `subscribe` calls removed. + * The deployment must carry no `_alchemy-queue.func` consumer function at + * all; the public routes keep serving. `/send` reports the platform's + * verdict on producing into a trigger-less topic honestly (queued vs the + * typed rejection) so the test can pin the real behavior either way. + * + * Keeps the v3 (evolved) Orders schema — evolution is not rolled back by + * removing the consumer. + */ +import * as Vercel from "@/Vercel/index.ts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import { + Echoes, + READBACK_GROUP, + errorInfo, + type Echo, +} from "./queue-evo-shared.ts"; + +export class Orders extends Vercel.Topic()("alchemy-qevo-orders", { + schema: Schema.Struct({ + orderId: Schema.String, + amountCents: Schema.Int, + runId: Schema.String, + note: Schema.optional(Schema.String), + }), + region: "iad1", + retentionSeconds: 300, +}) {} + +const received: Echo[] = []; + +export default class QueueEvoFn extends Vercel.Function()( + "QueueEvoFn", + { + main: import.meta.url, + }, + Effect.gen(function* () { + const orders = yield* Vercel.SendMessage(Orders); + const echoReader = yield* Vercel.ReceiveMessages(Echoes, { + consumerGroup: READBACK_GROUP, + }); + + // NO subscribe calls — the consumer function must vanish from the + // deployment. + + return { + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; + const url = new URL(request.url, "http://localhost"); + if (url.pathname === "/send") { + const runId = url.searchParams.get("runId") ?? "missing-run-id"; + const orderId = url.searchParams.get("orderId") ?? "order-1"; + const outcome = yield* orders + .send({ orderId, amountCents: 4200, runId }) + .pipe( + Effect.map((receipt) => ({ + queued: true as const, + orderId, + runId, + messageId: receipt.messageId, + })), + Effect.catch((error) => + Effect.succeed({ queued: false as const, ...errorInfo(error) }), + ), + ); + return yield* HttpServerResponse.json(outcome); + } + if (url.pathname === "/received") { + const outcome = yield* echoReader + .receive({ maxMessages: 10, visibilityTimeoutSeconds: 60 }) + .pipe( + Effect.map((batch) => ({ + ok: true as const, + count: batch.length, + received, + })), + Effect.catch((error) => + Effect.succeed({ ok: false as const, ...errorInfo(error) }), + ), + ); + return yield* HttpServerResponse.json(outcome); + } + return yield* HttpServerResponse.json({ ok: true, stage: 4 }); + }), + }; + }).pipe(Effect.provide([Vercel.SendMessageHttp, Vercel.ReceiveMessagesHttp])), +) {} diff --git a/packages/alchemy/test/Vercel/Chains/fixtures/smoke-api.ts b/packages/alchemy/test/Vercel/Chains/fixtures/smoke-api.ts new file mode 100644 index 0000000000..8a91d279af --- /dev/null +++ b/packages/alchemy/test/Vercel/Chains/fixtures/smoke-api.ts @@ -0,0 +1,208 @@ +/** + * The aggregate-smoke Effect Api (DEPTH.md row 7, the §1.1 showcase): ONE + * Effect-mode Vercel Function carrying every runtime surface at once — + * + * - `ReadEdgeConfig(SmokeFlags)` — flag reads over the injected + * connection-string token, + * - `ReadWriteBlob(SmokeUploads)` — blob put/get over the injected + * `BLOB_READ_WRITE_TOKEN` (the capability binding is what connects the + * store to this project), + * - `SendMessage`/`subscribe`/`ReceiveMessages` — the D9a two-function + * queue shape (public routes produce, the platform push-delivers to the + * generated consumer func, which echoes into `SmokeEchoes` for readback), + * - `cron` — a `CRON_SECRET`-guarded schedule route. + * + * Release identity (`API_VERSION` env + a rotated `Redacted` secret) is + * parameterized through a module-level setter read by the PROPS EFFECT at + * registration time: the test calls `setApiRelease("v2", …)` before a + * deploy cycle to rotate the release in that cycle. Inside the deployed + * bundle the props effect re-runs with the module defaults — harmless, the + * runtime never reconciles. + */ +import * as Vercel from "@/Vercel/index.ts"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import { SmokeFlags } from "./smoke-flags.ts"; +import { SmokeUploads } from "./smoke-store.ts"; + +export class SmokeJobs extends Vercel.Topic()("alchemy-smoke-jobs", { + schema: Schema.Struct({ + jobId: Schema.String, + runId: Schema.String, + }), + region: "iad1", + retentionSeconds: 300, +}) {} + +export interface JobEcho { + readonly jobId: string; + readonly runId: string; + readonly deliveryCount: number; + readonly topicName: string; + readonly consumerGroup: string; +} + +export class SmokeEchoes extends Vercel.Topic()( + "alchemy-smoke-echoes", + { + schema: Schema.Struct({ + jobId: Schema.String, + runId: Schema.String, + deliveryCount: Schema.Int, + topicName: Schema.String, + consumerGroup: Schema.String, + }), + region: "iad1", + retentionSeconds: 300, + }, +) {} + +// The release the NEXT registration deploys — mutated by the test between +// cycles (the props effect below reads it at registration time). +let apiVersion = "v1"; +let apiSecret = "smoke-secret-v1"; +export const setApiRelease = (version: string, secret: string) => { + apiVersion = version; + apiSecret = secret; +}; + +// Instance-global counters/buffers (readable on the same warm instance). +let cronFires = 0; +const received: JobEcho[] = []; + +export default class SmokeApi extends Vercel.Function()( + "SmokeApi", + Effect.sync(() => ({ + main: import.meta.url, + env: { + API_VERSION: apiVersion, + // Rotated between cycles: sensitive env var, fingerprint-diffed — + // rotation MUST mint a new immutable deployment. + API_SECRET: Redacted.make(apiSecret), + }, + })), + Effect.gen(function* () { + const flags = yield* SmokeFlags; + const config = yield* Vercel.ReadEdgeConfig(flags); + const blob = yield* Vercel.ReadWriteBlob(SmokeUploads); + const jobs = yield* Vercel.SendMessage(SmokeJobs); + const echoes = yield* Vercel.SendMessage(SmokeEchoes); + const echoReader = yield* Vercel.ReceiveMessages(SmokeEchoes, { + consumerGroup: "alchemy-smoke-readback", + }); + + // Queue consumer half: decoded payload + delivery metadata re-published + // into the echo topic for cross-function readback (the consumer func is + // a separate artifact — module globals there are invisible here). + yield* Vercel.subscribe(SmokeJobs, (job, meta) => + echoes + .send({ + jobId: job.jobId, + runId: job.runId, + deliveryCount: meta.deliveryCount, + topicName: meta.topicName, + consumerGroup: meta.consumerGroup, + }) + .pipe(Effect.orDie, Effect.asVoid), + ); + // Echo topic needs a trigger too (sends into a trigger-less topic 503). + yield* Vercel.subscribe(SmokeEchoes, () => Effect.void); + + yield* Vercel.cron( + "0 3 * * *", + Effect.sync(() => { + cronFires++; + }), + ); + + return { + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; + const url = yield* Effect.sync( + () => new globalThis.URL(request.url, "http://localhost"), + ); + + // Release identity — the rollback/promote observable. + if (url.pathname === "/version") { + const body = yield* Effect.sync(() => ({ + version: process.env.API_VERSION ?? null, + secretTail: (process.env.API_SECRET ?? "").slice(-2) || null, + })); + return yield* HttpServerResponse.json(body); + } + + // Edge Config reads (data plane — shared across ALL deployments). + if (url.pathname.startsWith("/flag/")) { + const key = decodeURIComponent(url.pathname.slice("/flag/".length)); + const value = yield* config.get(key).pipe(Effect.orDie); + return yield* HttpServerResponse.json({ value: value ?? null }); + } + + // Blob data path. + if (url.pathname === "/blob/put") { + const put = yield* blob + .put( + url.searchParams.get("path") ?? "", + url.searchParams.get("body") ?? "", + { contentType: "text/plain" }, + ) + .pipe(Effect.orDie); + return yield* HttpServerResponse.json({ + etag: put.etag, + pathname: put.pathname, + }); + } + if (url.pathname === "/blob/get") { + const result = yield* blob + .get(url.searchParams.get("path") ?? "") + .pipe( + Effect.flatMap((got) => + Effect.map(got.text, (text) => ({ text })), + ), + Effect.catchTag("Vercel.Blob.NotFound", () => + Effect.succeed({ notFound: true }), + ), + Effect.orDie, + ); + return yield* HttpServerResponse.json(result); + } + + // Queue producer + echo readback. + if (url.pathname === "/send") { + const runId = url.searchParams.get("runId") ?? "missing-run-id"; + const jobId = url.searchParams.get("jobId") ?? "job-1"; + yield* jobs.send({ jobId, runId }).pipe(Effect.orDie); + return yield* HttpServerResponse.json({ queued: true, jobId, runId }); + } + if (url.pathname === "/received") { + const batch = yield* echoReader + .receive({ maxMessages: 10, visibilityTimeoutSeconds: 60 }) + .pipe(Effect.orDie); + for (const message of batch) { + received.push(message.payload as JobEcho); + yield* echoReader.ack(message.receiptHandle).pipe(Effect.orDie); + } + return yield* HttpServerResponse.json({ received }); + } + + if (url.pathname === "/cron-fires") { + return yield* HttpServerResponse.json({ cronFires }); + } + + return yield* HttpServerResponse.json({ ok: true, path: request.url }); + }), + }; + }).pipe( + Effect.provide([ + Vercel.ReadEdgeConfigHttp, + Vercel.ReadWriteBlobHttp, + Vercel.SendMessageHttp, + Vercel.ReceiveMessagesHttp, + Vercel.QueueEventSourceLive, + Vercel.CronEventSourceLive, + ]), + ), +) {} diff --git a/packages/alchemy/test/Vercel/Chains/fixtures/smoke-flags.ts b/packages/alchemy/test/Vercel/Chains/fixtures/smoke-flags.ts new file mode 100644 index 0000000000..5a9f3e89db --- /dev/null +++ b/packages/alchemy/test/Vercel/Chains/fixtures/smoke-flags.ts @@ -0,0 +1,20 @@ +/** + * Shared Edge Config declaration for the aggregate smoke chain. + * + * Registration is FQN-idempotent: the TEST program registers + * `Vercel.EdgeConfig("SmokeFlags", { items })` FIRST each cycle (with that + * cycle's declarative item set), so the fixture Function's later + * `yield* SmokeFlags` resolves to the same instance — the fixture's + * `BASE_ITEMS` are only a fallback if the Function is ever deployed alone. + */ +import * as Vercel from "@/Vercel/index.ts"; + +export const BASE_ITEMS = { + greeting: "hello", + enableCheckout: true, + limits: { maxItems: 3 }, +}; + +export const SmokeFlags = Vercel.EdgeConfig("SmokeFlags", { + items: { ...BASE_ITEMS }, +}); diff --git a/packages/alchemy/test/Vercel/Chains/fixtures/smoke-store.ts b/packages/alchemy/test/Vercel/Chains/fixtures/smoke-store.ts new file mode 100644 index 0000000000..8e46457dd3 --- /dev/null +++ b/packages/alchemy/test/Vercel/Chains/fixtures/smoke-store.ts @@ -0,0 +1,16 @@ +/** + * Public blob store bound by the smoke chain's Effect Api. The + * `ReadWriteBlob` capability binding (not a `projects:` prop) is what + * connects the Api's project — the chain asserts the connection + * materialized from the capability alone and that blob data SURVIVES the + * Api's redeploy cycles (the store is never replaced). + */ +import * as Vercel from "@/Vercel/index.ts"; + +export const SmokeUploads = Vercel.BlobStore("SmokeUploads", { + access: "public", + // The chain leaves blobs in the store at destroy time — without this + // opt-in the provider surfaces the platform's `not_empty` Conflict + // (data-protection default) instead of purging. + forceDestroy: true, +}); diff --git a/packages/alchemy/test/Vercel/Chains/fixtures/smoke-surface.ts b/packages/alchemy/test/Vercel/Chains/fixtures/smoke-surface.ts new file mode 100644 index 0000000000..61fdb5531a --- /dev/null +++ b/packages/alchemy/test/Vercel/Chains/fixtures/smoke-surface.ts @@ -0,0 +1,28 @@ +/** + * Async-mode (no Effect runtime) "surface" Function for the aggregate + * smoke chain: a plain web-standard `{ fetch }` export whose only wiring + * is the env channel — `API_URL` (the Api's read-back production URL + * attribute Output) and `API_BYPASS` (the Api's Redacted protection-bypass + * secret). `/call` proxies the Api's `/version`, proving the env-bound URL + * reaches the CURRENT production deployment of the Api across redeploys, + * rollbacks, and promotes — without this function ever redeploying. + */ +export default { + async fetch(request: Request): Promise { + const url = new URL(request.url); + if (url.pathname === "/call") { + const target = process.env.API_URL; + if (target === undefined || target === "") { + return Response.json({ error: "API_URL not set" }, { status: 500 }); + } + const headers: Record = {}; + const bypass = process.env.API_BYPASS; + if (bypass !== undefined && bypass !== "") { + headers["x-vercel-protection-bypass"] = bypass; + } + const res = await fetch(`${target}/version`, { headers }); + return Response.json({ status: res.status, body: await res.json() }); + } + return Response.json({ ok: true }); + }, +}; diff --git a/packages/alchemy/test/Vercel/Chains/fixtures/state-cycles-fn.ts b/packages/alchemy/test/Vercel/Chains/fixtures/state-cycles-fn.ts new file mode 100644 index 0000000000..631325cc52 --- /dev/null +++ b/packages/alchemy/test/Vercel/Chains/fixtures/state-cycles-fn.ts @@ -0,0 +1,32 @@ +/** + * Async-mode Vercel Function fixture for the StateStoreCycles chain: reads + * the Edge Config connection string bound to the `FLAGS` env var with the + * promise-based `readEdgeConfigFromEnv` client. Kept async-mode (no Effect + * runtime) so the chain pins the binding-as-env-value form — the props are + * fully declared inline by the test, which is what lets each reconciliation + * cycle vary the Edge Config items without touching this file. + */ +import { readEdgeConfigFromEnv } from "@/Vercel/EdgeConfig/EdgeConfigRead.ts"; + +const flags = readEdgeConfigFromEnv("FLAGS"); + +export default { + async fetch(request: Request): Promise { + const path = new URL(request.url).pathname; + try { + if (path.startsWith("/item/")) { + const key = decodeURIComponent(path.slice("/item/".length)); + return Response.json({ value: (await flags.get(key)) ?? null }); + } + if (path.startsWith("/all")) { + return Response.json({ items: await flags.getAll() }); + } + return Response.json({ ok: true }); + } catch (error) { + return Response.json( + { error: error instanceof Error ? error.message : String(error) }, + { status: 500 }, + ); + } + }, +}; diff --git a/packages/alchemy/test/Vercel/Chains/fixtures/upstream-a.ts b/packages/alchemy/test/Vercel/Chains/fixtures/upstream-a.ts new file mode 100644 index 0000000000..a067ce3f49 --- /dev/null +++ b/packages/alchemy/test/Vercel/Chains/fixtures/upstream-a.ts @@ -0,0 +1,20 @@ +/** + * Async-mode upstream target of the one-way invoke chain + * (`ChainUpstreamA` ← InvokeFunction ← `ChainCallerB`): a plain + * web-standard `{ fetch }` export, no Effect runtime. Being async-mode is + * deliberate — it pins that a `Vercel.invoke({ LogicalId })` forward + * reference binds attribute Outputs off ANY Function row, not just + * Effect-mode class fixtures. + */ +export default { + async fetch(request: Request): Promise { + const url = new URL(request.url); + if (url.pathname === "/echo") { + return Response.json({ + fn: "upstream-a", + echo: url.pathname + url.search, + }); + } + return Response.json({ ok: true, fn: "upstream-a" }); + }, +}; diff --git a/packages/alchemy/test/Vercel/Checks/Check.test.ts b/packages/alchemy/test/Vercel/Checks/Check.test.ts new file mode 100644 index 0000000000..6156acd42a --- /dev/null +++ b/packages/alchemy/test/Vercel/Checks/Check.test.ts @@ -0,0 +1,133 @@ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as checks from "@distilled.cloud/vercel/checks_v2"; +import * as projects from "@distilled.cloud/vercel/projects"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +// Deterministic host-project name. +const HOST_LIFECYCLE = "alchemy-checks-host-lifecycle"; +const NAME_RENAMED = "alchemy-test-check-renamed"; + +const teamScopeOf = Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + return teamId === undefined ? {} : { teamId }; +}); + +// Out-of-band host-project fixture (NOT the Vercel.Project resource). +const ensureHostProject = (name: string, scope: { teamId?: string }) => + Effect.gen(function* () { + yield* projects + .deleteProject({ idOrName: name, ...scope }) + .pipe(Effect.catchTag("NotFound", () => Effect.void)); + const created = yield* projects.createProject({ name, ...scope }); + return created.id; + }); + +const deleteHostProject = (name: string, scope: { teamId?: string }) => + projects.deleteProject({ idOrName: name, ...scope }).pipe(Effect.ignore); + +/** Bounded typed wait-until-gone (uses the patched typed NotFound). */ +const expectCheckGone = ( + projectId: string, + checkId: string, + scope: { teamId?: string }, +) => + Effect.gen(function* () { + const gone = yield* checks + .getProjectCheck({ projectIdOrName: projectId, checkId, ...scope }) + .pipe( + Effect.map(() => false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (g) => g, + times: 10, + }), + ); + expect(gone).toBe(true); + }); + +test.provider( + "create, no-op redeploy, update, and destroy a project check", + (stack) => + Effect.gen(function* () { + const scope = yield* teamScopeOf; + const projectId = yield* ensureHostProject(HOST_LIFECYCLE, scope); + yield* Effect.gen(function* () { + yield* stack.destroy(); + + // Create with the engine-generated deterministic name and defaults + // (requires deployment-url, blocks none). Registered with a + // personal/team token — checks v2 is NOT integration-gated + // (live-verified 2026-08-13). + const created = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.Check("Check", { project: projectId }); + }), + ); + expect(created.checkId).toMatch(/^chk_/); + expect(created.projectId).toEqual(projectId); + expect(created.requires).toEqual("deployment-url"); + expect(created.blocks).toEqual("none"); + expect(created.sourceKind).toEqual("webhook"); + + // Out-of-band verification via distilled. + const observed = yield* checks.getProjectCheck({ + projectIdOrName: projectId, + checkId: created.checkId, + ...scope, + }); + expect(observed.name).toEqual(created.name); + expect(observed.blocks).toEqual("none"); + + // No-op redeploy: same identity, updatedAt untouched (no PATCH). + const second = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.Check("Check", { project: projectId }); + }), + ); + expect(second.checkId).toEqual(created.checkId); + expect(second.updatedAt).toEqual(created.updatedAt); + + // Update in place: rename + gate aliasing + rerequestable + timeout. + const updated = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.Check("Check", { + project: projectId, + name: NAME_RENAMED, + blocks: "deployment-alias", + isRerequestable: true, + timeout: 600, + }); + }), + ); + expect(updated.checkId).toEqual(created.checkId); + expect(updated.name).toEqual(NAME_RENAMED); + expect(updated.blocks).toEqual("deployment-alias"); + expect(updated.isRerequestable).toEqual(true); + expect(updated.timeout).toEqual(600); + + const observedUpdated = yield* checks.getProjectCheck({ + projectIdOrName: projectId, + checkId: created.checkId, + ...scope, + }); + expect(observedUpdated.name).toEqual(NAME_RENAMED); + expect(observedUpdated.blocks).toEqual("deployment-alias"); + + yield* stack.destroy(); + yield* expectCheckGone(projectId, created.checkId, scope); + }).pipe(Effect.ensuring(deleteHostProject(HOST_LIFECYCLE, scope))); + }).pipe(logLevel), + { timeout: 120_000 }, +); diff --git a/packages/alchemy/test/Vercel/Deploy/Artifact.test.ts b/packages/alchemy/test/Vercel/Deploy/Artifact.test.ts new file mode 100644 index 0000000000..763ca577bb --- /dev/null +++ b/packages/alchemy/test/Vercel/Deploy/Artifact.test.ts @@ -0,0 +1,126 @@ +/** + * Credential-free unit tests for the internal deploy engine's pure pieces: + * content addressing, artifact hashing, and Build Output v3 tree assembly. + * These run without a Vercel token (no API calls) — the live lifecycle + * suites are in ../Projects and ../Functions. + */ +import { + artifactFileFromBytes, + artifactHash, + sha1Hex, +} from "@/Vercel/Deploy/Artifact.ts"; +import { fromFunctionBundle } from "@/Vercel/Deploy/BuildOutput.ts"; +import { sensitiveFingerprint } from "@/Vercel/Deploy/Engine.ts"; +import { NodeServices } from "@effect/platform-node"; +import { expect, it } from "alchemy-test"; +import * as Effect from "effect/Effect"; + +const bytes = (s: string) => new TextEncoder().encode(s); + +const withNode = (eff: Effect.Effect) => + eff.pipe(Effect.provide(NodeServices.layer)) as Effect.Effect; + +it.live("sha1Hex matches the known vector", () => + Effect.gen(function* () { + // sha1("hello") — Vercel's upload content address. + expect(yield* sha1Hex(bytes("hello"))).toEqual( + "aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d", + ); + }), +); + +it.live("artifact hash is order-independent and content-sensitive", () => + Effect.gen(function* () { + const a = yield* artifactFileFromBytes("a.txt", bytes("aaa")); + const b = yield* artifactFileFromBytes("b.txt", bytes("bbb")); + const hash1 = yield* artifactHash([a, b]); + const hash2 = yield* artifactHash([b, a]); + expect(hash1).toEqual(hash2); + + const b2 = yield* artifactFileFromBytes("b.txt", bytes("BBB")); + const hash3 = yield* artifactHash([a, b2]); + expect(hash3).not.toEqual(hash1); + }), +); + +it.live("fromFunctionBundle assembles the Build Output v3 tree", () => + withNode( + Effect.gen(function* () { + const artifact = yield* fromFunctionBundle({ + bundle: [{ path: "index.mjs", bytes: bytes("export default {};") }], + vcConfig: { + runtime: "nodejs22.x", + handler: "index.mjs", + launcherType: "Nodejs", + supportsResponseStreaming: true, + }, + crons: [{ path: "/_alchemy/cron/x", schedule: "0 3 * * *" }], + routes: [{ src: "/custom", dest: "/index" }], + }); + + const paths = artifact.files.map((f) => f.path).sort(); + expect(paths).toEqual([ + ".vercel/output/config.json", + ".vercel/output/functions/index.func/.vc-config.json", + ".vercel/output/functions/index.func/index.mjs", + ]); + + const config = JSON.parse( + new TextDecoder().decode( + ( + artifact.files.find((f) => f.path === ".vercel/output/config.json")! + .source as { _tag: "Bytes"; bytes: Uint8Array } + ).bytes, + ), + ); + expect(config.version).toEqual(3); + // Contributed routes come BEFORE the filesystem handler; the /index + // catch-all is last. + expect(config.routes).toEqual([ + { src: "/custom", dest: "/index" }, + { handle: "filesystem" }, + { src: "/.*", dest: "/index" }, + ]); + expect(config.crons).toEqual([ + { path: "/_alchemy/cron/x", schedule: "0 3 * * *" }, + ]); + + // Same inputs → same hash (THE diff key must be deterministic). + const again = yield* fromFunctionBundle({ + bundle: [{ path: "index.mjs", bytes: bytes("export default {};") }], + vcConfig: { + runtime: "nodejs22.x", + handler: "index.mjs", + launcherType: "Nodejs", + supportsResponseStreaming: true, + }, + crons: [{ path: "/_alchemy/cron/x", schedule: "0 3 * * *" }], + routes: [{ src: "/custom", dest: "/index" }], + }); + expect(again.hash).toEqual(artifact.hash); + + // Cron changes are artifact changes (they live in config.json). + const noCron = yield* fromFunctionBundle({ + bundle: [{ path: "index.mjs", bytes: bytes("export default {};") }], + vcConfig: { + runtime: "nodejs22.x", + handler: "index.mjs", + launcherType: "Nodejs", + supportsResponseStreaming: true, + }, + }); + expect(noCron.hash).not.toEqual(artifact.hash); + }), + ), +); + +it.live("sensitive fingerprints are stable and value-addressed", () => + Effect.gen(function* () { + const one = yield* sensitiveFingerprint("s3cret"); + const two = yield* sensitiveFingerprint("s3cret"); + const other = yield* sensitiveFingerprint("different"); + expect(one).toMatch(/^alchemy:sha256:[0-9a-f]{64}$/); + expect(one).toEqual(two); + expect(one).not.toEqual(other); + }), +); diff --git a/packages/alchemy/test/Vercel/Domains/Cert.test.ts b/packages/alchemy/test/Vercel/Domains/Cert.test.ts new file mode 100644 index 0000000000..81e7d12bab --- /dev/null +++ b/packages/alchemy/test/Vercel/Domains/Cert.test.ts @@ -0,0 +1,160 @@ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as certs from "@distilled.cloud/vercel/certs"; +import * as domains from "@distilled.cloud/vercel/domains"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +const teamScope = Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + return teamId !== undefined ? { teamId } : {}; +}); + +// Checked-in fixture (generated once with `openssl req -x509 -newkey +// rsa:2048 -days 3650 -nodes -subj "/CN=alchemy-vercel-test.example"`) — +// never generated at test time. Used only to exercise the typed +// entitlement rejection on uploadCert; the key secures nothing. +const FIXTURE_CERT_PEM = `-----BEGIN CERTIFICATE----- +MIICyDCCAbACCQCUO0vwUYEX/DANBgkqhkiG9w0BAQsFADAmMSQwIgYDVQQDDBth +bGNoZW15LXZlcmNlbC10ZXN0LmV4YW1wbGUwHhcNMjYwODE0MDE1MzUwWhcNMzYw +ODExMDE1MzUwWjAmMSQwIgYDVQQDDBthbGNoZW15LXZlcmNlbC10ZXN0LmV4YW1w +bGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDEFCprKhFDZ1vR2b54 +ILB3G2CFBy7SLoXEOQZTHgj9SNvvleHoHRJcewThGfe+J8tOlngL76cCoFCyOeB1 +Iz4a1LgU44RzpfUWx7wvf8FV2d/4QcI2Czo2Pmh0c5cq9n0rq9myvjb8RFMHqsAA +zt5rpIjHL10PK9+4nUd0tZrF71twOEqKdHyCdMHzZnt4QQxwF4G/lJztXacbDPvU +Zg2pup0McaGSGMHDm6u0HpS3Cm2zLEgDzRfc/6C2of6KiCPFjpl2hGZosrXojoOx +7QPDeTKHJS8A0IXt1Vddu+hGNeJj3H8yzmdDMZKgUeGsfEIlWlw7+EDjlGvt+/vK +lW1pAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAJtHvR5XR2IzOeu7gW63YMnMT5xm +eV25dZaQNzSb6LqOf41PC45Ibew1ao2zPZHnPkWxj3iLNsm0Lv2MciAHgDzmyTBE +AX72USIFIq7e/CwBTGgGsgeGFPxySJD3h8RBxOG7bhcJEfVUB29nIuJPVwgkrtSu +sAo4uLm3EUNH7M3XHiSea9UnfbYI9uKvMjJYzoucWucJxLv6EB8HV1vODZRn3V9h +EFxAHyG/QelwT3yepNWHF29rbbte9HTV7oUB6XcaMqhDz8Nh6wCe5p73gxBU2zKL +fM28jl++ouiSMM7vVt/nheYzZ3ejYrcPi3GslScl9no8m5voUXuu0rvIVKk= +-----END CERTIFICATE-----`; + +const FIXTURE_KEY_PEM = `-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDEFCprKhFDZ1vR +2b54ILB3G2CFBy7SLoXEOQZTHgj9SNvvleHoHRJcewThGfe+J8tOlngL76cCoFCy +OeB1Iz4a1LgU44RzpfUWx7wvf8FV2d/4QcI2Czo2Pmh0c5cq9n0rq9myvjb8RFMH +qsAAzt5rpIjHL10PK9+4nUd0tZrF71twOEqKdHyCdMHzZnt4QQxwF4G/lJztXacb +DPvUZg2pup0McaGSGMHDm6u0HpS3Cm2zLEgDzRfc/6C2of6KiCPFjpl2hGZosrXo +joOx7QPDeTKHJS8A0IXt1Vddu+hGNeJj3H8yzmdDMZKgUeGsfEIlWlw7+EDjlGvt ++/vKlW1pAgMBAAECggEAFlZdxruSH+WkdjGiGzlOISODSWRaFyOppYMBj3J6f7BP +LeobREAbmWGCWsqEiKsr5BYMMv/oPMpapxMk2PNc3d2h4u9QZYRgeWnjrF2XftpF +Q5jqMRHyXb+aUrngXMqb09/N+yjkRrTZ6KOxH+ZxPD4QPvDMXzAWWofAXjFaInZA +ycoGTV+0eSUOGiY6r+1E3nmWpglYpNCwiWy3TY2AKuRWjorpLg5Ro+3bLZc5AASE +Jh1uXJ/lbyQIwvoipaME8J20flVHPMX+/YnOmQO5LelPrVNRVJaghHC5UVwPEHKM +3yzdiNUL4L99B31OwDyiVbnuG10B54YHxEgWytZpyQKBgQD7BWa0tkKdwdRdrgIp +Pklhoyoy9RYAdQU1lYr3f4SllgLTBClQXXE0pP1QXXUiwnMABMj14ZAjv7j+WRqa +AlHN93VIHL9Ns25F2MSYmzxy7IdK8SZ7ek0OG48NpVJIsCw+9df9V4W7brMg2wsh +IipgYfBO4NHNcRU5qJ3FOPdcxwKBgQDH98lIxLIvGKCxGu5W0NbtrCGYC3AMqE3W +DWK4eb9m1EQvs+G8Fb5CxeWAmFhvTpT0NW57jXwK+9QLZDf+J1LX+k8ugw+5PxDg +IiPjLI4QiyZhbSg8nX0Na8byn7785xwKX8MydEKuPMDnWPsaUu4miMV4xdGTuqSG +4o1+rj3UTwKBgDj/W/fSnsO1fGQdG86DnyP1aaKSdgF6kMk/AIP8R4FV06RYgI0H ++qmKgR5bajqPTo+FhqAWLKWBZh8S2nB38F1FQDM0m9en03U2qEVCknJB9OJ2aVeG +SLLYXR4rGMj6f8F4DyguVGZf13qxYhCO8nJaKreuYtU0RS6Hc/ORYNGHAoGBAJOB +VWonJeUVvptF6WAC5zgkzBcTANFlaR0nfJXlwOmCVNX3U+FhDJrGzfdg6YMZrUjD +DT94a3LStmS8xYzlxvdoPfZqWTPlsHYU2PIfkJ/ldSdS1OZ5qaA3y2Z3rfNyKz3/ +y8Yw+mr6h7Vf7sJJQEEOjNP84A6gE/MntQYoU5WDAoGAMUbX10JS6OcoEZxasuc+ +BgAwPQ7tkJe/7i7Lb+pADdiUT12yv8l7VaWbAP+Cb6wNyLrAceKP0WMreMQDQ3nY +OyygTGQHj7MMsD613+XPbiuBBfPHDFoGGOg+doA0CAAvbj/lhISnRcUyB9RuWt3G +Pc3wJwE5ts33pfgbSEURp9w= +-----END PRIVATE KEY-----`; + +// ───────────────────────────────────────────────────────────────────────────── +// Ungated probes — pin the typed entitlement/pretest rejections forever, at +// near-zero cost (no cert is ever created on the account). +// ───────────────────────────────────────────────────────────────────────────── + +test.provider( + "issueCert for a domain not resolving to Vercel fails with the typed DomainPretestFailed (HTTP 449)", + (_stack) => + Effect.gen(function* () { + const team = yield* teamScope; + const NAME = "alchemy-vercel-test-cert.example"; + // Ensure the domain exists on the team (idempotent upsert) so the + // pretest actually runs. + yield* domains.createOrTransferDomain({ name: NAME, ...team }); + const error = yield* certs + .issueCert({ cns: [NAME], ...team }) + .pipe(Effect.flip); + expect(error._tag).toEqual("DomainPretestFailed"); + yield* domains + .deleteDomain({ domain: NAME, ...team }) + .pipe(Effect.catchTag("NotFound", () => Effect.void)); + }).pipe(logLevel), + { timeout: 60_000 }, +); + +test.provider( + "uploadCert without an Enterprise plan fails with the typed PaymentRequired", + (_stack) => + Effect.gen(function* () { + const team = yield* teamScope; + const error = yield* certs + .uploadCert({ + ...team, + ca: FIXTURE_CERT_PEM, + key: FIXTURE_KEY_PEM, + cert: FIXTURE_CERT_PEM, + skipValidation: true, + }) + .pipe(Effect.flip); + expect(error._tag).toEqual("PaymentRequired"); + }).pipe(logLevel), + { timeout: 60_000 }, +); + +// ───────────────────────────────────────────────────────────────────────────── +// Full lifecycle — needs a domain that actually resolves to Vercel (issuance +// runs an HTTP pretest against the live edge), which the testing team does +// not have. Entitled environments run it with: +// VERCEL_TEST_CERTS=1 VERCEL_TEST_CERT_CNS=resolving.example.com +// ───────────────────────────────────────────────────────────────────────────── + +test.provider.skipIf(!process.env.VERCEL_TEST_CERTS)( + "cert lifecycle: issue, observe, destroy (VERCEL_TEST_CERTS=1)", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + const cns = (process.env.VERCEL_TEST_CERT_CNS ?? "") + .split(",") + .filter((s) => s.length > 0); + expect(cns.length).toBeGreaterThan(0); + + const created = yield* stack.deploy(Vercel.Cert("Cert", { cns })); + expect(created.certId.length).toBeGreaterThan(0); + expect(created.expiresAt).toBeGreaterThan(created.createdAt); + + const team = yield* teamScope; + const observed = yield* certs.getCertById({ + id: created.certId, + ...team, + }); + expect(observed.id).toEqual(created.certId); + + yield* stack.destroy(); + const gone = yield* certs + .getCertById({ id: created.certId, ...team }) + .pipe( + Effect.as("found" as const), + Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (s) => s === "gone", + times: 10, + }), + ); + expect(gone).toEqual("gone"); + }).pipe(logLevel), + { timeout: 120_000 }, +); diff --git a/packages/alchemy/test/Vercel/Domains/DnsRecord.test.ts b/packages/alchemy/test/Vercel/Domains/DnsRecord.test.ts new file mode 100644 index 0000000000..3ae5eb24d6 --- /dev/null +++ b/packages/alchemy/test/Vercel/Domains/DnsRecord.test.ts @@ -0,0 +1,163 @@ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as dns from "@distilled.cloud/vercel/dns"; +import * as domains from "@distilled.cloud/vercel/domains"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +const teamScope = Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + return teamId !== undefined ? { teamId } : {}; +}); + +/** Out-of-band read of a record by id (id endpoint is team-agnostic). */ +const getRecord = (recordId: string) => + dns.getDomainsRecordsByRecordId({ recordId }).pipe( + Effect.map((r): dns.GetDomainsRecordsByRecordIdResponse | undefined => r), + Effect.catchTag("NotFound", () => Effect.succeed(undefined)), + ); + +const expectRecordGone = (recordId: string) => + Effect.gen(function* () { + const gone = yield* getRecord(recordId).pipe( + Effect.map((r) => + r === undefined ? ("gone" as const) : ("found" as const), + ), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (s) => s === "gone", + times: 10, + }), + ); + expect(gone).toEqual("gone"); + }); + +const expectDomainGone = (name: string) => + Effect.gen(function* () { + const team = yield* teamScope; + const gone = yield* domains.getDomain({ domain: name, ...team }).pipe( + Effect.as("found" as const), + Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (s) => s === "gone", + times: 10, + }), + ); + expect(gone).toEqual("gone"); + }); + +test.provider( + "record lifecycle: create, observe, update (id is re-minted), destroy", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const ZONE = "alchemy-vercel-test-dns.example"; + + const deployRecord = (value: string, ttl: number) => + stack.deploy( + Effect.gen(function* () { + const zone = yield* Vercel.Domain("Zone", { name: ZONE }); + const record = yield* Vercel.DnsRecord("Probe", { + domain: zone, + type: "TXT", + name: "probe", + value, + ttl, + comment: "alchemy DnsRecord lifecycle test", + }); + return { zone, record }; + }), + ); + + const created = yield* deployRecord("hello-alchemy", 60); + expect(created.record.domain).toEqual(ZONE); + expect(created.record.type).toEqual("TXT"); + expect(created.record.name).toEqual("probe"); + expect(created.record.value).toEqual("hello-alchemy"); + expect(created.record.ttl).toEqual(60); + + // Out-of-band verification via distilled. + const observed = yield* getRecord(created.record.recordId); + expect(observed).toBeDefined(); + expect(observed!.value).toEqual("hello-alchemy"); + expect(observed!.domain).toEqual(ZONE); + + // No-op redeploy keeps the record id. + const noop = yield* deployRecord("hello-alchemy", 60); + expect(noop.record.recordId).toEqual(created.record.recordId); + + // Update value + ttl — Vercel mints a NEW record id on update. + const updated = yield* deployRecord("hello-alchemy-2", 120); + expect(updated.record.value).toEqual("hello-alchemy-2"); + expect(updated.record.ttl).toEqual(120); + expect(updated.record.recordId).not.toEqual(created.record.recordId); + + const observedUpdated = yield* getRecord(updated.record.recordId); + expect(observedUpdated).toBeDefined(); + expect(observedUpdated!.value).toEqual("hello-alchemy-2"); + + yield* stack.destroy(); + yield* expectRecordGone(updated.record.recordId); + yield* expectDomainGone(ZONE); + }).pipe(logLevel), + { timeout: 120_000 }, +); + +test.provider( + "replaces the record when it moves to another domain", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const ZONE_A = "alchemy-vercel-test-dns-a.example"; + const ZONE_B = "alchemy-vercel-test-dns-b.example"; + + // Keep BOTH zones deployed across the replacement step (a replace + // that simultaneously removes its old dependency deadlocks the + // engine — known bug). + const deployOn = (target: "a" | "b") => + stack.deploy( + Effect.gen(function* () { + const zoneA = yield* Vercel.Domain("ZoneA", { name: ZONE_A }); + const zoneB = yield* Vercel.Domain("ZoneB", { name: ZONE_B }); + const record = yield* Vercel.DnsRecord("Moved", { + domain: target === "a" ? zoneA : zoneB, + type: "CNAME", + name: "www", + value: "cname.vercel-dns.com", + }); + return { zoneA, zoneB, record }; + }), + ); + + const initial = yield* deployOn("a"); + expect(initial.record.domain).toEqual(ZONE_A); + + const replaced = yield* deployOn("b"); + expect(replaced.record.domain).toEqual(ZONE_B); + expect(replaced.record.recordId).not.toEqual(initial.record.recordId); + + const observed = yield* getRecord(replaced.record.recordId); + expect(observed).toBeDefined(); + expect(observed!.domain).toEqual(ZONE_B); + + // Old generation's record was deleted from zone A. + yield* expectRecordGone(initial.record.recordId); + + yield* stack.destroy(); + yield* expectDomainGone(ZONE_A); + yield* expectDomainGone(ZONE_B); + }).pipe(logLevel), + { timeout: 120_000 }, +); diff --git a/packages/alchemy/test/Vercel/Domains/Domain.test.ts b/packages/alchemy/test/Vercel/Domains/Domain.test.ts new file mode 100644 index 0000000000..aeef5b9445 --- /dev/null +++ b/packages/alchemy/test/Vercel/Domains/Domain.test.ts @@ -0,0 +1,117 @@ +import * as Provider from "@/Provider"; +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as domains from "@distilled.cloud/vercel/domains"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +const teamScope = Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + return teamId !== undefined ? { teamId } : {}; +}); + +/** Out-of-band read of a domain via distilled. */ +const getDomain = (name: string) => + Effect.gen(function* () { + const team = yield* teamScope; + return yield* domains.getDomain({ domain: name, ...team }).pipe( + Effect.map((res) => res.domain as typeof res.domain | undefined), + Effect.catchTag("NotFound", () => Effect.succeed(undefined)), + ); + }); + +/** Typed wait-until-gone (bounded). */ +const expectDomainGone = (name: string) => + Effect.gen(function* () { + const gone = yield* getDomain(name).pipe( + Effect.map((d) => + d === undefined ? ("gone" as const) : ("found" as const), + ), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (s) => s === "gone", + times: 10, + }), + ); + expect(gone).toEqual("gone"); + }); + +test.provider( + "domain lifecycle: add, observe, idempotent redeploy, destroy", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const NAME = "alchemy-vercel-test-crud.example"; + + const created = yield* stack.deploy( + Vercel.Domain("Apex", { name: NAME }), + ); + expect(created.name).toEqual(NAME); + expect(created.domainId.length).toBeGreaterThan(0); + expect(created.verified).toEqual(true); + + // Out-of-band verification via distilled. + const observed = yield* getDomain(NAME); + expect(observed).toBeDefined(); + expect(observed!.name).toEqual(NAME); + expect(observed!.id).toEqual(created.domainId); + + // Redeploying the same props converges without churn. + const noop = yield* stack.deploy(Vercel.Domain("Apex", { name: NAME })); + expect(noop.domainId).toEqual(created.domainId); + expect(noop.name).toEqual(NAME); + + // list() enumerates the deployed domain. + const provider = yield* Provider.findProvider(Vercel.Domain); + const all = yield* provider.list(); + expect(all.find((d) => d.name === NAME)).toBeDefined(); + + yield* stack.destroy(); + yield* expectDomainGone(NAME); + + // Destroy again — delete path is idempotent. + yield* stack.destroy(); + }).pipe(logLevel), + { timeout: 120_000 }, +); + +test.provider( + "replaces the domain when the name changes", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const NAME_A = "alchemy-vercel-test-repl-a.example"; + const NAME_B = "alchemy-vercel-test-repl-b.example"; + + const created = yield* stack.deploy( + Vercel.Domain("Apex", { name: NAME_A }), + ); + expect(created.name).toEqual(NAME_A); + + const replaced = yield* stack.deploy( + Vercel.Domain("Apex", { name: NAME_B }), + ); + expect(replaced.name).toEqual(NAME_B); + expect(replaced.domainId).not.toEqual(created.domainId); + + // The new domain exists; the old generation was deleted. + const observedB = yield* getDomain(NAME_B); + expect(observedB).toBeDefined(); + yield* expectDomainGone(NAME_A); + + yield* stack.destroy(); + yield* expectDomainGone(NAME_B); + }).pipe(logLevel), + { timeout: 120_000 }, +); diff --git a/packages/alchemy/test/Vercel/Domains/ProjectDomain.test.ts b/packages/alchemy/test/Vercel/Domains/ProjectDomain.test.ts new file mode 100644 index 0000000000..2596c8adb8 --- /dev/null +++ b/packages/alchemy/test/Vercel/Domains/ProjectDomain.test.ts @@ -0,0 +1,195 @@ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as domains from "@distilled.cloud/vercel/domains"; +import * as projects from "@distilled.cloud/vercel/projects"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +const teamScope = Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + return teamId !== undefined ? { teamId } : {}; +}); + +/** + * Create (or reuse, after an interrupted run) a host project out-of-band via + * distilled — deterministic name — run `body` against it, and always delete + * the project again. Attaching `*.{apexDomain}` auto-adds the apex domain to + * the team's domain list, so it is deleted on the way out as well. + */ +const withHostProject = ( + hostName: string, + apexDomain: string, + body: (projectId: string) => Effect.Effect, +) => + Effect.gen(function* () { + const team = yield* teamScope; + const host = yield* projects + .createProject({ name: hostName, ...team }) + .pipe( + Effect.map((p) => ({ id: p.id })), + Effect.catchTag("Conflict", () => + projects + .getProject({ idOrName: hostName, ...team }) + .pipe(Effect.map((p) => ({ id: p.id }))), + ), + ); + return yield* body(host.id).pipe( + Effect.ensuring( + projects + .deleteProject({ idOrName: host.id, ...team }) + .pipe(Effect.ignore), + ), + Effect.ensuring( + Effect.gen(function* () { + const t = yield* teamScope; + yield* domains.deleteDomain({ domain: apexDomain, ...t }).pipe( + Effect.catchTag("NotFound", () => Effect.void), + Effect.ignore, + ); + }), + ), + ); + }); + +/** Out-of-band: find the project domain row by name via the list endpoint. */ +const findProjectDomain = (projectId: string, name: string) => + Effect.gen(function* () { + const team = yield* teamScope; + const body = yield* projects.getProjectDomains({ + idOrName: projectId, + limit: 100, + ...team, + }); + return body.domains.find((d) => d.name === name); + }); + +test.provider( + "project domain lifecycle: attach, sync redirect, detach", + (stack) => + withHostProject( + "alchemy-test-domains-pd-life", + "alchemy-vercel-test-pd.example", + (projectId) => + Effect.gen(function* () { + yield* stack.destroy(); + + const APP = "app.alchemy-vercel-test-pd.example"; + const WWW = "www.alchemy-vercel-test-pd.example"; + + const deployPair = (withRedirect: boolean) => + stack.deploy( + Effect.gen(function* () { + const www = yield* Vercel.ProjectDomain("Www", { + project: projectId, + name: WWW, + }); + const app = yield* Vercel.ProjectDomain("App", { + project: projectId, + name: APP, + ...(withRedirect + ? { redirect: WWW, redirectStatusCode: 308 as const } + : {}), + }); + return { app, www }; + }), + ); + + const created = yield* deployPair(false); + expect(created.app.projectId).toEqual(projectId); + expect(created.app.name).toEqual(APP); + expect(created.app.apexName).toEqual( + "alchemy-vercel-test-pd.example", + ); + // The apex was auto-added to the same team, so the attachment + // verifies immediately. + expect(created.app.verified).toEqual(true); + expect(created.app.redirect).toBeUndefined(); + + // Out-of-band verification via distilled. + const observed = yield* findProjectDomain(projectId, APP); + expect(observed).toBeDefined(); + expect(observed!.verified).toEqual(true); + + // Sync: add a redirect from app -> www. + const updated = yield* deployPair(true); + expect(updated.app.redirect).toEqual(WWW); + expect(updated.app.redirectStatusCode).toEqual(308); + + const observedRedirect = yield* findProjectDomain(projectId, APP); + expect(observedRedirect!.redirect).toEqual(WWW); + expect(observedRedirect!.redirectStatusCode).toEqual(308); + + // Redeploy the same shape — converges without churn. + const noop = yield* deployPair(true); + expect(noop.app.redirect).toEqual(WWW); + + yield* stack.destroy(); + + // Both attachments are detached (bounded wait). + const gone = yield* Effect.gen(function* () { + const app = yield* findProjectDomain(projectId, APP); + const www = yield* findProjectDomain(projectId, WWW); + return app === undefined && www === undefined + ? ("gone" as const) + : ("found" as const); + }).pipe( + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (s) => s === "gone", + times: 10, + }), + ); + expect(gone).toEqual("gone"); + + // Destroy again — delete path is idempotent. + yield* stack.destroy(); + }), + ).pipe(logLevel), + { timeout: 120_000 }, +); + +test.provider( + "replaces the attachment when the domain name changes", + (stack) => + withHostProject( + "alchemy-test-domains-pd-repl", + "alchemy-vercel-test-pdr.example", + (projectId) => + Effect.gen(function* () { + yield* stack.destroy(); + + const NAME_A = "a.alchemy-vercel-test-pdr.example"; + const NAME_B = "b.alchemy-vercel-test-pdr.example"; + + const deployName = (name: string) => + stack.deploy( + Vercel.ProjectDomain("Attached", { project: projectId, name }), + ); + + const created = yield* deployName(NAME_A); + expect(created.name).toEqual(NAME_A); + + const replaced = yield* deployName(NAME_B); + expect(replaced.name).toEqual(NAME_B); + + const onB = yield* findProjectDomain(projectId, NAME_B); + expect(onB).toBeDefined(); + const onA = yield* findProjectDomain(projectId, NAME_A); + expect(onA).toBeUndefined(); + + yield* stack.destroy(); + const afterDestroy = yield* findProjectDomain(projectId, NAME_B); + expect(afterDestroy).toBeUndefined(); + }), + ).pipe(logLevel), + { timeout: 120_000 }, +); diff --git a/packages/alchemy/test/Vercel/Drains/Drain.test.ts b/packages/alchemy/test/Vercel/Drains/Drain.test.ts new file mode 100644 index 0000000000..5969736959 --- /dev/null +++ b/packages/alchemy/test/Vercel/Drains/Drain.test.ts @@ -0,0 +1,209 @@ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as drains from "@distilled.cloud/vercel/drains"; +import * as projects from "@distilled.cloud/vercel/projects"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +const teamScope = Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + return teamId !== undefined ? { teamId } : {}; +}); + +/** Out-of-band read via distilled; `undefined` = drain does not exist. */ +const getDrain = (id: string) => + Effect.gen(function* () { + const team = yield* teamScope; + return yield* drains.getDrain({ id, ...team }).pipe( + Effect.map((drain): drains.GetDrainResponse | undefined => drain), + Effect.catchTag("NotFound", () => Effect.succeed(undefined)), + ); + }); + +test.provider("drain lifecycle: create, no-op redeploy, destroy", (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const props: Vercel.DrainProps = { + schemas: { log: { version: "v1" } }, + delivery: { + type: "http", + // Vercel validates the endpoint URL (must resolve) but does not + // verify delivery, so a well-known host works as a sink. + endpoint: "https://example.com/alchemy/drain-lifecycle", + encoding: "json", + }, + }; + + const created = yield* stack.deploy(Vercel.Drain("Logs", props)); + + expect(created.drainId).toMatch(/^drn_/); + // Engine-generated deterministic physical name. + expect(created.drainName.length).toBeGreaterThan(0); + expect(created.status).toEqual("enabled"); + expect(created.ownerId).toBeDefined(); + expect(created.projectIds).toBeUndefined(); + + // Out-of-band verification via distilled. + const fetched = yield* getDrain(created.drainId); + expect(fetched).toBeDefined(); + expect(fetched!.name).toEqual(created.drainName); + expect(fetched!.source).toMatchObject({ kind: "self-served" }); + expect(fetched!.delivery).toMatchObject({ + type: "http", + endpoint: "https://example.com/alchemy/drain-lifecycle", + encoding: "json", + }); + expect(fetched!.filterV2).toBeUndefined(); + + // Same props again is a true no-op: same id AND untouched updatedAt + // (a PATCH would bump it). + const noop = yield* stack.deploy(Vercel.Drain("Logs", props)); + expect(noop.drainId).toEqual(created.drainId); + expect(noop.drainName).toEqual(created.drainName); + expect(noop.updatedAt).toEqual(created.updatedAt); + + yield* stack.destroy(); + expect(yield* getDrain(created.drainId)).toBeUndefined(); + + // Destroy again — delete path is idempotent. + yield* stack.destroy(); + }).pipe(logLevel), +); + +test.provider( + "update in place: filter, sampling, delivery, and status deltas", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const created = yield* stack.deploy( + Vercel.Drain("Updating", { + name: "alchemy-test-drain-update", + schemas: { log: { version: "v1" } }, + filter: { type: "basic", log: { sources: ["lambda"] } }, + sampling: [{ rate: 0.5 }], + delivery: { + type: "http", + endpoint: "https://example.com/alchemy/drain-update", + encoding: "json", + }, + }), + ); + + expect(created.drainName).toEqual("alchemy-test-drain-update"); + const before = yield* getDrain(created.drainId); + expect(before!.filterV2?.filter).toMatchObject({ + type: "basic", + log: { sources: ["lambda"] }, + }); + expect(before!.sampling).toMatchObject([ + { type: "head_sampling", rate: 0.5 }, + ]); + + // Widen the filter, drop sampling, switch encoding, pause the drain — + // all PATCH deltas on the same drain (no replacement). + const updated = yield* stack.deploy( + Vercel.Drain("Updating", { + name: "alchemy-test-drain-update", + schemas: { log: { version: "v1" } }, + filter: { type: "basic", log: { sources: ["lambda", "edge"] } }, + delivery: { + type: "http", + endpoint: "https://example.com/alchemy/drain-update", + encoding: "ndjson", + }, + status: "disabled", + }), + ); + + expect(updated.drainId).toEqual(created.drainId); + expect(updated.status).toEqual("disabled"); + + const after = yield* getDrain(created.drainId); + expect(after!.filterV2?.filter).toMatchObject({ + type: "basic", + log: { sources: ["lambda", "edge"] }, + }); + expect(after!.sampling ?? []).toEqual([]); + expect(after!.delivery).toMatchObject({ + type: "http", + encoding: "ndjson", + }); + expect(after!.status).toEqual("disabled"); + + yield* stack.destroy(); + expect(yield* getDrain(created.drainId)).toBeUndefined(); + }).pipe(logLevel), +); + +test.provider("drain scoped to a specific project", (stack) => + Effect.gen(function* () { + // Host project created out-of-band via distilled (deterministic name so + // an interrupted run reuses it); removed again in `ensuring` below. + const hostName = "alchemy-test-drain-host"; + const team = yield* teamScope; + const host = yield* projects + .createProject({ name: hostName, ...team }) + .pipe( + Effect.map((p) => ({ id: p.id })), + Effect.catchTag("Conflict", () => + projects + .getProject({ idOrName: hostName, ...team }) + .pipe(Effect.map((p) => ({ id: p.id }))), + ), + ); + + yield* Effect.gen(function* () { + yield* stack.destroy(); + + const scoped = yield* stack.deploy( + Vercel.Drain("Scoped", { + projects: "some", + projectIds: [host.id], + schemas: { log: { version: "v1" } }, + delivery: { + type: "http", + endpoint: "https://example.com/alchemy/drain-scoped", + encoding: "json", + }, + }), + ); + + expect(scoped.projectIds).toEqual([host.id]); + const fetched = yield* getDrain(scoped.drainId); + expect([...(fetched!.projectIds ?? [])]).toEqual([host.id]); + + // Back to all projects — PATCH { projects: "all", projectIds: null }. + const widened = yield* stack.deploy( + Vercel.Drain("Scoped", { + schemas: { log: { version: "v1" } }, + delivery: { + type: "http", + endpoint: "https://example.com/alchemy/drain-scoped", + encoding: "json", + }, + }), + ); + expect(widened.drainId).toEqual(scoped.drainId); + expect(widened.projectIds ?? []).toEqual([]); + + yield* stack.destroy(); + expect(yield* getDrain(scoped.drainId)).toBeUndefined(); + }).pipe( + Effect.ensuring( + projects + .deleteProject({ idOrName: host.id, ...team }) + .pipe(Effect.ignore), + ), + ); + }).pipe(logLevel), +); diff --git a/packages/alchemy/test/Vercel/EdgeCache/Purge.test.ts b/packages/alchemy/test/Vercel/EdgeCache/Purge.test.ts new file mode 100644 index 0000000000..2c10bd4de2 --- /dev/null +++ b/packages/alchemy/test/Vercel/EdgeCache/Purge.test.ts @@ -0,0 +1,90 @@ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as projects from "@distilled.cloud/vercel/projects"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Result from "effect/Result"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +// Deterministic out-of-band probe project — same name on every run. +const PROBE_PROJECT = "alchemy-test-edge-cache"; + +const teamScopeOf = Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + return teamId; +}); + +const ensureProbeProject = Effect.gen(function* () { + const teamId = yield* teamScopeOf; + return yield* projects.getProject({ idOrName: PROBE_PROJECT, teamId }).pipe( + Effect.map((p) => p.id), + Effect.catchTag("NotFound", () => + projects + .createProject({ name: PROBE_PROJECT, teamId }) + .pipe(Effect.map((p) => p.id)), + ), + ); +}); + +const deleteProbeProject = Effect.gen(function* () { + const teamId = yield* teamScopeOf; + yield* projects + .deleteProject({ idOrName: PROBE_PROJECT, teamId }) + .pipe(Effect.catchTag("NotFound", () => Effect.void)); +}); + +test.provider( + "purge actions succeed against a live project", + () => + Effect.gen(function* () { + const projectId = yield* ensureProbeProject; + + // Soft invalidation by tag — accepts both a single tag and a list. + yield* Vercel.invalidateEdgeCacheByTags(projectId, "alchemy-test-tag"); + yield* Vercel.invalidateEdgeCacheByTags( + { projectId }, + ["alchemy-test-tag-a", "alchemy-test-tag-b"], + { target: "production" }, + ); + + // Hard delete by tag with a revalidation deadline. + yield* Vercel.dangerouslyDeleteEdgeCacheByTags( + projectId, + "alchemy-test-tag", + { revalidationDeadlineSeconds: 60 }, + ); + + // Source-image variants. + yield* Vercel.invalidateEdgeCacheBySrcImages(projectId, [ + "https://example.com/alchemy-test.png", + ]); + yield* Vercel.dangerouslyDeleteEdgeCacheBySrcImages(projectId, [ + "https://example.com/alchemy-test.png", + ]); + }).pipe(Effect.ensuring(deleteProbeProject.pipe(Effect.ignore)), logLevel), + { timeout: 120_000 }, +); + +test.provider( + "purge against a nonexistent project fails with typed NotFound", + () => + Effect.gen(function* () { + const purged = yield* Effect.result( + Vercel.invalidateEdgeCacheByTags( + "prj_nonexistent000000000000000", + "alchemy-test-tag", + ), + ); + expect(Result.isFailure(purged)).toBe(true); + if (Result.isFailure(purged)) { + expect(purged.failure._tag).toBe("NotFound"); + } + }).pipe(logLevel), +); diff --git a/packages/alchemy/test/Vercel/EdgeConfig/EdgeConfig.local.test.ts b/packages/alchemy/test/Vercel/EdgeConfig/EdgeConfig.local.test.ts new file mode 100644 index 0000000000..b7045c6dc1 --- /dev/null +++ b/packages/alchemy/test/Vercel/EdgeConfig/EdgeConfig.local.test.ts @@ -0,0 +1,365 @@ +/** + * Vercel Edge Config dev-mode (`alchemy dev`) tests — the registry-style + * local providers per the Local-tests doctrine: `dev: true` runs local + * providers behind the RPC sidecar proxy by default, matching the process + * topology of the real `alchemy dev` command. + * + * Covers (a) the local roundtrip through a locally running Function binding + * `ReadEdgeConfig` (Effect mode) plus the async channel + * (`readEdgeConfigFromEnv` over an explicit local `EdgeConfigToken`), with + * `dev:` identity markers; (b) the RAW data-plane protocol against the + * sidecar endpoint — the exact contract probed against + * `@vercel/edge-config@1.5.1` (missing item = 404 WITH the + * `x-edge-config-digest` header, bare 404 = config-not-found, bearer auth, + * 401 on a bad token); (c) registry live-sync — an items-only redeploy is + * served immediately without restarting the consuming Function; and (d) + * the mixed stack: an `Alchemy.remote()` Edge Config runs LIVE during dev + * (real `ecfg_` id, real token minted by the delegating local provider) + * alongside a fully local sibling, verified out-of-band via distilled and + * gone after destroy. + */ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as globalConfig from "@distilled.cloud/vercel/global_config"; +import { expect } from "alchemy-test"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import LocalEdgeFn from "./fixtures/local-edge-config-fn.ts"; +import { LOCAL_FIXTURE_ITEMS, LocalFlags } from "./fixtures/local-flags.ts"; +import MixedEdgeFn, { + MIXED_LIVE_ITEMS, + MIXED_LOCAL_ITEMS, + MixedLiveFlags, + MixedLocalFlags, +} from "./fixtures/mixed-edge-config-fn.ts"; + +const { test } = Test.make({ + providers: Vercel.providers(), + dev: true, +}); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +const asyncFixtureMain = new URL( + "./fixtures/async-edge-config-fn.ts", + import.meta.url, +).pathname; + +class NotReady extends Data.TaggedError("NotReady")<{ status: number }> {} + +// The first request races the dev bundle's first build — retry, bounded. +const readiness = Schedule.max([ + Schedule.min([ + Schedule.exponential("500 millis"), + Schedule.spaced("2 seconds"), + ]), + Schedule.recurs(45), +]); + +const getJsonReady = (url: string) => + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const res = yield* client.get(url).pipe( + Effect.flatMap((res) => + res.status === 200 + ? Effect.succeed(res) + : Effect.fail(new NotReady({ status: res.status })), + ), + Effect.retry({ + while: (e): e is NotReady => e instanceof NotReady, + schedule: readiness, + }), + ); + return yield* res.json; + }).pipe(Effect.orDie); + +/** Poll a JSON route (bounded) until the body matches. */ +const getJsonUntil = (url: string, until: (body: A) => boolean) => + getJsonReady(url).pipe( + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (body) => until(body as A), + times: 20, + }), + Effect.map((body) => body as A), + ); + +/** A raw data-plane request with bearer auth (the SDK's request shape). */ +const raw = ( + url: string, + options?: { token?: string; method?: "GET" | "HEAD" }, +) => + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + let request = HttpClientRequest.make(options?.method ?? "GET")(url).pipe( + HttpClientRequest.appendUrlParam("version", "1"), + ); + if (options?.token !== undefined) { + request = HttpClientRequest.bearerToken(request, options.token); + } + const response = yield* client.execute(request); + const body = yield* response.text; + return { + status: response.status, + digestHeader: response.headers["x-edge-config-digest"], + body, + }; + }); + +test.provider( + "local roundtrip: dev markers, effect + async clients, raw protocol, live item sync", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const deploy = (items: Record) => + stack.deploy( + Effect.gen(function* () { + const fn = yield* LocalEdgeFn; + const flags = yield* LocalFlags; + // A standalone config + explicit token: drives the raw + // protocol probes and the async-channel Function, and its + // varying `items` pin registry live-sync across deploys. + const standalone = yield* Vercel.EdgeConfig("StandaloneFlags", { + items, + }); + const token = yield* Vercel.EdgeConfigToken( + "StandaloneFlagsToken", + { edgeConfigId: standalone.edgeConfigId }, + ); + const asyncFn = yield* Vercel.Function("AsyncLocalEdgeFn", { + main: asyncFixtureMain, + env: { FLAGS: token.connectionString }, + }); + return { fn, flags, standalone, token, asyncFn }; + }), + ); + + const first = yield* deploy({ greeting: "standalone-hello", n: 1 }); + + // ── dev identity markers — proof no cloud call ran. + expect(first.flags.edgeConfigId).toMatch(/^dev:ecfg_/); + expect(first.flags.ownerId).toBe("dev"); + expect(first.standalone.edgeConfigId).toMatch(/^dev:ecfg_/); + expect(first.token.tokenId).toMatch(/^dev:/); + expect(Redacted.value(first.token.token)).toMatch(/^dev:ectok_/); + expect(first.fn.url).toMatch(/^http:\/\/localhost:\d+$/); + expect(first.asyncFn.projectId).toMatch(/^dev:/); + const connection = Redacted.value(first.token.connectionString); + expect(connection).toMatch(/^http:\/\/localhost:\d+\/dev:ecfg_/); + + // ── Effect client through the locally running Function. + const greeting = (yield* getJsonReady( + `${first.fn.url}/item/greeting`, + )) as { value: unknown }; + expect(greeting.value).toEqual(LOCAL_FIXTURE_ITEMS.greeting); + const missing = (yield* getJsonReady(`${first.fn.url}/item/nope`)) as { + value: unknown; + }; + expect(missing.value).toBeNull(); + const hasIt = (yield* getJsonReady( + `${first.fn.url}/has/enableCheckout`, + )) as { has: boolean }; + expect(hasIt.has).toBe(true); + const hasNot = (yield* getJsonReady(`${first.fn.url}/has/nope`)) as { + has: boolean; + }; + expect(hasNot.has).toBe(false); + const all = (yield* getJsonReady(`${first.fn.url}/all`)) as { + items: Record; + }; + expect(all.items).toEqual(LOCAL_FIXTURE_ITEMS); + const some = (yield* getJsonReady(`${first.fn.url}/some`)) as { + items: Record; + }; + expect(some.items).toEqual({ + greeting: LOCAL_FIXTURE_ITEMS.greeting, + enableCheckout: LOCAL_FIXTURE_ITEMS.enableCheckout, + }); + const digest = (yield* getJsonReady(`${first.fn.url}/digest`)) as { + digest: string; + }; + expect(digest.digest).toEqual(first.flags.digest); + + // ── Async channel: `readEdgeConfigFromEnv` (plain fetch, the SDK's + // protocol semantics) against the explicit local token. + const asyncGreeting = (yield* getJsonReady( + `${first.asyncFn.url}/item/greeting`, + )) as { value: unknown }; + expect(asyncGreeting.value).toEqual("standalone-hello"); + const asyncAll = (yield* getJsonReady(`${first.asyncFn.url}/all`)) as { + items: Record; + }; + expect(asyncAll.items).toEqual({ greeting: "standalone-hello", n: 1 }); + const asyncDigest = (yield* getJsonReady( + `${first.asyncFn.url}/digest`, + )) as { digest: string }; + expect(asyncDigest.digest).toEqual(first.standalone.digest); + + // ── RAW protocol against the sidecar data plane (the probe-verified + // `@vercel/edge-config` contract). + const { baseUrl, token } = + Vercel.parseEdgeConfigConnectionString(connection); + const origin = new URL(baseUrl).origin; + + // Present item: 200 + digest header. + const present = yield* raw(`${baseUrl}/item/greeting`, { token }); + expect(present.status).toBe(200); + expect(JSON.parse(present.body)).toEqual("standalone-hello"); + expect(present.digestHeader).toEqual(first.standalone.digest); + + // Missing ITEM: 404 *with* the digest header (SDK ⇒ undefined). + const missingItem = yield* raw(`${baseUrl}/item/nope`, { token }); + expect(missingItem.status).toBe(404); + expect(missingItem.digestHeader).toEqual(first.standalone.digest); + + // HEAD: 200 for present, 404 + digest for missing. + const headPresent = yield* raw(`${baseUrl}/item/greeting`, { + token, + method: "HEAD", + }); + expect(headPresent.status).toBe(200); + const headMissing = yield* raw(`${baseUrl}/item/nope`, { + token, + method: "HEAD", + }); + expect(headMissing.status).toBe(404); + expect(headMissing.digestHeader).toEqual(first.standalone.digest); + + // Item subset + digest endpoints. + const subset = yield* raw(`${baseUrl}/items?key=greeting`, { token }); + expect(subset.status).toBe(200); + expect(JSON.parse(subset.body)).toEqual({ + greeting: "standalone-hello", + }); + const digestRaw = yield* raw(`${baseUrl}/digest`, { token }); + expect(digestRaw.status).toBe(200); + expect(JSON.parse(digestRaw.body)).toEqual(first.standalone.digest); + + // Bad token: 401. Unknown config: BARE 404 (no digest header) — the + // SDK's "config not found" throw signal. + const badToken = yield* raw(`${baseUrl}/item/greeting`, { + token: "dev:ectok_wrong", + }); + expect(badToken.status).toBe(401); + const unknownConfig = yield* raw( + `${origin}/dev:ecfg_does-not-exist/item/greeting`, + { token }, + ); + expect(unknownConfig.status).toBe(404); + expect(unknownConfig.digestHeader).toBeUndefined(); + + // ── Registry live-sync: an items-only redeploy converges the served + // data WITHOUT re-minting the token or restarting the Functions. + const second = yield* deploy({ greeting: "standalone-bonjour", n: 2 }); + expect(second.standalone.edgeConfigId).toEqual( + first.standalone.edgeConfigId, + ); + expect(second.standalone.digest).not.toEqual(first.standalone.digest); + expect(Redacted.value(second.token.token)).toEqual( + Redacted.value(first.token.token), + ); + // Same connection string ⇒ unchanged env ⇒ the async Function did + // not restart (its dev deployment id is the config hash). + expect(second.asyncFn.deploymentId).toEqual(first.asyncFn.deploymentId); + expect(second.asyncFn.url).toEqual(first.asyncFn.url); + const updated = yield* raw(`${baseUrl}/item/greeting`, { token }); + expect(JSON.parse(updated.body)).toEqual("standalone-bonjour"); + const updatedThroughFn = yield* getJsonUntil<{ value: unknown }>( + `${second.asyncFn.url}/item/greeting`, + (body) => body.value === "standalone-bonjour", + ); + expect(updatedThroughFn.value).toEqual("standalone-bonjour"); + + yield* stack.destroy(); + + // The registry row is gone: the data plane now answers a bare 404. + const afterDestroy = yield* raw(`${baseUrl}/item/greeting`, { token }); + expect(afterDestroy.status).toBe(404); + expect(afterDestroy.digestHeader).toBeUndefined(); + }).pipe(logLevel), + { timeout: 240_000 }, +); + +test.provider( + "Alchemy.remote() Edge Config runs live in dev alongside a local one", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + const { teamId } = yield* Vercel.VercelEnvironment.current; + + const deployed = yield* stack.deploy( + Effect.gen(function* () { + const fn = yield* MixedEdgeFn; + const localFlags = yield* MixedLocalFlags; + const liveFlags = yield* MixedLiveFlags; + return { fn, localFlags, liveFlags }; + }), + ); + + // The default config is emulated; the remote() one is real — and the + // Function itself runs locally. + expect(deployed.localFlags.edgeConfigId).toMatch(/^dev:ecfg_/); + expect(deployed.liveFlags.edgeConfigId).toMatch(/^ecfg_/); + expect(deployed.fn.url).toMatch(/^http:\/\/localhost:\d+$/); + expect(deployed.fn.projectId).toMatch(/^dev:/); + + // Both bindings round-trip through the SAME locally running Function. + const local = (yield* getJsonReady( + `${deployed.fn.url}/local/item/source`, + )) as { value: unknown }; + expect(local.value).toEqual(MIXED_LOCAL_ITEMS.source); + // The live data plane is eventually consistent after item writes. + const live = yield* getJsonUntil<{ value: unknown }>( + `${deployed.fn.url}/live/item/source`, + (body) => body.value === MIXED_LIVE_ITEMS.source, + ); + expect(live.value).toEqual(MIXED_LIVE_ITEMS.source); + + // Out-of-band via distilled: the remote() config exists on real + // Vercel with the declared items, and the delegating local token + // provider minted exactly one REAL read token for it. + const items = yield* globalConfig.getEdgeConfigItems({ + edgeConfigId: deployed.liveFlags.edgeConfigId, + teamId, + }); + expect( + Object.fromEntries(items.map((item) => [item.key, item.value])), + ).toEqual(MIXED_LIVE_ITEMS); + const tokens = yield* globalConfig.getEdgeConfigTokens({ + edgeConfigId: deployed.liveFlags.edgeConfigId, + teamId, + }); + expect(tokens.length).toEqual(1); + + yield* stack.destroy(); + + // The live config was deleted from the cloud on destroy (its state + // row is stamped live, so the live provider handles the delete even + // in a dev run — and the delegated token delete rode the cascade). + const gone = yield* globalConfig + .getEdgeConfig({ + edgeConfigId: deployed.liveFlags.edgeConfigId, + teamId, + }) + .pipe( + Effect.as(false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (g) => g, + times: 10, + }), + ); + expect(gone).toBe(true); + }).pipe(logLevel), + { timeout: 240_000 }, +); diff --git a/packages/alchemy/test/Vercel/EdgeConfig/EdgeConfig.test.ts b/packages/alchemy/test/Vercel/EdgeConfig/EdgeConfig.test.ts new file mode 100644 index 0000000000..215590db31 --- /dev/null +++ b/packages/alchemy/test/Vercel/EdgeConfig/EdgeConfig.test.ts @@ -0,0 +1,295 @@ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as globalConfig from "@distilled.cloud/vercel/global_config"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +const currentTeamId = Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + return teamId; +}); + +/** Read the config's items out-of-band as a plain record. */ +const fetchItems = (edgeConfigId: string, teamId: string | undefined) => + globalConfig + .getEdgeConfigItems({ edgeConfigId, teamId }) + .pipe( + Effect.map((items) => + Object.fromEntries(items.map((item) => [item.key, item.value])), + ), + ); + +/** Bounded typed wait until the config is gone. */ +const waitUntilGone = (edgeConfigId: string, teamId: string | undefined) => + Effect.gen(function* () { + const state = yield* globalConfig + .getEdgeConfig({ edgeConfigId, teamId }) + .pipe( + Effect.as("found" as const), + Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)), + Effect.repeat({ + schedule: Schedule.spaced("1 second"), + until: (s) => s === "gone", + times: 8, + }), + ); + expect(state).toEqual("gone"); + }); + +/** Read one item from the Edge Config data plane (edge-config.vercel.com). */ +const readDataPlaneItem = (edgeConfigId: string, token: string, key: string) => + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const response = yield* client.execute( + HttpClientRequest.get( + `https://edge-config.vercel.com/${edgeConfigId}/item/${key}`, + ).pipe(HttpClientRequest.setHeader("Authorization", `Bearer ${token}`)), + ); + const text = yield* response.text; + return { status: response.status, text }; + }); + +test.provider( + "create, update, and delete an Edge Config with declarative items", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + const teamId = yield* currentTeamId; + + const created = yield* stack.deploy( + Vercel.EdgeConfig("Lifecycle", { + items: { + greeting: "hello", + flags: { beta: true, limit: 10 }, + }, + }), + ); + + expect(created.edgeConfigId).toMatch(/^ecfg_/); + expect(created.slug).toBeDefined(); + expect(created.digest).toBeDefined(); + expect(created.itemCount).toEqual(2); + + // Out-of-band verification via distilled (management API). + const observed = yield* globalConfig.getEdgeConfig({ + edgeConfigId: created.edgeConfigId, + teamId, + }); + expect(observed.slug).toEqual(created.slug); + const items = yield* fetchItems(created.edgeConfigId, teamId); + expect(items).toEqual({ + greeting: "hello", + flags: { beta: true, limit: 10 }, + }); + + // Update: change one value, add a key, remove a key. + const updated = yield* stack.deploy( + Vercel.EdgeConfig("Lifecycle", { + items: { + greeting: "hi", + newKey: [1, 2, 3], + }, + }), + ); + // Same physical config — items updates never replace. + expect(updated.edgeConfigId).toEqual(created.edgeConfigId); + expect(updated.slug).toEqual(created.slug); + const updatedItems = yield* fetchItems(created.edgeConfigId, teamId); + expect(updatedItems).toEqual({ + greeting: "hi", + newKey: [1, 2, 3], + }); + + // No-op redeploy converges without churn. + const noop = yield* stack.deploy( + Vercel.EdgeConfig("Lifecycle", { + items: { + greeting: "hi", + newKey: [1, 2, 3], + }, + }), + ); + expect(noop.edgeConfigId).toEqual(created.edgeConfigId); + expect(noop.digest).toEqual(updated.digest); + + yield* stack.destroy(); + yield* waitUntilGone(created.edgeConfigId, teamId); + }).pipe(logLevel), + { timeout: 120_000 }, +); + +test.provider( + "out-of-band item drift converges on redeploy", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + const teamId = yield* currentTeamId; + + const config = yield* stack.deploy( + Vercel.EdgeConfig("Drift", { + items: { a: "1", b: "2" }, + }), + ); + + // Drift out-of-band: overwrite a managed key and add a foreign one. + yield* globalConfig.patchEdgeConfigItems({ + edgeConfigId: config.edgeConfigId, + teamId, + items: [ + { operation: "upsert", key: "a", value: "drifted" }, + { operation: "upsert", key: "c", value: "foreign" }, + ], + }); + const drifted = yield* fetchItems(config.edgeConfigId, teamId); + expect(drifted).toEqual({ a: "drifted", b: "2", c: "foreign" }); + + // Redeploy with one declared change. (An identical declaration + // short-circuits at plan time — "no changes" — so reconcile only runs + // when props differ.) The item sync diffs OBSERVED cloud items against + // the declaration, so it must also revert the drifted key and delete + // the foreign one. + const redeployed = yield* stack.deploy( + Vercel.EdgeConfig("Drift", { + items: { a: "1", b: "3" }, + }), + ); + expect(redeployed.edgeConfigId).toEqual(config.edgeConfigId); + const converged = yield* fetchItems(config.edgeConfigId, teamId); + expect(converged).toEqual({ a: "1", b: "3" }); + + yield* stack.destroy(); + yield* waitUntilGone(config.edgeConfigId, teamId); + }).pipe(logLevel), + { timeout: 120_000 }, +); + +test.provider( + "rename in place and manage the schema lifecycle", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + const teamId = yield* currentTeamId; + + const schema = { + type: "object", + properties: { greeting: { type: "string" } }, + }; + const created = yield* stack.deploy( + Vercel.EdgeConfig("Rename", { + slug: "alchemy_test_ec_rename_a", + schema, + items: { greeting: "hello" }, + }), + ); + expect(created.slug).toEqual("alchemy_test_ec_rename_a"); + + const observedSchema = yield* globalConfig.getEdgeConfigSchema({ + edgeConfigId: created.edgeConfigId, + teamId, + }); + expect(observedSchema).toEqual({ definition: schema }); + + // Rename — slug is mutable via PUT, so the id must survive. + const renamed = yield* stack.deploy( + Vercel.EdgeConfig("Rename", { + slug: "alchemy_test_ec_rename_b", + schema, + items: { greeting: "hello" }, + }), + ); + expect(renamed.edgeConfigId).toEqual(created.edgeConfigId); + expect(renamed.slug).toEqual("alchemy_test_ec_rename_b"); + + // Drop the schema declaration — the provider deletes the stored schema. + yield* stack.deploy( + Vercel.EdgeConfig("Rename", { + slug: "alchemy_test_ec_rename_b", + items: { greeting: "hello" }, + }), + ); + const removedSchema = yield* globalConfig.getEdgeConfigSchema({ + edgeConfigId: created.edgeConfigId, + teamId, + }); + // Schema-less configs answer an empty 204, which decodes to `{}`. + expect(removedSchema).toEqual({}); + + yield* stack.destroy(); + yield* waitUntilGone(created.edgeConfigId, teamId); + }).pipe(logLevel), + { timeout: 120_000 }, +); + +test.provider( + "read token mints and the data plane serves items", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + const teamId = yield* currentTeamId; + + const config = yield* stack.deploy( + Vercel.EdgeConfig("DataPlane", { + items: { dataplane: "v1" }, + }), + ); + + // Mint a read token out-of-band (the once-disclosed plaintext token). + const minted = yield* globalConfig.createEdgeConfigToken({ + edgeConfigId: config.edgeConfigId, + teamId, + label: "alchemy-test-dataplane", + }); + expect(minted.token).toBeDefined(); + + // Data-plane read via the connection-string endpoint. ITEM writes + // propagate sub-second (probe B3: ~60ms), but a freshly minted TOKEN + // can answer 401 for ~20-30s before the data plane accepts it — + // bounded retry over that window. + const first = yield* readDataPlaneItem( + config.edgeConfigId, + minted.token, + "dataplane", + ).pipe( + Effect.repeat({ + schedule: Schedule.spaced("4 seconds"), + until: (r) => r.status === 200 && r.text === '"v1"', + times: 10, + }), + ); + expect(first.text).toEqual('"v1"'); + + // Update the item and confirm the data plane converges. + yield* stack.deploy( + Vercel.EdgeConfig("DataPlane", { + items: { dataplane: "v2" }, + }), + ); + const second = yield* readDataPlaneItem( + config.edgeConfigId, + minted.token, + "dataplane", + ).pipe( + Effect.repeat({ + schedule: Schedule.spaced("1 second"), + until: (r) => r.status === 200 && r.text === '"v2"', + times: 8, + }), + ); + expect(second.text).toEqual('"v2"'); + + yield* stack.destroy(); + yield* waitUntilGone(config.edgeConfigId, teamId); + }).pipe(logLevel), + { timeout: 120_000 }, +); diff --git a/packages/alchemy/test/Vercel/EdgeConfig/ReadEdgeConfig.test.ts b/packages/alchemy/test/Vercel/EdgeConfig/ReadEdgeConfig.test.ts new file mode 100644 index 0000000000..2555811e3c --- /dev/null +++ b/packages/alchemy/test/Vercel/EdgeConfig/ReadEdgeConfig.test.ts @@ -0,0 +1,295 @@ +/** + * EdgeConfigRead capability tests — live against the standing Vercel test + * team (run with the doppler alchemy-v2/dev env). + * + * Covers: the Effect-mode fixture binding an Edge Config via + * `Vercel.ReadEdgeConfig` + `ReadEdgeConfigHttp` (get / getAll / has / + * digest driven over HTTP via the production alias), the auto-minted + * `EdgeConfigToken` (observe-and-keep — redeploy stability), the sensitive + * connection-string env row, and the async-mode path (explicit + * `EdgeConfigToken` + `env:` binding + `readEdgeConfigFromEnv`). + */ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as globalConfig from "@distilled.cloud/vercel/global_config"; +import * as projects from "@distilled.cloud/vercel/projects"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import EdgeConfigFn from "./fixtures/edge-config-fn.ts"; +import { FIXTURE_ITEMS, Flags } from "./fixtures/flags.ts"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +const asyncFixtureMain = new URL( + "./fixtures/async-edge-config-fn.ts", + import.meta.url, +).pathname; + +// Fresh .vercel.app URLs take a few seconds to start serving 200s — always +// retry the first request (bounded). +const readiness = Schedule.max([ + Schedule.exponential("500 millis"), + Schedule.recurs(20), +]); + +const getJson = (url: string) => + HttpClient.get(url).pipe( + Effect.flatMap((response) => + response.status === 200 + ? response.json + : Effect.fail(new Error(`status ${response.status}`)), + ), + Effect.retry({ schedule: readiness }), + ); + +/** + * Data-plane reads are eventually consistent after item writes — poll a + * JSON route (bounded) until the body matches. + */ +const getJsonUntil = (url: string, until: (body: A) => boolean) => + getJson(url).pipe( + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (body) => until(body as A), + times: 15, + }), + Effect.map((body) => body as A), + ); + +/** Poll (bounded) until the function's project is gone. */ +const expectProjectGone = (projectId: string) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + const gone = yield* projects + .getProject({ idOrName: projectId, teamId }) + .pipe( + Effect.map(() => false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (g) => g, + times: 10, + }), + ); + expect(gone).toBe(true); + }); + +/** Poll (bounded) until the Edge Config is gone (cascades its tokens). */ +const expectEdgeConfigGone = (edgeConfigId: string) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + const gone = yield* globalConfig + .getEdgeConfig({ edgeConfigId, teamId }) + .pipe( + Effect.as(false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + Effect.repeat({ + schedule: Schedule.spaced("1 second"), + until: (g) => g, + times: 8, + }), + ); + expect(gone).toBe(true); + }); + +test.provider( + "effect mode: ReadEdgeConfig binding mints a token, injects a sensitive env row, and serves reads", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + const { teamId } = yield* Vercel.VercelEnvironment.current; + + const out = yield* stack.deploy( + Effect.gen(function* () { + const fn = yield* EdgeConfigFn; + const flags = yield* Flags; + return { fn, flags }; + }), + ); + expect(out.fn.url).toBeDefined(); + expect(out.flags.edgeConfigId).toMatch(/^ecfg_/); + + // 1. get — declared items round-trip through the deployed client + // (data plane is eventually consistent; bounded poll). + const greeting = yield* getJsonUntil<{ value: unknown }>( + `${out.fn.url}/item/greeting`, + (body) => body.value === FIXTURE_ITEMS.greeting, + ); + expect(greeting.value).toEqual(FIXTURE_ITEMS.greeting); + + // 2. get on a missing key is undefined (the 404+digest contract) — + // NOT an error. + const missing = (yield* getJson(`${out.fn.url}/item/nope`)) as { + value: unknown; + }; + expect(missing.value).toBeNull(); + + // 3. has — present and missing keys. + const hasIt = (yield* getJson(`${out.fn.url}/has/enableCheckout`)) as { + has: boolean; + }; + expect(hasIt.has).toBe(true); + const hasNot = (yield* getJson(`${out.fn.url}/has/nope`)) as { + has: boolean; + }; + expect(hasNot.has).toBe(false); + + // 4. getAll — full item set, and a subset. + const all = yield* getJsonUntil<{ items: Record }>( + `${out.fn.url}/all`, + (body) => Object.keys(body.items ?? {}).length === 3, + ); + expect(all.items).toEqual(FIXTURE_ITEMS); + const some = (yield* getJson(`${out.fn.url}/some`)) as { + items: Record; + }; + expect(some.items).toEqual({ + greeting: FIXTURE_ITEMS.greeting, + enableCheckout: FIXTURE_ITEMS.enableCheckout, + }); + + // 5. digest — the data plane converges on the management digest. + const digest = yield* getJsonUntil<{ digest: string }>( + `${out.fn.url}/digest`, + (body) => body.digest === out.flags.digest, + ); + expect(digest.digest).toEqual(out.flags.digest); + + // 6. The connection string landed as a SENSITIVE project env row + // (key = sanitized `.connectionString` capture). + const envs = yield* projects.filterProjectEnvs({ + idOrName: out.fn.projectId, + teamId, + decrypt: "true", + }); + const rows = ( + Array.isArray(envs) + ? envs + : typeof envs === "object" && envs !== null && "envs" in envs + ? envs.envs + : [] + ) as Array<{ key: string; type: string }>; + const row = rows.find( + (r) => r.key === "EdgeConfigFnFlagsReadToken_connectionString", + ); + expect(row).toBeDefined(); + expect(row!.type).toEqual("sensitive"); + + // 7. Exactly one read token was minted for the config (out-of-band + // via distilled — pins the getEdgeConfigTokens array patch too). + const tokens = yield* globalConfig.getEdgeConfigTokens({ + edgeConfigId: out.flags.edgeConfigId, + teamId, + }); + expect(tokens.length).toEqual(1); + expect(tokens[0]!.edgeConfigId).toEqual(out.flags.edgeConfigId); + + // 8. Churn regression: an identical redeploy re-observes (never + // re-mints) the token, so the env fingerprint — and therefore the + // deployment — must not change. + const redeployed = yield* stack.deploy( + Effect.gen(function* () { + const fn = yield* EdgeConfigFn; + const flags = yield* Flags; + return { fn, flags }; + }), + ); + expect(redeployed.fn.deploymentId).toEqual(out.fn.deploymentId); + const tokensAfter = yield* globalConfig.getEdgeConfigTokens({ + edgeConfigId: out.flags.edgeConfigId, + teamId, + }); + expect(tokensAfter.length).toEqual(1); + expect(tokensAfter[0]!.id).toEqual(tokens[0]!.id); + + // 9. Destroy cascades the project, the config, and its token. + yield* stack.destroy(); + yield* expectEdgeConfigGone(out.flags.edgeConfigId); + yield* expectProjectGone(out.fn.projectId); + }).pipe(logLevel), + { timeout: 180_000 }, +); + +test.provider( + "async mode: explicit EdgeConfigToken env binding + readEdgeConfigFromEnv client", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const program = Effect.gen(function* () { + const flags = yield* Vercel.EdgeConfig("AsyncFlags", { + items: { greeting: "async-hello", limit: 7 }, + }); + const token = yield* Vercel.EdgeConfigToken("AsyncFlagsToken", { + edgeConfigId: flags.edgeConfigId, + }); + const fn = yield* Vercel.Function("AsyncEdgeFn", { + main: asyncFixtureMain, + env: { + // Redacted attribute Output ⇒ synced as a sensitive env var. + FLAGS: token.connectionString, + }, + }); + return { flags, token, fn }; + }); + + const out = yield* stack.deploy(program); + expect(out.fn.url).toBeDefined(); + expect(out.token.tokenId).toBeDefined(); + expect(Redacted.value(out.token.connectionString)).toEqual( + `https://edge-config.vercel.com/${out.flags.edgeConfigId}?token=${Redacted.value(out.token.token)}`, + ); + + // 1. The env var arrived as the RAW connection string (async channel + // has no marker packing). + const env = yield* getJsonUntil<{ present: boolean; isUrl: boolean }>( + `${out.fn.url}/env`, + (body) => body.present, + ); + expect(env.isUrl).toBe(true); + + // 2. Reads through the promise client (bounded eventual-consistency + // poll). + const greeting = yield* getJsonUntil<{ value: unknown }>( + `${out.fn.url}/item/greeting`, + (body) => body.value === "async-hello", + ); + expect(greeting.value).toEqual("async-hello"); + const has = (yield* getJson(`${out.fn.url}/has/limit`)) as { + has: boolean; + }; + expect(has.has).toBe(true); + const all = yield* getJsonUntil<{ items: Record }>( + `${out.fn.url}/all`, + (body) => Object.keys(body.items ?? {}).length === 2, + ); + expect(all.items).toEqual({ greeting: "async-hello", limit: 7 }); + + // 3. Churn regression: identical redeploy keeps the token AND the + // deployment (observe-and-keep — no env fingerprint drift). + const redeployed = yield* stack.deploy(program); + expect(redeployed.token.tokenId).toEqual(out.token.tokenId); + expect(Redacted.value(redeployed.token.token)).toEqual( + Redacted.value(out.token.token), + ); + expect(redeployed.fn.deploymentId).toEqual(out.fn.deploymentId); + + // 4. Deleting the stack removes the token, config, and project. + // Destroy removes the token, config, and project. The standalone + // token row deletes cleanly even when the config cascade removed it + // first (idempotent NotFound catch) — destroy() fails otherwise. + yield* stack.destroy(); + yield* expectEdgeConfigGone(out.flags.edgeConfigId); + yield* expectProjectGone(out.fn.projectId); + }).pipe(logLevel), + { timeout: 180_000 }, +); diff --git a/packages/alchemy/test/Vercel/EdgeConfig/WriteEdgeConfig.test.ts b/packages/alchemy/test/Vercel/EdgeConfig/WriteEdgeConfig.test.ts new file mode 100644 index 0000000000..b33b20856f --- /dev/null +++ b/packages/alchemy/test/Vercel/EdgeConfig/WriteEdgeConfig.test.ts @@ -0,0 +1,215 @@ +/** + * EdgeConfigWrite capability tests — live against the standing Vercel test + * team (run with the doppler alchemy-v2/dev env). + * + * Two halves: + * + * 1. UNGATED platform probe — pins the live facts that make WriteEdgeConfig + * an explicit opt-in (probe 2026-08): `createAuthToken` mints real + * `scope: "project-only"` tokens, but Edge Configs are team-owned, so a + * project-scoped token cannot read them (typed `NotFound`), cannot write + * them (typed `NotFound`), and cannot mint read tokens (typed + * `Forbidden`). If this probe ever flips (Vercel ships a scoped write + * credential), revisit `WriteEdgeConfigHttp` to mint least-privilege + * tokens instead of requiring a user-supplied one. + * + * 2. GATED end-to-end fixture (`VERCEL_TEST_EDGE_CONFIG_WRITE=1`) — deploys + * a Function that binds `WriteEdgeConfig` with a user-supplied + * management token and drives set/delete over HTTP, verifying items + * out-of-band through the management API. + */ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as authentication from "@distilled.cloud/vercel/authentication"; +import { credentials } from "@distilled.cloud/vercel/Credentials"; +import * as globalConfig from "@distilled.cloud/vercel/global_config"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import WriteEdgeConfigFn, { + WriteFlags, +} from "./fixtures/write-edge-config-fn.ts"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +// Fresh .vercel.app URLs take a few seconds to start serving 200s. +const readiness = Schedule.max([ + Schedule.exponential("500 millis"), + Schedule.recurs(20), +]); + +const getJson = (url: string) => + HttpClient.get(url).pipe( + Effect.flatMap((response) => + response.status === 200 + ? response.json + : Effect.fail(new Error(`status ${response.status}`)), + ), + Effect.retry({ schedule: readiness }), + ); + +/** Read the config's items out-of-band as a plain record (management API). */ +const fetchItems = (edgeConfigId: string, teamId: string | undefined) => + globalConfig + .getEdgeConfigItems({ edgeConfigId, teamId }) + .pipe( + Effect.map((items) => + Object.fromEntries(items.map((item) => [item.key, item.value])), + ), + ); + +test.provider( + "platform probe: project-scoped tokens cannot touch team-owned Edge Configs (why writes are opt-in)", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + const { teamId } = yield* Vercel.VercelEnvironment.current; + + // A probe project to scope the token to, and a team-owned config the + // scoped token will be tested against — both stack-owned. + const out = yield* stack.deploy( + Effect.gen(function* () { + const project = yield* Vercel.Project("ScopedTokenProbe", {}); + const config = yield* Vercel.EdgeConfig("ScopedProbeConfig", { + items: { probe: "v1" }, + }); + return { project, config }; + }), + ); + + // Mint a project-scoped, expiring token — this WORKS: real scoping + // exists on the token-creation API. + const minted = yield* authentication.createAuthToken({ + name: "alchemy-test-ec-write-probe", + projectId: out.project.projectId, + expiresAt: Date.now() + 15 * 60 * 1000, + }); + const dropToken = authentication + .deleteAuthToken({ tokenId: minted.token.id }) + .pipe(Effect.ignore); + + yield* Effect.gen(function* () { + expect((minted.token as { scope?: string }).scope).toEqual( + "project-only", + ); + + const scopedLayer = Layer.merge( + credentials({ token: minted.bearerToken }), + FetchHttpClient.layer, + ); + + // The scoped token cannot even SEE the team-owned config… + const readTag = yield* globalConfig + .getEdgeConfig({ edgeConfigId: out.config.edgeConfigId, teamId }) + .pipe( + Effect.as("ok" as const), + Effect.catchTag("NotFound", () => + Effect.succeed("NotFound" as const), + ), + Effect.provide(scopedLayer), + ); + expect(readTag).toEqual("NotFound"); + + // …cannot WRITE it… + const writeTag = yield* globalConfig + .patchEdgeConfigItems({ + edgeConfigId: out.config.edgeConfigId, + teamId, + items: [{ operation: "upsert", key: "probe", value: "scoped" }], + }) + .pipe( + Effect.as("ok" as const), + Effect.catchTag("NotFound", () => + Effect.succeed("NotFound" as const), + ), + Effect.catchTag("Forbidden", () => + Effect.succeed("Forbidden" as const), + ), + Effect.provide(scopedLayer), + ); + expect(writeTag).toEqual("NotFound"); + + // …and cannot mint data-plane read tokens for it either. + const mintTag = yield* globalConfig + .createEdgeConfigToken({ + edgeConfigId: out.config.edgeConfigId, + teamId, + label: "alchemy-test-ec-write-probe", + }) + .pipe( + Effect.as("ok" as const), + Effect.catchTag("NotFound", () => + Effect.succeed("NotFound" as const), + ), + Effect.catchTag("Forbidden", () => + Effect.succeed("Forbidden" as const), + ), + Effect.provide(scopedLayer), + ); + expect(mintTag).toEqual("Forbidden"); + + // The write it was denied did not land. + const items = yield* fetchItems(out.config.edgeConfigId, teamId); + expect(items).toEqual({ probe: "v1" }); + }).pipe(Effect.ensuring(dropToken)); + + yield* stack.destroy(); + }).pipe(logLevel), + { timeout: 120_000 }, +); + +test.provider.skipIf(!process.env.VERCEL_TEST_EDGE_CONFIG_WRITE)( + "effect mode: WriteEdgeConfig opt-in binds a user-supplied token and writes converge", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + const { teamId } = yield* Vercel.VercelEnvironment.current; + + const out = yield* stack.deploy( + Effect.gen(function* () { + const fn = yield* WriteEdgeConfigFn; + const flags = yield* WriteFlags; + return { fn, flags }; + }), + ); + expect(out.fn.url).toBeDefined(); + expect(out.flags.edgeConfigId).toMatch(/^ecfg_/); + + // Runtime upsert through the deployed binding; management-API + // readback is strongly consistent, so no data-plane wait is needed. + const set = (yield* getJson( + `${out.fn.url}/set?key=banner&value=hello`, + )) as { ok: boolean }; + expect(set.ok).toBe(true); + const afterSet = yield* fetchItems(out.flags.edgeConfigId, teamId); + expect(afterSet).toEqual({ seeded: "v1", banner: "hello" }); + + // Batched patch: upsert one key, delete another. + const patched = (yield* getJson( + `${out.fn.url}/patch?key=mode&value=on&drop=banner`, + )) as { ok: boolean }; + expect(patched.ok).toBe(true); + const afterPatch = yield* fetchItems(out.flags.edgeConfigId, teamId); + expect(afterPatch).toEqual({ seeded: "v1", mode: "on" }); + + // Runtime delete. + const del = (yield* getJson(`${out.fn.url}/delete?key=mode`)) as { + ok: boolean; + }; + expect(del.ok).toBe(true); + const afterDelete = yield* fetchItems(out.flags.edgeConfigId, teamId); + expect(afterDelete).toEqual({ seeded: "v1" }); + + yield* stack.destroy(); + }).pipe(logLevel), + { timeout: 180_000 }, +); diff --git a/packages/alchemy/test/Vercel/EdgeConfig/fixtures/async-edge-config-fn.ts b/packages/alchemy/test/Vercel/EdgeConfig/fixtures/async-edge-config-fn.ts new file mode 100644 index 0000000000..6aed0a70ef --- /dev/null +++ b/packages/alchemy/test/Vercel/EdgeConfig/fixtures/async-edge-config-fn.ts @@ -0,0 +1,45 @@ +/** + * Async-mode Vercel Function fixture for the EdgeConfig read client: no + * Effect runtime — reads the connection string bound to the `FLAGS` env + * var with the promise-based `readEdgeConfigFromEnv` client. + */ +import { readEdgeConfigFromEnv } from "@/Vercel/EdgeConfig/EdgeConfigRead.ts"; + +const flags = readEdgeConfigFromEnv("FLAGS"); + +export default { + async fetch(request: Request): Promise { + const path = new URL(request.url).pathname; + try { + if (path.startsWith("/item/")) { + const key = decodeURIComponent(path.slice("/item/".length)); + return Response.json({ value: (await flags.get(key)) ?? null }); + } + if (path.startsWith("/has/")) { + const key = decodeURIComponent(path.slice("/has/".length)); + return Response.json({ has: await flags.has(key) }); + } + if (path.startsWith("/all")) { + return Response.json({ items: await flags.getAll() }); + } + if (path.startsWith("/digest")) { + return Response.json({ digest: await flags.digest() }); + } + // Raw env visibility: proves the sensitive row landed as the plain + // connection string (not marker-packed) on the async channel. + if (path.startsWith("/env")) { + const raw = process.env.FLAGS ?? null; + return Response.json({ + present: raw !== null, + isUrl: raw?.startsWith("https://edge-config.vercel.com/") ?? false, + }); + } + return Response.json({ ok: true }); + } catch (error) { + return Response.json( + { error: error instanceof Error ? error.message : String(error) }, + { status: 500 }, + ); + } + }, +}; diff --git a/packages/alchemy/test/Vercel/EdgeConfig/fixtures/edge-config-fn.ts b/packages/alchemy/test/Vercel/EdgeConfig/fixtures/edge-config-fn.ts new file mode 100644 index 0000000000..522ae185a7 --- /dev/null +++ b/packages/alchemy/test/Vercel/EdgeConfig/fixtures/edge-config-fn.ts @@ -0,0 +1,54 @@ +/** + * Effect-mode Vercel Function fixture for the EdgeConfigRead capability: + * declares an Edge Config with items, binds it via + * `Vercel.ReadEdgeConfig`, and exposes one HTTP route per client method + * (get / getAll / has / digest). + */ +import * as Vercel from "@/Vercel/index.ts"; +import * as Effect from "effect/Effect"; +import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import { Flags } from "./flags.ts"; + +export default class EdgeConfigFn extends Vercel.Function()( + "EdgeConfigFn", + { + main: import.meta.url, + }, + Effect.gen(function* () { + const flags = yield* Flags; + const config = yield* Vercel.ReadEdgeConfig(flags); + + return { + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; + const path = request.url.split("?")[0]!; + if (path.startsWith("/item/")) { + const key = decodeURIComponent(path.slice("/item/".length)); + const value = yield* config.get(key).pipe(Effect.orDie); + return yield* HttpServerResponse.json({ value: value ?? null }); + } + if (path.startsWith("/has/")) { + const key = decodeURIComponent(path.slice("/has/".length)); + const has = yield* config.has(key).pipe(Effect.orDie); + return yield* HttpServerResponse.json({ has }); + } + if (path.startsWith("/some")) { + const items = yield* config + .getAll(["greeting", "enableCheckout"]) + .pipe(Effect.orDie); + return yield* HttpServerResponse.json({ items }); + } + if (path.startsWith("/all")) { + const items = yield* config.getAll().pipe(Effect.orDie); + return yield* HttpServerResponse.json({ items }); + } + if (path.startsWith("/digest")) { + const digest = yield* config.digest().pipe(Effect.orDie); + return yield* HttpServerResponse.json({ digest }); + } + return yield* HttpServerResponse.json({ ok: true }); + }), + }; + }).pipe(Effect.provide(Vercel.ReadEdgeConfigHttp)), +) {} diff --git a/packages/alchemy/test/Vercel/EdgeConfig/fixtures/flags.ts b/packages/alchemy/test/Vercel/EdgeConfig/fixtures/flags.ts new file mode 100644 index 0000000000..7347c787c4 --- /dev/null +++ b/packages/alchemy/test/Vercel/EdgeConfig/fixtures/flags.ts @@ -0,0 +1,18 @@ +/** + * Shared Edge Config declaration — yielded by BOTH the Function fixture + * (to bind it) and the test's deploy program (to read its attributes): + * resource registration is FQN-idempotent, so both yields resolve to the + * same instance. + */ +import * as Vercel from "@/Vercel/index.ts"; + +/** The declarative item set under test (asserted by the test out-of-band). */ +export const FIXTURE_ITEMS = { + greeting: "hello", + enableCheckout: true, + limits: { maxItems: 3 }, +}; + +export const Flags = Vercel.EdgeConfig("Flags", { + items: { ...FIXTURE_ITEMS }, +}); diff --git a/packages/alchemy/test/Vercel/EdgeConfig/fixtures/local-edge-config-fn.ts b/packages/alchemy/test/Vercel/EdgeConfig/fixtures/local-edge-config-fn.ts new file mode 100644 index 0000000000..f527023aba --- /dev/null +++ b/packages/alchemy/test/Vercel/EdgeConfig/fixtures/local-edge-config-fn.ts @@ -0,0 +1,55 @@ +/** + * Effect-mode Vercel Function fixture for the dev-mode EdgeConfigRead + * roundtrip: binds the locally emulated {@link LocalFlags} config via + * `Vercel.ReadEdgeConfig` and exposes one HTTP route per client method + * (get / has / getAll / digest) — identical shape to the live fixture, so + * the dev data plane is exercised through the exact same client surface. + */ +import * as Vercel from "@/Vercel/index.ts"; +import * as Effect from "effect/Effect"; +import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import { LocalFlags } from "./local-flags.ts"; + +export default class LocalEdgeFn extends Vercel.Function()( + "LocalEdgeFn", + { + main: import.meta.url, + }, + Effect.gen(function* () { + const flags = yield* LocalFlags; + const config = yield* Vercel.ReadEdgeConfig(flags); + + return { + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; + const path = request.url.split("?")[0]!; + if (path.startsWith("/item/")) { + const key = decodeURIComponent(path.slice("/item/".length)); + const value = yield* config.get(key).pipe(Effect.orDie); + return yield* HttpServerResponse.json({ value: value ?? null }); + } + if (path.startsWith("/has/")) { + const key = decodeURIComponent(path.slice("/has/".length)); + const has = yield* config.has(key).pipe(Effect.orDie); + return yield* HttpServerResponse.json({ has }); + } + if (path.startsWith("/some")) { + const items = yield* config + .getAll(["greeting", "enableCheckout"]) + .pipe(Effect.orDie); + return yield* HttpServerResponse.json({ items }); + } + if (path.startsWith("/all")) { + const items = yield* config.getAll().pipe(Effect.orDie); + return yield* HttpServerResponse.json({ items }); + } + if (path.startsWith("/digest")) { + const digest = yield* config.digest().pipe(Effect.orDie); + return yield* HttpServerResponse.json({ digest }); + } + return yield* HttpServerResponse.json({ ok: true }); + }), + }; + }).pipe(Effect.provide(Vercel.ReadEdgeConfigHttp)), +) {} diff --git a/packages/alchemy/test/Vercel/EdgeConfig/fixtures/local-flags.ts b/packages/alchemy/test/Vercel/EdgeConfig/fixtures/local-flags.ts new file mode 100644 index 0000000000..66e5845b21 --- /dev/null +++ b/packages/alchemy/test/Vercel/EdgeConfig/fixtures/local-flags.ts @@ -0,0 +1,17 @@ +/** + * Shared Edge Config declaration for the dev-mode (`alchemy dev`) suite — + * yielded by BOTH the local Function fixture (to bind it) and the test's + * deploy program (to read its attributes); registration is FQN-idempotent. + */ +import * as Vercel from "@/Vercel/index.ts"; + +/** The declarative item set under test (asserted by the test). */ +export const LOCAL_FIXTURE_ITEMS = { + greeting: "local-hello", + enableCheckout: true, + limits: { maxItems: 3 }, +}; + +export const LocalFlags = Vercel.EdgeConfig("LocalFlags", { + items: { ...LOCAL_FIXTURE_ITEMS }, +}); diff --git a/packages/alchemy/test/Vercel/EdgeConfig/fixtures/mixed-edge-config-fn.ts b/packages/alchemy/test/Vercel/EdgeConfig/fixtures/mixed-edge-config-fn.ts new file mode 100644 index 0000000000..a9ff6e7441 --- /dev/null +++ b/packages/alchemy/test/Vercel/EdgeConfig/fixtures/mixed-edge-config-fn.ts @@ -0,0 +1,69 @@ +/** + * Effect-mode Vercel Function fixture for the MIXED dev-mode stack: one + * locally emulated Edge Config plus one `Alchemy.remote()` (real) Edge + * Config, both bound via `Vercel.ReadEdgeConfig` into the SAME locally + * running Function. Routes are prefixed `/local/…` and `/live/…`. + */ +import { remote } from "@/ProviderMode.ts"; +import * as Vercel from "@/Vercel/index.ts"; +import * as Effect from "effect/Effect"; +import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import type { ReadEdgeConfigClient } from "@/Vercel/EdgeConfig/EdgeConfigRead.ts"; + +export const MIXED_LOCAL_ITEMS = { source: "local", n: 1 }; +export const MIXED_LIVE_ITEMS = { source: "live", n: 2 }; + +export const MixedLocalFlags = Vercel.EdgeConfig("MixedLocalFlags", { + items: { ...MIXED_LOCAL_ITEMS }, +}); + +/** Runs LIVE even during `alchemy dev` — the local-emulation opt-out. */ +export const MixedLiveFlags = Vercel.EdgeConfig("MixedLiveFlags", { + items: { ...MIXED_LIVE_ITEMS }, +}).pipe(remote()); + +export default class MixedEdgeFn extends Vercel.Function()( + "MixedEdgeFn", + { + main: import.meta.url, + }, + Effect.gen(function* () { + const local = yield* MixedLocalFlags; + const live = yield* MixedLiveFlags; + const localConfig = yield* Vercel.ReadEdgeConfig(local); + const liveConfig = yield* Vercel.ReadEdgeConfig(live); + + const serve = (config: ReadEdgeConfigClient, path: string) => + Effect.gen(function* () { + if (path.startsWith("/item/")) { + const key = decodeURIComponent(path.slice("/item/".length)); + const value = yield* config.get(key).pipe(Effect.orDie); + return yield* HttpServerResponse.json({ value: value ?? null }); + } + if (path.startsWith("/all")) { + const items = yield* config.getAll().pipe(Effect.orDie); + return yield* HttpServerResponse.json({ items }); + } + if (path.startsWith("/digest")) { + const digest = yield* config.digest().pipe(Effect.orDie); + return yield* HttpServerResponse.json({ digest }); + } + return yield* HttpServerResponse.json({ ok: true }); + }); + + return { + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; + const path = request.url.split("?")[0]!; + if (path.startsWith("/local")) { + return yield* serve(localConfig, path.slice("/local".length)); + } + if (path.startsWith("/live")) { + return yield* serve(liveConfig, path.slice("/live".length)); + } + return yield* HttpServerResponse.json({ ok: true }); + }), + }; + }).pipe(Effect.provide(Vercel.ReadEdgeConfigHttp)), +) {} diff --git a/packages/alchemy/test/Vercel/EdgeConfig/fixtures/write-edge-config-fn.ts b/packages/alchemy/test/Vercel/EdgeConfig/fixtures/write-edge-config-fn.ts new file mode 100644 index 0000000000..4fb673b52f --- /dev/null +++ b/packages/alchemy/test/Vercel/EdgeConfig/fixtures/write-edge-config-fn.ts @@ -0,0 +1,79 @@ +/** + * Effect-mode Vercel Function fixture for the EXPLICIT-OPT-IN + * EdgeConfigWrite capability: declares an Edge Config, binds it via + * `Vercel.WriteEdgeConfig` with a USER-SUPPLIED management token + * (`Config.redacted("VERCEL_TOKEN")`, resolved from the deploy + * environment and synced as a sensitive project env var), and exposes one + * HTTP route per client method (set / delete / patch). + * + * Deployed only when `VERCEL_TEST_EDGE_CONFIG_WRITE=1` — binding a + * team-wide management token into a Function is the documented tradeoff + * the caller must accept (see EdgeConfigWrite's Runtime authorization + * JSDoc). + */ +import * as Vercel from "@/Vercel/index.ts"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; + +/** Seeded declaratively; mutated at runtime through the write binding. */ +export const WRITE_FIXTURE_ITEMS = { seeded: "v1" }; + +export const WriteFlags = Vercel.EdgeConfig("WriteFlags", { + items: { ...WRITE_FIXTURE_ITEMS }, +}); + +export default class WriteEdgeConfigFn extends Vercel.Function()( + "WriteEdgeConfigFn", + { + main: import.meta.url, + }, + Effect.gen(function* () { + const flags = yield* WriteFlags; + const writer = yield* Vercel.WriteEdgeConfig(flags); + + return { + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; + const [path, query] = request.url.split("?") as [ + string, + string | undefined, + ]; + const params = new URLSearchParams(query ?? ""); + if (path === "/set") { + const key = params.get("key") ?? "k"; + const value = params.get("value") ?? "v"; + yield* writer.set({ [key]: value }).pipe(Effect.orDie); + return yield* HttpServerResponse.json({ ok: true }); + } + if (path === "/delete") { + const key = params.get("key") ?? "k"; + yield* writer.delete([key]).pipe(Effect.orDie); + return yield* HttpServerResponse.json({ ok: true }); + } + if (path === "/patch") { + const key = params.get("key") ?? "k"; + const value = params.get("value") ?? "v"; + const drop = params.get("drop"); + yield* writer + .patch([ + { operation: "upsert", key, value }, + ...(drop === null + ? [] + : [{ operation: "delete" as const, key: drop }]), + ]) + .pipe(Effect.orDie); + return yield* HttpServerResponse.json({ ok: true }); + } + return yield* HttpServerResponse.json({ ok: true }); + }), + }; + }).pipe( + Effect.provide( + // EXPLICIT opt-in: the write token is supplied by the composition, + // never auto-bound. Resolved on the DEPLOY machine's environment. + Vercel.WriteEdgeConfigHttp({ token: Config.redacted("VERCEL_TOKEN") }), + ), + ), +) {} diff --git a/packages/alchemy/test/Vercel/Environments/SharedEnv.test.ts b/packages/alchemy/test/Vercel/Environments/SharedEnv.test.ts new file mode 100644 index 0000000000..e8b20ae18b --- /dev/null +++ b/packages/alchemy/test/Vercel/Environments/SharedEnv.test.ts @@ -0,0 +1,283 @@ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as environment from "@distilled.cloud/vercel/environment"; +import * as projects from "@distilled.cloud/vercel/projects"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +// Deterministic host-project names — one per test so concurrently running +// tests never fight over a fixture. +const HOST_LIFECYCLE_A = "alchemy-sharedenv-host-a"; +const HOST_LIFECYCLE_B = "alchemy-sharedenv-host-b"; +const HOST_SENSITIVE = "alchemy-sharedenv-host-sensitive"; + +// Shared env var keys are team-global; deterministic and suite-unique. +const KEY = "ALCHEMY_TEST_SHARED_ENV"; +const SENSITIVE_KEY = "ALCHEMY_TEST_SHARED_SENSITIVE"; +const RECOVERY_KEY = "ALCHEMY_TEST_SHARED_RECOVERY"; + +const teamScopeOf = Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + return teamId === undefined ? {} : { teamId }; +}); + +// Out-of-band host-project fixture (NOT the Vercel.Project resource — these +// tests must not depend on a concurrently-owned provider). Delete-if-exists +// first so an interrupted previous run can't wedge the deterministic name. +const ensureHostProject = (name: string, scope: { teamId?: string }) => + Effect.gen(function* () { + yield* projects + .deleteProject({ idOrName: name, ...scope }) + .pipe(Effect.catchTag("NotFound", () => Effect.void)); + const created = yield* projects.createProject({ name, ...scope }); + return created.id; + }); + +// Finalizer-safe (used with `Effect.ensuring`): never fails. +const deleteHostProject = (name: string, scope: { teamId?: string }) => + projects.deleteProject({ idOrName: name, ...scope }).pipe(Effect.ignore); + +/** Bounded typed wait-until-gone. */ +const expectSharedEnvGone = (id: string, scope: { teamId?: string }) => + Effect.gen(function* () { + const gone = yield* environment.getSharedEnvVar({ id, ...scope }).pipe( + Effect.map(() => false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (g) => g, + times: 10, + }), + ); + expect(gone).toBe(true); + }); + +test.provider("create, update, relink, and destroy a shared env var", (stack) => + Effect.gen(function* () { + const scope = yield* teamScopeOf; + const projectA = yield* ensureHostProject(HOST_LIFECYCLE_A, scope); + const projectB = yield* ensureHostProject(HOST_LIFECYCLE_B, scope); + yield* Effect.gen(function* () { + yield* stack.destroy(); + + const created = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.SharedEnv("Var", { + key: KEY, + value: "one", + target: ["production", "preview"], + projects: [projectA], + comment: "alchemy test", + }); + }), + ); + expect(created.sharedEnvId).toMatch(/^env_/); + expect(created.key).toEqual(KEY); + expect(created.type).toEqual("encrypted"); + expect([...created.target].sort()).toEqual(["preview", "production"]); + expect(created.projectIds).toEqual([projectA]); + expect(created.comment).toEqual("alchemy test"); + + // Out-of-band verification via distilled: encrypted values are + // readable back, links and targets landed. + const observed = yield* environment.getSharedEnvVar({ + id: created.sharedEnvId, + ...scope, + }); + expect(observed.value).toEqual("one"); + expect([...(observed.target ?? [])].sort()).toEqual([ + "preview", + "production", + ]); + expect(observed.projectId).toEqual([projectA]); + + // No-op redeploy: same identity, no spurious update. + const second = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.SharedEnv("Var", { + key: KEY, + value: "one", + target: ["production", "preview"], + projects: [projectA], + comment: "alchemy test", + }); + }), + ); + expect(second.sharedEnvId).toEqual(created.sharedEnvId); + expect(second.updatedAt).toEqual(created.updatedAt); + + // Update in place: new value, narrower target, relink A -> B, + // new comment. Identity (id) is stable. + const updated = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.SharedEnv("Var", { + key: KEY, + value: "two", + target: ["production"], + projects: [projectB], + comment: "alchemy test 2", + }); + }), + ); + expect(updated.sharedEnvId).toEqual(created.sharedEnvId); + expect(updated.target).toEqual(["production"]); + expect(updated.projectIds).toEqual([projectB]); + expect(updated.comment).toEqual("alchemy test 2"); + + const observedUpdated = yield* environment.getSharedEnvVar({ + id: created.sharedEnvId, + ...scope, + }); + expect(observedUpdated.value).toEqual("two"); + expect(observedUpdated.projectId).toEqual([projectB]); + expect(observedUpdated.target).toEqual(["production"]); + + yield* stack.destroy(); + yield* expectSharedEnvGone(created.sharedEnvId, scope); + + // Idempotent destroy: destroying again (already gone) must not fail. + yield* stack.destroy(); + }).pipe( + Effect.ensuring( + Effect.andThen( + deleteHostProject(HOST_LIFECYCLE_A, scope), + deleteHostProject(HOST_LIFECYCLE_B, scope), + ), + ), + ); + }).pipe(logLevel), +); + +test.provider( + "sensitive values are write-only and drift-corrected by content hash", + (stack) => + Effect.gen(function* () { + const scope = yield* teamScopeOf; + const projectId = yield* ensureHostProject(HOST_SENSITIVE, scope); + yield* Effect.gen(function* () { + yield* stack.destroy(); + + const created = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.SharedEnv("Sens", { + key: SENSITIVE_KEY, + value: "s3cret-1", + type: "sensitive", + target: ["production"], + projects: [projectId], + }); + }), + ); + expect(created.type).toEqual("sensitive"); + expect(created.valueHash).toBeDefined(); + + // The plaintext never comes back from the API. + const observed = yield* environment.getSharedEnvVar({ + id: created.sharedEnvId, + ...scope, + }); + expect(observed.type).toEqual("sensitive"); + expect(observed.value).not.toEqual("s3cret-1"); + + // No-op redeploy: the persisted content hash matches, so no write. + const second = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.SharedEnv("Sens", { + key: SENSITIVE_KEY, + value: "s3cret-1", + type: "sensitive", + target: ["production"], + projects: [projectId], + }); + }), + ); + expect(second.sharedEnvId).toEqual(created.sharedEnvId); + expect(second.valueHash).toEqual(created.valueHash); + expect(second.updatedAt).toEqual(created.updatedAt); + + // Value change: hash drift forces a write even though the cloud + // value is unreadable. + const rotated = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.SharedEnv("Sens", { + key: SENSITIVE_KEY, + value: "s3cret-2", + type: "sensitive", + target: ["production"], + projects: [projectId], + }); + }), + ); + expect(rotated.sharedEnvId).toEqual(created.sharedEnvId); + expect(rotated.valueHash).not.toEqual(created.valueHash); + + yield* stack.destroy(); + yield* expectSharedEnvGone(created.sharedEnvId, scope); + }).pipe(Effect.ensuring(deleteHostProject(HOST_SENSITIVE, scope))); + }).pipe(logLevel), +); + +test.provider( + "converges an existing key on create and tolerates out-of-band deletion", + (stack) => + Effect.gen(function* () { + const scope = yield* teamScopeOf; + // Pre-create the key out-of-band — the reconciler must observe it by + // key and sync it in place (crash recovery / SQS-style name + // observation), never create a duplicate. + const pre = yield* environment.createSharedEnvVariable({ + evs: [{ key: RECOVERY_KEY, value: "pre" }], + type: "encrypted", + target: ["production"], + ...scope, + }); + const preId = pre.created[0]?.id; + expect(preId).toBeDefined(); + yield* Effect.gen(function* () { + yield* stack.destroy(); + + const deployed = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.SharedEnv("Rec", { + key: RECOVERY_KEY, + value: "post", + target: ["production"], + }); + }), + ); + expect(deployed.sharedEnvId).toEqual(preId); + + const observed = yield* environment.getSharedEnvVar({ + id: deployed.sharedEnvId, + ...scope, + }); + expect(observed.value).toEqual("post"); + + // Out-of-band deletion: destroy must catch the typed NotFound and + // succeed anyway (idempotent delete). + yield* environment.deleteSharedEnvVariable({ + ids: [deployed.sharedEnvId], + ...scope, + }); + yield* stack.destroy(); + }).pipe( + Effect.ensuring( + // Belt-and-braces: never leak the deterministic key. + preId !== undefined + ? environment + .deleteSharedEnvVariable({ ids: [preId], ...scope }) + .pipe(Effect.ignore) + : Effect.void, + ), + ); + }).pipe(logLevel), +); diff --git a/packages/alchemy/test/Vercel/FeatureFlags/FeatureFlag.test.ts b/packages/alchemy/test/Vercel/FeatureFlags/FeatureFlag.test.ts new file mode 100644 index 0000000000..4cfc4131dd --- /dev/null +++ b/packages/alchemy/test/Vercel/FeatureFlags/FeatureFlag.test.ts @@ -0,0 +1,228 @@ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as featureFlags from "@distilled.cloud/vercel/feature_flags"; +import * as projects from "@distilled.cloud/vercel/projects"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +// Deterministic host-project names — one per test so concurrently running +// tests never fight over a fixture. +const HOST_LIFECYCLE = "alchemy-featureflags-host-lifecycle"; +const HOST_SLUG = "alchemy-featureflags-host-slug"; + +const teamScopeOf = Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + return teamId === undefined ? {} : { teamId }; +}); + +// Out-of-band host-project fixture (NOT the Vercel.Project resource — these +// tests must not depend on a concurrently-owned provider). Delete-if-exists +// first so an interrupted previous run can't wedge the deterministic name. +const ensureHostProject = (name: string, scope: { teamId?: string }) => + Effect.gen(function* () { + yield* projects + .deleteProject({ idOrName: name, ...scope }) + .pipe(Effect.catchTag("NotFound", () => Effect.void)); + const created = yield* projects.createProject({ name, ...scope }); + return created.id; + }); + +// Finalizer-safe (used with `Effect.ensuring`): never fails. +const deleteHostProject = (name: string, scope: { teamId?: string }) => + projects.deleteProject({ idOrName: name, ...scope }).pipe(Effect.ignore); + +/** Bounded typed wait-until-gone. */ +const expectFlagGone = ( + projectId: string, + flagId: string, + scope: { teamId?: string }, +) => + Effect.gen(function* () { + const gone = yield* featureFlags + .getFlag({ projectIdOrName: projectId, flagIdOrSlug: flagId, ...scope }) + .pipe( + Effect.map(() => false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (g) => g, + times: 10, + }), + ); + expect(gone).toBe(true); + }); + +const onOff: Vercel.FlagVariant[] = [ + { id: "on", value: true }, + { id: "off", value: false }, +]; + +test.provider( + "create, no-op redeploy, update, and destroy a boolean flag", + (stack) => + Effect.gen(function* () { + const scope = yield* teamScopeOf; + const projectId = yield* ensureHostProject(HOST_LIFECYCLE, scope); + yield* Effect.gen(function* () { + yield* stack.destroy(); + + const created = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.FeatureFlag("Flag", { + project: projectId, + kind: "boolean", + variants: onOff, + description: "initial", + environments: { + production: { + fallthrough: { type: "variant", variantId: "on" }, + }, + }, + }); + }), + ); + expect(created.flagId).toMatch(/^flg_/); + expect(created.projectId).toEqual(projectId); + expect(created.kind).toEqual("boolean"); + expect(created.state).toEqual("active"); + expect(created.revision).toEqual(0); + // Auto-generated slug is lowercase (flag slugs allow [a-zA-Z0-9_-]). + expect(created.slug).toEqual(created.slug.toLowerCase()); + + // Out-of-band verification via distilled. + const observed = yield* featureFlags.getFlag({ + projectIdOrName: projectId, + flagIdOrSlug: created.flagId, + ...scope, + }); + expect(observed.slug).toEqual(created.slug); + expect(observed.description).toEqual("initial"); + expect(observed.environments.production?.active).toEqual(true); + + // No-op redeploy: same props — no spurious PATCH (revision pinned). + const second = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.FeatureFlag("Flag", { + project: projectId, + kind: "boolean", + variants: onOff, + description: "initial", + environments: { + production: { + fallthrough: { type: "variant", variantId: "on" }, + }, + }, + }); + }), + ); + expect(second.flagId).toEqual(created.flagId); + expect(second.revision).toEqual(0); + + // Update in place: description + pause production. + const updated = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.FeatureFlag("Flag", { + project: projectId, + kind: "boolean", + variants: onOff, + description: "updated", + environments: { + production: { + active: false, + pausedOutcome: { type: "variant", variantId: "off" }, + fallthrough: { type: "variant", variantId: "on" }, + }, + }, + }); + }), + ); + expect(updated.flagId).toEqual(created.flagId); + expect(updated.revision).toBeGreaterThan(0); + + const observedUpdated = yield* featureFlags.getFlag({ + projectIdOrName: projectId, + flagIdOrSlug: created.flagId, + ...scope, + }); + expect(observedUpdated.description).toEqual("updated"); + expect(observedUpdated.environments.production?.active).toEqual(false); + expect(observedUpdated.environments.production?.pausedOutcome).toEqual({ + type: "variant", + variantId: "off", + }); + + yield* stack.destroy(); + yield* expectFlagGone(projectId, created.flagId, scope); + }).pipe(Effect.ensuring(deleteHostProject(HOST_LIFECYCLE, scope))); + }).pipe(logLevel), + { timeout: 120_000 }, +); + +test.provider( + "explicit slug: created verbatim, slug change replaces the flag", + (stack) => + Effect.gen(function* () { + const scope = yield* teamScopeOf; + const projectId = yield* ensureHostProject(HOST_SLUG, scope); + yield* Effect.gen(function* () { + yield* stack.destroy(); + + const created = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.FeatureFlag("Flag", { + project: projectId, + slug: "alchemy-test-flag", + kind: "string", + variants: [ + { id: "a", value: "alpha" }, + { id: "b", value: "beta" }, + ], + environments: { + production: { + fallthrough: { type: "variant", variantId: "a" }, + }, + }, + }); + }), + ); + expect(created.slug).toEqual("alchemy-test-flag"); + expect(created.kind).toEqual("string"); + + // Changing the slug is a REPLACEMENT: new flag id, old slug gone. + const replaced = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.FeatureFlag("Flag", { + project: projectId, + slug: "alchemy-test-flag-renamed", + kind: "string", + variants: [ + { id: "a", value: "alpha" }, + { id: "b", value: "beta" }, + ], + environments: { + production: { + fallthrough: { type: "variant", variantId: "a" }, + }, + }, + }); + }), + ); + expect(replaced.slug).toEqual("alchemy-test-flag-renamed"); + expect(replaced.flagId).not.toEqual(created.flagId); + yield* expectFlagGone(projectId, created.flagId, scope); + + yield* stack.destroy(); + yield* expectFlagGone(projectId, replaced.flagId, scope); + }).pipe(Effect.ensuring(deleteHostProject(HOST_SLUG, scope))); + }).pipe(logLevel), + { timeout: 120_000 }, +); diff --git a/packages/alchemy/test/Vercel/Functions/Bridge.test.ts b/packages/alchemy/test/Vercel/Functions/Bridge.test.ts new file mode 100644 index 0000000000..2f10795c06 --- /dev/null +++ b/packages/alchemy/test/Vercel/Functions/Bridge.test.ts @@ -0,0 +1,244 @@ +/** + * Vercel Function Effect-mode runtime bridge tests — live against the + * standing Vercel test team (run with the doppler alchemy-v2/dev env). + * + * Covers: the two-phase class fixture deployed + driven over HTTP via the + * production alias, post-response request finalizers, Fluid re-entrancy, + * the `Function.URL` sentinel (both the Effect accessor and the async + * `env:` channel), crons landing in the deployment config, the + * CRON_SECRET-guarded cron route, and the auto-minted protection-bypass + * secret. + */ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as deployments from "@distilled.cloud/vercel/deployments"; +import * as projects from "@distilled.cloud/vercel/projects"; +import { expect } from "alchemy-test"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import * as Schedule from "effect/Schedule"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import { MinimumLogLevel } from "effect/References"; +import EffectFn from "./fixtures/effect-fn.ts"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +const asyncFixtureMain = new URL("./fixtures/handler.ts", import.meta.url) + .pathname; + +// Fresh .vercel.app URLs take a few seconds to start serving 200s — always +// retry the first request (bounded). +const readiness = Schedule.max([ + Schedule.exponential("500 millis"), + Schedule.recurs(20), +]); + +const getJson = (url: string) => + HttpClient.get(url).pipe( + Effect.flatMap((response) => + response.status === 200 + ? response.json + : Effect.fail(new Error(`status ${response.status}`)), + ), + Effect.retry({ schedule: readiness }), + ); + +const getStatus = (url: string, headers?: Record) => + HttpClient.get(url, headers !== undefined ? { headers } : undefined).pipe( + Effect.map((response) => response.status), + ); + +/** Poll (bounded) until the function's project is gone. */ +const expectProjectGone = (projectId: string) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + const gone = yield* projects + .getProject({ idOrName: projectId, teamId }) + .pipe( + Effect.map(() => false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (g) => g, + times: 10, + }), + ); + expect(gone).toBe(true); + }); + +test.provider( + "effect-mode bridge: fetch, finalizers, self URL, cron guard, bypass, redeploy stability", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const fn = yield* stack.deploy( + Effect.gen(function* () { + return yield* EffectFn; + }), + ); + expect(fn.url).toBeDefined(); + expect(fn.deploymentId).toBeDefined(); + expect(fn.cronSecret).toBeDefined(); + expect(fn.protectionBypass).toBeDefined(); + + // 1. Plain fetch round-trip through the bridge (first-request retry). + const root = (yield* getJson(`${fn.url}/`)) as { ok: boolean }; + expect(root.ok).toBe(true); + + // 2. Re-entrancy smoke: concurrent requests against the warm + // function all complete (each runs in its own fiber + scope). + const slow = yield* Effect.all( + Array.from({ length: 4 }, () => + getJson(`${fn.url}/slow`).pipe( + Effect.map((body) => (body as { slow: boolean }).slow), + ), + ), + { concurrency: "unbounded" }, + ); + expect(slow).toEqual([true, true, true, true]); + + // 3. Request finalizers settle POST-response: /finalize registers a + // finalizer that sleeps 3s — the response must not wait for it. + const [elapsed, finalize] = yield* Effect.timed( + getJson(`${fn.url}/finalize`), + ); + expect((finalize as { ok: boolean }).ok).toBe(true); + expect(Duration.toMillis(elapsed)).toBeLessThan(2500); + // ... and the finalizer DID run afterwards (same warm instance — + // module-global readback, the probe-verified waitUntil contract). + const finalized = yield* getJson(`${fn.url}/finalized`).pipe( + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (body) => (body as { finalized: number }).finalized >= 1, + times: 15, + }), + ); + expect((finalized as { finalized: number }).finalized).toBeGreaterThan(0); + + // 4. Function.URL accessor resolves to the read-back production URL. + const self = (yield* getJson(`${fn.url}/self`)) as { url: string }; + expect(self.url).toEqual(fn.url); + + // 5. The cron landed in the deployment config (read back via + // distilled getDeployment). + const { teamId } = yield* Vercel.VercelEnvironment.current; + const deployment = yield* deployments.getDeployment({ + idOrUrl: fn.deploymentId, + teamId, + }); + const crons = + "crons" in deployment && Array.isArray(deployment.crons) + ? deployment.crons + : []; + expect(crons).toEqual([ + { schedule: "0 3 * * *", path: "/_alchemy/cron/0" }, + ]); + + // 6. The guarded cron route: 401 without the secret, 401 with a wrong + // bearer, 200 with the minted CRON_SECRET (which also proves the + // handler ran — a failing handler responds 500). + const cronUrl = `${fn.url}/_alchemy/cron/0`; + expect(yield* getStatus(cronUrl)).toBe(401); + expect( + yield* getStatus(cronUrl, { authorization: "Bearer wrong-secret" }), + ).toBe(401); + expect( + yield* getStatus(cronUrl, { + authorization: `Bearer ${Redacted.value(fn.cronSecret!)}`, + }), + ).toBe(200); + + // 7. CRON_SECRET was synced as a sensitive project env var. + const envs = yield* projects.filterProjectEnvs({ + idOrName: fn.projectId, + teamId, + decrypt: "true", + }); + const rows = ( + Array.isArray(envs) + ? envs + : typeof envs === "object" && envs !== null && "envs" in envs + ? envs.envs + : [] + ) as Array<{ key: string; type: string }>; + const cronRow = rows.find((row) => row.key === "CRON_SECRET"); + expect(cronRow).toBeDefined(); + expect(cronRow!.type).toEqual("sensitive"); + + // 8. The protection-bypass secret was minted BEFORE the first deploy + // and is exposed (observe-and-keep) — the attribute matches the + // project's automation-bypass entry. + const project = yield* projects.getProject({ + idOrName: fn.projectId, + teamId, + }); + const bypassEntry = ( + project.protectionBypass as + | Record + | undefined + )?.[Redacted.value(fn.protectionBypass!)]; + expect(bypassEntry).toBeDefined(); + expect(bypassEntry!.scope).toEqual("automation-bypass"); + + // 9. Churn regression: an identical second deploy is a skip-on-hash + // no-op — the cron secret and self URL substitution are stable, so + // the deployment (and the bypass secret) must not change. + const redeployed = yield* stack.deploy( + Effect.gen(function* () { + return yield* EffectFn; + }), + ); + expect(redeployed.projectId).toEqual(fn.projectId); + expect(redeployed.deploymentId).toEqual(fn.deploymentId); + expect(Redacted.value(redeployed.cronSecret!)).toEqual( + Redacted.value(fn.cronSecret!), + ); + expect(Redacted.value(redeployed.protectionBypass!)).toEqual( + Redacted.value(fn.protectionBypass!), + ); + + // 10. Destroy cascades the owned project; typed wait-until-gone. + yield* stack.destroy(); + yield* expectProjectGone(fn.projectId); + }).pipe(logLevel), + { timeout: 120_000 }, +); + +test.provider( + "async env: Function.URL sentinel resolves to the function's own URL", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const fn = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.Function("SelfEnvFn", { + main: asyncFixtureMain, + env: { + GREETING: "self", + SELF_URL: Vercel.Function.URL, + }, + }); + }), + ); + expect(fn.url).toBeDefined(); + + const env = (yield* getJson(`${fn.url}/env`)) as { + greeting: string | null; + selfUrl: string | null; + }; + expect(env.greeting).toEqual("self"); + expect(env.selfUrl).toEqual(fn.url); + + yield* stack.destroy(); + yield* expectProjectGone(fn.projectId); + }).pipe(logLevel), + { timeout: 120_000 }, +); diff --git a/packages/alchemy/test/Vercel/Functions/Function.local.test.ts b/packages/alchemy/test/Vercel/Functions/Function.local.test.ts new file mode 100644 index 0000000000..13a0c5c659 --- /dev/null +++ b/packages/alchemy/test/Vercel/Functions/Function.local.test.ts @@ -0,0 +1,304 @@ +/** + * Vercel Function dev-mode (`alchemy dev`) tests — the local provider per + * the Local-tests doctrine: `dev: true` runs local providers behind the + * RPC sidecar proxy by default, matching the process topology of the real + * `alchemy dev` command. + * + * Covers (a) the local roundtrip across the FULL launcher matrix + * (`{ fetch }`, method exports, `(req, res)`, and the Effect bridge) with + * `dev:` identity markers, injected platform env/headers, the guarded cron + * route, and URL stability across a config restart; and (b) the + * `Alchemy.remote()` opt-out deploying live during dev with out-of-band + * distilled verification and post-destroy absence. + */ +import * as Alchemy from "@/index.ts"; +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as projects from "@distilled.cloud/vercel/projects"; +import { expect } from "alchemy-test"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import * as Schedule from "effect/Schedule"; +import { MinimumLogLevel } from "effect/References"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import LocalEffectFn from "./fixtures/local-effect-fn.ts"; + +const { test } = Test.make({ + providers: Vercel.providers(), + dev: true, +}); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +const asyncMain = new URL("./fixtures/local-async-fn.ts", import.meta.url) + .pathname; +const methodMain = new URL("./fixtures/local-method-fn.ts", import.meta.url) + .pathname; +const nodeMain = new URL("./fixtures/local-node-fn.ts", import.meta.url) + .pathname; + +class NotReady extends Data.TaggedError("NotReady")<{ + status: number; + location: string | undefined; + message: string; +}> {} + +// The first request races the dev bundle's first build (rolldown over the +// alchemy barrel for Effect functions) — retry with a bounded cap. +const readiness = Schedule.max([ + Schedule.min([ + Schedule.exponential("500 millis"), + Schedule.spaced("2 seconds"), + ]), + Schedule.recurs(45), +]); + +const getJsonReady = (url: string) => + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const res = yield* client.get(url).pipe( + Effect.flatMap((res) => + res.status === 200 + ? Effect.succeed(res) + : Effect.fail( + new NotReady({ + status: res.status, + location: res.headers.location, + message: `GET ${url} -> ${res.status}${ + res.headers.location === undefined + ? "" + : ` (location: ${res.headers.location})` + }`, + }), + ), + ), + Effect.retry({ + while: (e): e is NotReady => e instanceof NotReady, + schedule: readiness, + }), + ); + return yield* res.json; + }).pipe(Effect.orDie); + +const getStatus = (url: string, headers?: Record) => + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + let request = HttpClientRequest.get(url); + if (headers) request = HttpClientRequest.setHeaders(request, headers); + return yield* client.execute(request); + }); + +test.provider( + "local roundtrip: launcher matrix, dev markers, cron guard, hot restart", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + const client = yield* HttpClient.HttpClient; + + const deploy = (greeting: string) => + stack.deploy( + Effect.gen(function* () { + const asyncFn = yield* Vercel.Function("AsyncFn", { + main: asyncMain, + env: { GREETING: greeting, SELF_URL: Vercel.Function.URL }, + }); + const methodFn = yield* Vercel.Function("MethodFn", { + main: methodMain, + }); + const nodeFn = yield* Vercel.Function("NodeFn", { + main: nodeMain, + }); + const effectFn = yield* LocalEffectFn; + return { asyncFn, methodFn, nodeFn, effectFn }; + }), + ); + + const first = yield* deploy("hello"); + + // dev identity markers — proof no cloud call ran. + for (const fn of [ + first.asyncFn, + first.methodFn, + first.nodeFn, + first.effectFn, + ]) { + expect(fn.url).toMatch(/^http:\/\/localhost:\d+$/); + expect(fn.projectId).toMatch(/^dev:/); + expect(fn.deploymentId).toMatch(/^dev:/); + } + + // ── arm 1: default { fetch } + injected platform env + self URL. + const env = (yield* getJsonReady(`${first.asyncFn.url}/env`)) as { + greeting: string | null; + vercel: string | null; + vercelEnv: string | null; + vercelUrl: string | null; + deploymentId: string | null; + selfUrl: string | null; + }; + expect(env.greeting).toBe("hello"); + expect(env.vercel).toBe("1"); + expect(env.vercelEnv).toBe("development"); + expect(`http://${env.vercelUrl}`).toBe(first.asyncFn.url); + expect(env.deploymentId).toMatch(/^dev:/); + expect(env.selfUrl).toBe(first.asyncFn.url); + + // Injected platform headers. + const headers = (yield* getJsonReady(`${first.asyncFn.url}/headers`)) as { + vercelId: string | null; + country: string | null; + proto: string | null; + }; + expect(headers.vercelId).toBeTruthy(); + expect(headers.country).toBe("US"); + expect(headers.proto).toBe("http"); + + // Request body streaming (POST /echo). + const echoRes = yield* client.execute( + HttpClientRequest.post(`${first.asyncFn.url}/echo`).pipe( + HttpClientRequest.bodyText("ping", "text/plain"), + ), + ); + expect(echoRes.status).toBe(200); + expect((yield* echoRes.json) as object).toEqual({ echo: "ping" }); + + // ── arm 3: method exports (GET/POST routed, DELETE is 405 + Allow). + const got = (yield* getJsonReady(`${first.methodFn.url}/route`)) as { + method: string; + path: string; + }; + expect(got).toEqual({ method: "GET", path: "/route" }); + const posted = yield* client.execute( + HttpClientRequest.post(`${first.methodFn.url}/route`).pipe( + HttpClientRequest.bodyText("body", "text/plain"), + ), + ); + expect((yield* posted.json) as object).toEqual({ + method: "POST", + echo: "body", + }); + const del = yield* client.execute( + HttpClientRequest.make("DELETE")(`${first.methodFn.url}/route`), + ); + expect(del.status).toBe(405); + + // ── arm 2: legacy (req, res) default export. + const node = (yield* getJsonReady(`${first.nodeFn.url}/anything`)) as { + node: boolean; + url: string | null; + }; + expect(node.node).toBe(true); + expect(node.url).toBe("/anything"); + + // ── Effect bridge: served through makeVercelBridge inside the shim. + const effectEnv = (yield* getJsonReady(`${first.effectFn.url}/env`)) as { + vercelEnv: string | null; + deploymentId: string | null; + }; + expect(effectEnv.vercelEnv).toBe("development"); + expect(effectEnv.deploymentId).toMatch(/^dev:/); + + // Cron route guard: unauthorized is a 401; the platform invoker's + // exact shape (Bearer CRON_SECRET) runs the handler. + expect(first.effectFn.cronSecret).toBeDefined(); + const secret = Redacted.value(first.effectFn.cronSecret!); + const unauthorized = yield* getStatus( + `${first.effectFn.url}/_alchemy/cron/0`, + ); + expect(unauthorized.status).toBe(401); + const fired = yield* getStatus(`${first.effectFn.url}/_alchemy/cron/0`, { + authorization: `Bearer ${secret}`, + "user-agent": "vercel-cron/1.0", + }); + expect(fired.status).toBe(200); + const cronFires = (yield* getJsonReady( + `${first.effectFn.url}/cron-fires`, + )) as { cronFires: number }; + expect(cronFires.cronFires).toBe(1); + + // ── Hot restart on config (env) change keeps the URL stable. + const second = yield* deploy("bonjour"); + expect(second.asyncFn.url).toBe(first.asyncFn.url); + expect(second.asyncFn.deploymentId).not.toBe(first.asyncFn.deploymentId); + const updated = yield* Effect.gen(function* () { + const body = (yield* getJsonReady(`${second.asyncFn.url}/env`)) as { + greeting: string | null; + }; + return body.greeting; + }).pipe( + // The restart cuts over make-before-break: the old child may serve + // a few more requests until the replacement is ready. + Effect.repeat({ + schedule: Schedule.spaced("500 millis"), + until: (greeting) => greeting === "bonjour", + times: 60, + }), + ); + expect(updated).toBe("bonjour"); + + yield* stack.destroy(); + }).pipe(logLevel), + { timeout: 240_000 }, +); + +test.provider( + "Alchemy.remote() Function deploys live during dev", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const deployed = yield* stack.deploy( + Effect.gen(function* () { + const fn = yield* Vercel.Function("RemoteFn", { + main: asyncMain, + env: { GREETING: "live" }, + }).pipe(Alchemy.remote()); + return { fn }; + }), + ); + + // Real cloud identity — not the local emulator. + expect(deployed.fn.projectId).not.toMatch(/^dev:/); + expect(deployed.fn.url).toMatch(/^https:\/\//); + + // Round-trip against the real deployment. + const env = (yield* getJsonReady(`${deployed.fn.url}/env`)) as { + greeting: string | null; + vercelEnv: string | null; + }; + expect(env.greeting).toBe("live"); + expect(env.vercelEnv).toBe("production"); + + // Out-of-band via distilled: the project exists on real Vercel. + const { teamId } = yield* Vercel.VercelEnvironment.current; + const project = yield* projects.getProject({ + idOrName: deployed.fn.projectId, + teamId, + }); + expect(project.id).toBe(deployed.fn.projectId); + + yield* stack.destroy(); + + // The live project was deleted from the cloud on destroy (the state + // row is stamped live, so the live provider handles the delete even + // in a dev run). + const gone = yield* projects + .getProject({ idOrName: deployed.fn.projectId, teamId }) + .pipe( + Effect.map(() => false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (g) => g, + times: 10, + }), + ); + expect(gone).toBe(true); + }).pipe(logLevel), + { timeout: 240_000 }, +); diff --git a/packages/alchemy/test/Vercel/Functions/Function.test.ts b/packages/alchemy/test/Vercel/Functions/Function.test.ts new file mode 100644 index 0000000000..7d5d35c18c --- /dev/null +++ b/packages/alchemy/test/Vercel/Functions/Function.test.ts @@ -0,0 +1,268 @@ +/** + * Vercel Function (async mode) lifecycle tests — live against the standing + * Vercel test team (run with the doppler alchemy-v2/dev env). + */ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import { syncProjectEnv } from "@/Vercel/Deploy/Engine.ts"; +import * as deployments from "@distilled.cloud/vercel/deployments"; +import * as projects from "@distilled.cloud/vercel/projects"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import * as Schedule from "effect/Schedule"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import { MinimumLogLevel } from "effect/References"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +const fixtureMain = new URL("./fixtures/handler.ts", import.meta.url).pathname; + +// Fresh .vercel.app URLs take a few seconds to start serving 200s — always +// retry the first request (bounded). +const readiness = Schedule.max([ + Schedule.exponential("500 millis"), + Schedule.recurs(20), +]); + +const getJson = (url: string) => + HttpClient.get(url).pipe( + Effect.flatMap((response) => + response.status === 200 + ? response.json + : Effect.fail(new Error(`status ${response.status}`)), + ), + Effect.retry({ schedule: readiness }), + ); + +/** Poll (bounded) until the function's project is gone. */ +const expectProjectGone = (projectId: string) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + const gone = yield* projects + .getProject({ idOrName: projectId, teamId }) + .pipe( + Effect.map(() => false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (g) => g, + times: 10, + }), + ); + expect(gone).toBe(true); + }); + +test.provider( + "deploy, drive, redeploy (skip-on-hash), env update, destroy", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const makeFn = (greeting: string) => + Effect.gen(function* () { + return yield* Vercel.Function("Fn", { + main: fixtureMain, + env: { GREETING: greeting }, + }); + }); + + // 1. Greenfield deploy. + const created = yield* stack.deploy(makeFn("hello")); + expect(created.projectId).toBeDefined(); + expect(created.deploymentId).toBeDefined(); + expect(created.url).toBeDefined(); + + // 2. Out-of-band verification via distilled: deployment is READY and + // carries the alchemy meta stamps. + const { teamId } = yield* Vercel.VercelEnvironment.current; + const page = yield* deployments.getDeployments({ + teamId, + projectId: created.projectId, + limit: 10, + }); + const deployed = page.deployments.find( + (d) => d.uid === created.deploymentId, + ); + expect(deployed).toBeDefined(); + expect(deployed!.readyState).toEqual("READY"); + expect(deployed!.meta?.alchemyLogicalId).toEqual("Fn"); + expect(deployed!.meta?.alchemyContentHash).toBeDefined(); + + // 3. Drive the deployed function over HTTP (first-request retry). + const root = (yield* getJson(`${created.url}/`)) as { ok: boolean }; + expect(root.ok).toBe(true); + const env = (yield* getJson(`${created.url}/env`)) as { + greeting: string | null; + }; + expect(env.greeting).toEqual("hello"); + + // 4. Redeploy with no changes: skip-on-hash keeps the deployment. + const redeployed = yield* stack.deploy(makeFn("hello")); + expect(redeployed.projectId).toEqual(created.projectId); + expect(redeployed.deploymentId).toEqual(created.deploymentId); + + // 5. Env change forces a new immutable deployment (Vercel env only + // takes effect on new deployments). + const updated = yield* stack.deploy(makeFn("bonjour")); + expect(updated.projectId).toEqual(created.projectId); + expect(updated.deploymentId).not.toEqual(created.deploymentId); + const updatedEnv = (yield* getJson(`${updated.url}/env`).pipe( + // The production alias may briefly serve the previous deployment + // (same bounded re-poll as the sensitive-env rotation test below). + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (body) => + (body as { greeting: string | null }).greeting === "bonjour", + times: 15, + }), + )) as { + greeting: string | null; + }; + expect(updatedEnv.greeting).toEqual("bonjour"); + + // 6. Destroy cascades the owned project; typed wait-until-gone. + yield* stack.destroy(); + yield* expectProjectGone(created.projectId); + }).pipe(logLevel), + { timeout: 120_000 }, +); + +test.provider( + "inline script function deploys and serves", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const script = `export default { + fetch(request) { + return Response.json({ inline: true, url: request.url }); + }, +}; +`; + const fn = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.Function("Inline", { script }); + }), + ); + expect(fn.url).toBeDefined(); + const body = (yield* getJson(`${fn.url}/`)) as { inline: boolean }; + expect(body.inline).toBe(true); + + yield* stack.destroy(); + yield* expectProjectGone(fn.projectId); + }).pipe(logLevel), + { timeout: 120_000 }, +); + +test.provider( + "sensitive env: hidden from listing, no redeploy churn, rotation redeploys", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const makeFn = (secret: string) => + Effect.gen(function* () { + return yield* Vercel.Function("SecretFn", { + main: fixtureMain, + env: { + GREETING: "hi", + SECRET_VALUE: Redacted.make(secret), + }, + }); + }); + + const created = yield* stack.deploy(makeFn("s3cret")); + + // Platform pin (live-verified): sensitive rows ARE listed but + // write-only — `value` comes back EMPTY even with decrypt=true (only + // the fingerprint comment is readable), and creating one targeting + // `development` is rejected outright (why SENSITIVE_ENV_TARGETS + // excludes it). Values are unobservable (this team's encrypted rows + // also list as `decrypted: false` ciphertext), so the drift baseline + // lives in persisted state. + const { teamId } = yield* Vercel.VercelEnvironment.current; + const envs = yield* projects.filterProjectEnvs({ + idOrName: created.projectId, + teamId, + decrypt: "true", + }); + const rows = ( + Array.isArray(envs) + ? envs + : typeof envs === "object" && envs !== null && "envs" in envs + ? envs.envs + : [] + ) as Array<{ + key: string; + type: string; + value?: string; + comment?: string; + }>; + const secret = rows.find((row) => row.key === "SECRET_VALUE"); + expect(secret).toBeDefined(); + expect(secret!.type).toEqual("sensitive"); + expect(secret!.value).toEqual(""); + expect(secret!.comment).toMatch(/^alchemy:sha256:/); + const greeting = rows.find((row) => row.key === "GREETING"); + expect(greeting).toBeDefined(); + expect(greeting!.type).toEqual("encrypted"); + + // The runtime sees the secret as a plain env string. + const env = (yield* getJson(`${created.url}/env`)) as { + secret: boolean; + secretValue: string | null; + }; + expect(env.secret).toBe(true); + expect(env.secretValue).toEqual("s3cret"); + + // CHURN REGRESSION (engine level): re-syncing the identical desired + // env against the persisted baseline must be a no-op even though the + // sensitive/encrypted values are unobservable in the listing. This is + // exactly what reconcile does — a `changed: true` here would force a + // redeploy on every deploy. + const meta = rows.find((row) => row.key === "ALCHEMY_META"); + expect(meta?.value).toBeDefined(); + const resync = yield* syncProjectEnv({ + idOrName: created.projectId, + desired: [ + { key: "GREETING", value: "hi" }, + { key: "SECRET_VALUE", value: Redacted.make("s3cret") }, + { key: "ALCHEMY_META", value: meta!.value!, type: "plain" }, + ], + managedEnv: created.managedEnv, + }); + expect(resync.changed).toBe(false); + + // CHURN REGRESSION (stack level): an identical second deploy keeps + // the same deployment. + const redeployed = yield* stack.deploy(makeFn("s3cret")); + expect(redeployed.projectId).toEqual(created.projectId); + expect(redeployed.deploymentId).toEqual(created.deploymentId); + + // Rotating the Redacted value DOES redeploy (env only takes effect + // on new deployments) and the runtime sees the new value. + const rotated = yield* stack.deploy(makeFn("r0tated")); + expect(rotated.projectId).toEqual(created.projectId); + expect(rotated.deploymentId).not.toEqual(created.deploymentId); + const rotatedEnv = (yield* getJson(`${rotated.url}/env`).pipe( + // The production alias may briefly serve the previous deployment. + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (body) => + (body as { secretValue: string | null }).secretValue === "r0tated", + times: 15, + }), + )) as { secretValue: string | null }; + expect(rotatedEnv.secretValue).toEqual("r0tated"); + + yield* stack.destroy(); + yield* expectProjectGone(created.projectId); + }).pipe(logLevel), + { timeout: 120_000 }, +); diff --git a/packages/alchemy/test/Vercel/Functions/InvokeFunction.test.ts b/packages/alchemy/test/Vercel/Functions/InvokeFunction.test.ts new file mode 100644 index 0000000000..307df20b88 --- /dev/null +++ b/packages/alchemy/test/Vercel/Functions/InvokeFunction.test.ts @@ -0,0 +1,181 @@ +/** + * Vercel InvokeFunction capability tests — live against the standing + * Vercel test team (run with the doppler alchemy-v2/dev env). + * + * Covers THE circular pre-create story: two Effect-mode Functions A↔B each + * binding `invoke(other)` — A by importing B's class, B by the + * `{ LogicalId }` forward reference that keeps the module/type graph + * acyclic — so each side's URL and protection-bypass secret must be read + * back off the pre-created stub before either deploys. Also a simple + * one-way async-mode test driving the same attributes through the plain + * `env:` channel. + */ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as projects from "@distilled.cloud/vercel/projects"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import { MinimumLogLevel } from "effect/References"; +import InvokeEchoA from "./fixtures/invoke-a.ts"; +import InvokeEchoB from "./fixtures/invoke-b.ts"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +const asyncTargetMain = new URL("./fixtures/handler.ts", import.meta.url) + .pathname; +const asyncCallerMain = new URL( + "./fixtures/invoke-async-caller.ts", + import.meta.url, +).pathname; + +// Fresh .vercel.app URLs take a few seconds to start serving 200s — always +// retry the first request (bounded: `times` on the retry is the hard cap). +const readiness = Schedule.min([ + Schedule.exponential("500 millis"), + Schedule.spaced("2 seconds"), +]); + +const getJson = (url: string) => + Effect.gen(function* () { + const response = yield* HttpClient.get(url); + const body = yield* response.text; + if (response.status !== 200) { + return yield* Effect.fail( + new Error(`status ${response.status}: ${body.slice(0, 300)}`), + ); + } + // Fresh production aliases can transiently serve a 200 HTML edge + // placeholder before the deployment is routed — treat an unparseable + // body as retryable, not a crash. + return yield* Effect.try({ + try: () => JSON.parse(body) as unknown, + catch: () => + new Error(`status 200 but non-JSON body: ${body.slice(0, 300)}`), + }); + }).pipe( + // Bounded: the schedule alone is NOT a bound (Schedule.max keeps + // recurring while either input does) — `times` is the hard cap. + Effect.retry({ schedule: readiness, times: 40 }), + ); + +/** Poll (bounded) until the function's project is gone. */ +const expectProjectGone = (projectId: string) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + const gone = yield* projects + .getProject({ idOrName: projectId, teamId }) + .pipe( + Effect.map(() => false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (g) => g, + times: 10, + }), + ); + expect(gone).toBe(true); + }); + +interface CallResponse { + fn: string; + targetUrl: string; + target: { fn: string; echo: string }; +} + +test.provider( + "circular invoke: A and B each binding invoke(other) round-trips both ways", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const { a, b } = yield* stack.deploy( + Effect.gen(function* () { + const a = yield* InvokeEchoA; + const b = yield* InvokeEchoB; + return { a, b }; + }), + ); + + // Both URLs are real read-back values (assigned production domains), + // not computed placeholders — and they are distinct projects. + expect(a.url).toMatch(/^https:\/\/.+\.vercel\.app$/); + expect(b.url).toMatch(/^https:\/\/.+\.vercel\.app$/); + expect(a.url).not.toEqual(b.url); + expect(a.projectId).not.toEqual(b.projectId); + // The bypass secrets the runtime clients send were minted pre-deploy. + expect(a.protectionBypass).toBeDefined(); + expect(b.protectionBypass).toBeDefined(); + + // A → B: A's handler invokes B's /echo through the capability client + // (bypass header + env-bound URL) and reports the runtime-bound URL. + const viaA = (yield* getJson(`${a.url}/call-b`)) as CallResponse; + expect(viaA.fn).toEqual("a"); + expect(viaA.targetUrl).toEqual(b.url); + expect(viaA.target.fn).toEqual("b"); + expect(viaA.target.echo).toContain("from=a"); + + // B → A: the other direction of the cycle. + const viaB = (yield* getJson(`${b.url}/call-a`)) as CallResponse; + expect(viaB.fn).toEqual("b"); + expect(viaB.targetUrl).toEqual(a.url); + expect(viaB.target.fn).toEqual("a"); + expect(viaB.target.echo).toContain("from=b"); + + yield* stack.destroy(); + yield* expectProjectGone(a.projectId); + yield* expectProjectGone(b.projectId); + }).pipe(logLevel), + { timeout: 180_000 }, +); + +test.provider( + "async env: one-way TARGET_URL/TARGET_BYPASS attribute binding", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const { caller, target } = yield* stack.deploy( + Effect.gen(function* () { + const target = yield* Vercel.Function("InvokeAsyncTarget", { + main: asyncTargetMain, + }); + const caller = yield* Vercel.Function("InvokeAsyncCaller", { + main: asyncCallerMain, + env: { + TARGET_URL: target.url, + TARGET_BYPASS: target.protectionBypass, + }, + }); + return { caller, target }; + }), + ); + expect(target.url).toMatch(/^https:\/\/.+\.vercel\.app$/); + expect(caller.url).toBeDefined(); + + // The caller replies 200 immediately with the INNER target-fetch + // status embedded — poll (bounded) until the target URL serves 200s + // through the caller, so fresh-URL propagation can't flake the test. + const result = yield* getJson(`${caller.url}/call`).pipe( + Effect.map((r) => r as { status: number; body: { echo: string } }), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (r) => r.status === 200, + times: 20, + }), + ); + expect(result.status).toBe(200); + expect(result.body.echo).toEqual("hi from caller"); + + yield* stack.destroy(); + yield* expectProjectGone(caller.projectId); + yield* expectProjectGone(target.projectId); + }).pipe(logLevel), + { timeout: 180_000 }, +); diff --git a/packages/alchemy/test/Vercel/Functions/Logs.test.ts b/packages/alchemy/test/Vercel/Functions/Logs.test.ts new file mode 100644 index 0000000000..950587460a --- /dev/null +++ b/packages/alchemy/test/Vercel/Functions/Logs.test.ts @@ -0,0 +1,130 @@ +/** + * Live test for the Vercel.Function `logs`/`tail` provider hooks — deploys a + * tiny function that `console.log`s a marker on every request, then drives + * the hooks resolved via `findProviderByType` (the exact path the + * `alchemy logs`/`alchemy tail` CLI commands take). + * + * Platform reality (PROBES.md): the runtime-logs endpoint is a live-only + * hanging stream with zero historical delivery, so both assertions capture + * lines by making requests WHILE a stream/window is open. + */ +import * as Provider from "@/Provider.ts"; +import type { LogLine } from "@/Provider.ts"; +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Schedule from "effect/Schedule"; +import * as Stream from "effect/Stream"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import { MinimumLogLevel } from "effect/References"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +const fixtureMain = new URL("./fixtures/logs-fn.ts", import.meta.url).pathname; + +// Fresh .vercel.app URLs take a few seconds to start serving 200s — always +// retry the first request (bounded). +const readiness = Schedule.max([ + Schedule.exponential("500 millis"), + Schedule.recurs(20), +]); + +const getOk = (url: string) => + HttpClient.get(url).pipe( + Effect.flatMap((response) => + response.status === 200 + ? Effect.void + : Effect.fail(new Error(`status ${response.status}`)), + ), + Effect.retry({ schedule: readiness }), + ); + +const MARKER = "alchemy-logs-probe"; + +test.provider( + "tail streams runtime request logs; logs captures a bounded window", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const fn = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.Function("LogsFn", { main: fixtureMain }); + }), + ); + expect(fn.url).toBeDefined(); + expect(fn.deploymentId).toBeDefined(); + + // Warm the URL so the tail loop's requests hit a serving deployment. + yield* getOk(`${fn.url}/warm`); + + // Resolve the SAME provider service the CLI commands use. + const provider = + yield* Provider.findProviderByType("Vercel.Function"); + expect(provider.tail).toBeDefined(); + expect(provider.logs).toBeDefined(); + + const hookInput = { + id: "LogsFn", + fqn: "LogsTestStack/LogsFn", + instanceId: fn.deploymentId, + props: { main: fixtureMain }, + output: fn, + }; + + // ── tail: collect the live stream while poking the function ──────── + const captured: LogLine[] = []; + const collector = yield* Effect.forkChild( + provider.tail!(hookInput).pipe( + Stream.runForEach((line) => Effect.sync(() => captured.push(line))), + ), + ); + + const sawTailLine = yield* Effect.gen(function* () { + yield* HttpClient.get(`${fn.url}/tail-probe`).pipe(Effect.ignore); + yield* Effect.sleep("3 seconds"); + return captured.some((line) => line.message.includes(MARKER)); + }).pipe( + Effect.repeat({ + schedule: Schedule.spaced("500 millis"), + until: (found) => found, + times: 10, + }), + ); + yield* Fiber.interrupt(collector); + expect(sawTailLine).toBe(true); + + // ── logs: a bounded read captures a request made inside its window ── + const sawLogsLine = yield* Effect.gen(function* () { + // Fire a request ~1.5s into the 5s live-capture window. + const requester = yield* Effect.forkChild( + Effect.sleep("1500 millis").pipe( + Effect.andThen(HttpClient.get(`${fn.url}/logs-probe`)), + Effect.ignore, + ), + ); + const lines = yield* provider.logs!({ + ...hookInput, + options: { limit: 200 }, + }); + yield* Fiber.await(requester); + return lines.some((line) => line.message.includes(MARKER)); + }).pipe( + Effect.repeat({ + until: (found) => found, + times: 2, + }), + ); + expect(sawLogsLine).toBe(true); + + yield* stack.destroy(); + }).pipe(logLevel), + { timeout: 150_000 }, +); diff --git a/packages/alchemy/test/Vercel/Functions/Tenancy.test.ts b/packages/alchemy/test/Vercel/Functions/Tenancy.test.ts new file mode 100644 index 0000000000..a12f0f2fa4 --- /dev/null +++ b/packages/alchemy/test/Vercel/Functions/Tenancy.test.ts @@ -0,0 +1,369 @@ +/** + * Preview tenancy — the cross-stage story (DESIGN §5.1), live against the + * standing Vercel test team (run with the doppler alchemy-v2/dev env). + * + * Topology under test: + * + * stage "staging" — deploys the shared `Vercel.Project` and a tenant + * `Vercel.Function` targeting PRODUCTION of it, and + * exports the project through the stack output. + * stage "pr7" — a preview tenant: deploys the same Function shape + * into the SHARED project via the cross-stage ref + * (`Alchemy.stackRef` → `project:`), landing an + * ADDITIVE preview deployment behind the stable + * per-stage alias `{projectName}-pr7.vercel.app`. + * stage "prc" — pins the MANDATORY cross-tenant env conflict check: + * a tenant env key that already exists in project env + * (`ALCHEMY_META`, the ownership stamp) is a typed + * `Vercel.TenantEnvConflict`, because project env WINS + * over per-deployment env (PROBES.md probe 3). + * + * Assertions: additive preview deployments (staging's production deployment + * and URL untouched), stable per-stage alias serving through the automation + * bypass secret (assign-created `.vercel.app` aliases are SSO-gated on team + * accounts), per-deployment env delivery via `.vc-config.json` (tenant keys + * NEVER appear in project env), skip-on-hash redeploy stability, PR-stage + * destroy that removes ONLY its alias + meta-stamped deployments, and the + * staging-destroy refusal while its tenant production deployment is active. + */ +import * as Alchemy from "@/index.ts"; +import type * as OutputNS from "@/Output.ts"; +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as aliases from "@distilled.cloud/vercel/aliases"; +import * as deployments from "@distilled.cloud/vercel/deployments"; +import * as projects from "@distilled.cloud/vercel/projects"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Result from "effect/Result"; +import * as Schedule from "effect/Schedule"; +import * as HttpClient from "effect/unstable/http/HttpClient"; + +const { test, deploy, destroy } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +const fixtureMain = new URL("./fixtures/handler.ts", import.meta.url).pathname; + +const APP = "vercel-fn-tenancy"; +const STAGING = "staging"; +const PR = "pr7"; +const CONFLICT = "prc"; + +/** staging: the shared project + its production owner (a tenant Function). */ +const StagingStack = Alchemy.Stack( + APP, + { providers: Vercel.providers(), state: Alchemy.localState() }, + Effect.gen(function* () { + const shared = yield* Vercel.Project("Shared"); + const prod = yield* Vercel.Function("Prod", { + main: fixtureMain, + project: shared.projectId, + target: "production", + env: { GREETING: "staging-production" }, + }); + return { + projectId: shared.projectId.as(), + projectName: shared.projectName.as(), + prodDeploymentId: prod.deploymentId.as(), + prodUrl: prod.url.as(), + }; + }), +); + +/** PR stage: a preview tenant of staging's project via the cross-stage ref. */ +const PreviewStack = Alchemy.Stack( + APP, + { providers: Vercel.providers(), state: Alchemy.localState() }, + Effect.gen(function* () { + const staging = (yield* Alchemy.stackRef<{ projectId: string }>(APP, { + stage: STAGING, + })) as unknown as OutputNS.ToOutput<{ projectId: string }>; + const fn = yield* Vercel.Function("PrFn", { + main: fixtureMain, + project: staging.projectId, + env: { GREETING: "pr-preview" }, + }); + return { + projectId: fn.projectId.as(), + deploymentId: fn.deploymentId.as(), + url: fn.url.as(), + alias: fn.stageAlias.as<{ uid: string; alias: string }>(), + }; + }), +); + +/** Conflict stage: tenant env key that already lives in project env. */ +const ConflictStack = Alchemy.Stack( + APP, + { providers: Vercel.providers(), state: Alchemy.localState() }, + Effect.gen(function* () { + const staging = (yield* Alchemy.stackRef<{ projectId: string }>(APP, { + stage: STAGING, + })) as unknown as OutputNS.ToOutput<{ projectId: string }>; + const fn = yield* Vercel.Function("Conflict", { + main: fixtureMain, + project: staging.projectId, + // ALCHEMY_META is the ownership stamp the Project resource keeps in + // PROJECT env — a guaranteed key conflict. + env: { ALCHEMY_META: "shadowed" }, + }); + return { deploymentId: fn.deploymentId.as() }; + }), +); + +/** Robust failure → text (aggregated causes may resist stringification). */ +const describeFailure = (failure: unknown): string => { + const base = String(failure); + try { + return `${base} ${JSON.stringify(failure)}`; + } catch { + return base; + } +}; + +const getJson = (url: string, bypass?: string) => + HttpClient.get( + url, + bypass !== undefined + ? { headers: { "x-vercel-protection-bypass": bypass } } + : undefined, + ).pipe( + Effect.flatMap((response) => + response.status === 200 + ? response.json + : Effect.fail(new Error(`status ${response.status}`)), + ), + // Fresh aliases/deployments take a few seconds to start serving — + // always retry the first request (bounded, ~20s worst case). + Effect.retry({ schedule: Schedule.spaced("1 second"), times: 20 }), + ); + +/** The project's automation bypass secret (minted by the tenant reconcile). */ +const readBypass = (projectId: string) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + const project = yield* projects.getProject({ idOrName: projectId, teamId }); + const entry = Object.entries(project.protectionBypass ?? {}).find( + ([, value]) => + (value as { scope?: string } | undefined)?.scope === + "automation-bypass", + ); + expect(entry).toBeDefined(); + return entry![0]; + }); + +/** Out-of-band deployment observation (undefined = gone). */ +const observeDeployment = (id: string) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + return yield* deployments + .getDeployment({ idOrUrl: id, teamId }) + .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))); + }); + +/** Out-of-band alias observation (undefined = gone). */ +const observeAlias = (aliasName: string) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + return yield* aliases + .getAlias({ idOrAlias: aliasName, teamId }) + .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))); + }); + +const projectEnvKeys = (projectId: string) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + const body = yield* projects.filterProjectEnvs({ + idOrName: projectId, + teamId, + }); + const rows = + typeof body === "object" && body !== null && "envs" in body + ? (body.envs as Array<{ key: string }>) + : []; + return rows.map((row) => row.key); + }); + +/** + * Teardown-only: retire the shared project's active production deployment + * so the staging destroy (which refuses to brick a live production) can + * proceed — the project itself is being retired. + */ +const retireProduction = (projectId: string) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + const page = yield* deployments.getDeployments({ + teamId, + projectId, + limit: 100, + }); + for (const d of page.deployments) { + if (d.target === "production") { + yield* deployments + .deleteDeployment({ id: d.uid, teamId }) + .pipe(Effect.catchTag("NotFound", () => Effect.void)); + } + } + }); + +/** Poll (bounded) until the shared project is gone. */ +const expectProjectGone = (projectId: string) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + const gone = yield* projects + .getProject({ idOrName: projectId, teamId }) + .pipe( + Effect.map(() => false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (g) => g, + times: 10, + }), + ); + expect(gone).toBe(true); + }); + +test.provider( + "preview tenancy: cross-stage tenant is additive, per-stage alias + per-deployment env, stage destroy leaves the shared project intact", + () => { + // Captured for the crash-safe teardown below. + let sharedProjectId: string | undefined; + + const teardown = Effect.gen(function* () { + yield* destroy(PreviewStack, { stage: PR }).pipe(Effect.ignore); + yield* destroy(ConflictStack, { stage: CONFLICT }).pipe(Effect.ignore); + if (sharedProjectId !== undefined) { + yield* retireProduction(sharedProjectId).pipe(Effect.ignore); + } + yield* destroy(StagingStack, { stage: STAGING }).pipe(Effect.ignore); + }); + + return Effect.gen(function* () { + // ── 1. Deploy the durable staging stage. A previously crashed run's + // leftovers converge here (deterministic names + engine state). + const staging = yield* deploy(StagingStack, { stage: STAGING }); + sharedProjectId = staging.projectId; + expect(staging.projectId).toBeDefined(); + expect(staging.prodDeploymentId).toBeDefined(); + + // The tenant reconcile minted the automation bypass BEFORE its first + // deploy (bypass secrets only open post-mint deployments). + const bypass = yield* readBypass(staging.projectId); + + // Staging's production serves on the project's production alias. + const prodBody = (yield* getJson(`${staging.prodUrl}/env`, bypass)) as { + greeting: string | null; + }; + expect(prodBody.greeting).toEqual("staging-production"); + + // ── 2. Deploy the PR stage as a preview tenant via the cross-stage + // reference — same shared project, ADDITIVE deployment. + const pr = yield* deploy(PreviewStack, { stage: PR }); + expect(pr.projectId).toEqual(staging.projectId); + expect(pr.deploymentId).not.toEqual(staging.prodDeploymentId); + + // Stable per-stage alias: {projectName}-{stage}.vercel.app. + expect(pr.alias).toBeDefined(); + expect(pr.alias.alias).toEqual(`${staging.projectName}-${PR}.vercel.app`); + expect(pr.url).toEqual(`https://${pr.alias.alias}`); + + // The PR deployment is a PREVIEW deployment (no production target)… + const prDep = yield* observeDeployment(pr.deploymentId); + expect(prDep).toBeDefined(); + expect(prDep!.target ?? null).toBeNull(); + + // …and staging's production deployment is UNTOUCHED (still promoted, + // still serving its own env). + const prodDep = yield* observeDeployment(staging.prodDeploymentId); + expect(prodDep).toBeDefined(); + expect(prodDep!.readyState).toEqual("READY"); + expect(prodDep!.readySubstate).toEqual("PROMOTED"); + + // ── 3. Per-stage alias serves the PR tenant through the bypass + // (SSO-gated on team accounts), with ITS per-deployment env. + const prBody = (yield* getJson(`${pr.url}/env`, bypass)) as { + greeting: string | null; + }; + expect(prBody.greeting).toEqual("pr-preview"); + + // Per-deployment delivery, proven from both directions: the two + // tenants see DIFFERENT values for the same key, and the key never + // landed in shared project env. + const envKeys = yield* projectEnvKeys(staging.projectId); + expect(envKeys).not.toContain("GREETING"); + + // ── 4. Identical redeploy: skip-on-hash keeps deployment AND alias. + const redeployed = yield* deploy(PreviewStack, { stage: PR }); + expect(redeployed.deploymentId).toEqual(pr.deploymentId); + expect(redeployed.alias.alias).toEqual(pr.alias.alias); + + // ── 5. The MANDATORY cross-tenant env conflict check: a tenant env + // key that exists in project env is a typed plan-time error + // (project env would silently shadow it). + const conflicted = yield* Effect.result( + deploy(ConflictStack, { stage: CONFLICT }), + ); + expect(Result.isFailure(conflicted)).toBe(true); + const conflictText = Result.isFailure(conflicted) + ? describeFailure(conflicted.failure) + : ""; + expect( + conflictText.includes("TenantEnvConflict") || + conflictText.includes("already exist in shared project"), + ).toBe(true); + yield* destroy(ConflictStack, { stage: CONFLICT }); + + // ── 6. PR stage destroy removes ONLY its alias + its meta-stamped + // deployments — the shared project and staging's production + // survive. + yield* destroy(PreviewStack, { stage: PR }); + expect(yield* observeAlias(pr.alias.alias)).toBeUndefined(); + const prGone = yield* observeDeployment(pr.deploymentId).pipe( + Effect.map((d) => d === undefined || d.readyState === "DELETED"), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (g) => g, + times: 10, + }), + ); + expect(prGone).toBe(true); + const prodStill = yield* observeDeployment(staging.prodDeploymentId); + expect(prodStill).toBeDefined(); + expect(prodStill!.readyState).toEqual("READY"); + const prodBodyAfter = (yield* getJson( + `${staging.prodUrl}/env`, + bypass, + )) as { greeting: string | null }; + expect(prodBodyAfter.greeting).toEqual("staging-production"); + + // ── 7. Staging destroy REFUSES while its tenant production + // deployment is active — deleting it would brick the site, not + // roll it back (PROBES.md probe 1). + const refused = yield* Effect.result( + destroy(StagingStack, { stage: STAGING }), + ); + expect(Result.isFailure(refused)).toBe(true); + const refusedText = Result.isFailure(refused) + ? describeFailure(refused.failure) + : ""; + expect( + refusedText.includes("TenantProductionDeleteRefused") || + refusedText.includes("refusing to delete deployment"), + ).toBe(true); + + // ── 8. Teardown: retire the production deployment out-of-band (the + // shared project is going away), then the destroy proceeds — + // tenant rows drain and the Project delete cascades. + yield* retireProduction(staging.projectId); + yield* destroy(StagingStack, { stage: STAGING }); + yield* expectProjectGone(staging.projectId); + }).pipe(Effect.ensuring(teardown), logLevel); + }, + { timeout: 180_000 }, +); diff --git a/packages/alchemy/test/Vercel/Functions/fixtures/effect-fn.ts b/packages/alchemy/test/Vercel/Functions/fixtures/effect-fn.ts new file mode 100644 index 0000000000..6dcd315588 --- /dev/null +++ b/packages/alchemy/test/Vercel/Functions/fixtures/effect-fn.ts @@ -0,0 +1,63 @@ +/** + * Effect-mode Vercel Function fixture (two-phase class pattern): exercises + * the runtime bridge — HttpServerRequest/HttpServerResponse handlers, + * post-response request finalizers, the `Function.URL` accessor, and a + * secret-guarded cron route. + */ +import * as Vercel from "@/Vercel/index.ts"; +import * as Effect from "effect/Effect"; +import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; + +// Instance-global counters: written by request finalizers / the cron +// handler, read back over HTTP on the same warm Fluid instance. +let finalized = 0; +let cronFires = 0; + +export default class EffectFn extends Vercel.Function()( + "EffectFn", + { + main: import.meta.url, + }, + Effect.gen(function* () { + const selfUrl = yield* Vercel.Function.URL; + yield* Vercel.cron( + "0 3 * * *", + Effect.sync(() => { + cronFires++; + }), + ); + + return { + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; + if (request.url.startsWith("/self")) { + return yield* HttpServerResponse.json({ url: yield* selfUrl }); + } + if (request.url.startsWith("/finalized")) { + return yield* HttpServerResponse.json({ finalized, cronFires }); + } + if (request.url.startsWith("/finalize")) { + // The finalizer sleeps well past the response: proves request + // finalizers settle post-response (waitUntil) instead of + // blocking the reply. + yield* Effect.addFinalizer(() => + Effect.sleep("3 seconds").pipe( + Effect.andThen( + Effect.sync(() => { + finalized++; + }), + ), + ), + ); + return yield* HttpServerResponse.json({ ok: true }); + } + if (request.url.startsWith("/slow")) { + yield* Effect.sleep("500 millis"); + return yield* HttpServerResponse.json({ slow: true }); + } + return yield* HttpServerResponse.json({ ok: true, path: request.url }); + }), + }; + }).pipe(Effect.provide(Vercel.CronEventSourceLive)), +) {} diff --git a/packages/alchemy/test/Vercel/Functions/fixtures/handler.ts b/packages/alchemy/test/Vercel/Functions/fixtures/handler.ts new file mode 100644 index 0000000000..e451f83bdf --- /dev/null +++ b/packages/alchemy/test/Vercel/Functions/fixtures/handler.ts @@ -0,0 +1,23 @@ +/** + * Async-mode Vercel Function fixture: a plain web-standard `{ fetch }` + * export — no Effect runtime, no bridge. Vercel's Node launcher accepts + * this shape natively. + */ +export default { + async fetch(request: Request): Promise { + const url = new URL(request.url); + if (url.pathname === "/env") { + return Response.json({ + greeting: process.env.GREETING ?? null, + secret: process.env.SECRET_VALUE !== undefined, + secretValue: process.env.SECRET_VALUE ?? null, + selfUrl: process.env.SELF_URL ?? null, + }); + } + if (url.pathname === "/echo" && request.method === "POST") { + const body = await request.text(); + return Response.json({ echo: body }); + } + return Response.json({ ok: true, path: url.pathname }); + }, +}; diff --git a/packages/alchemy/test/Vercel/Functions/fixtures/invoke-a.ts b/packages/alchemy/test/Vercel/Functions/fixtures/invoke-a.ts new file mode 100644 index 0000000000..ad77028569 --- /dev/null +++ b/packages/alchemy/test/Vercel/Functions/fixtures/invoke-a.ts @@ -0,0 +1,56 @@ +/** + * Effect-mode fixture A of the circular InvokeFunction pair: A imports B's + * class and binds `invoke(B)`; B closes the cycle by referencing A by + * logical id (see invoke-b.ts) so the module graph — and therefore the + * type graph — stays acyclic while the RUNTIME invoke topology is fully + * circular. Exercises the engine's pre-create story: both projects (URLs + * read back from the assigned production domain, bypass secrets minted) + * exist before either function deploys. + */ +import * as Vercel from "@/Vercel/index.ts"; +import * as Effect from "effect/Effect"; +import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import InvokeEchoB from "./invoke-b.ts"; + +export default class InvokeEchoA extends Vercel.Function()( + "InvokeEchoA", + { + main: import.meta.url, + }, + Effect.gen(function* () { + const b = yield* Vercel.invoke(InvokeEchoB); + + return { + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; + if (request.url.startsWith("/call-b")) { + // Round trip A → B: hits B's /echo through the bypass-carrying + // client and reports the runtime-bound target URL. + const res = yield* b.get("/echo?from=a").pipe(Effect.orDie); + const body = yield* res.json.pipe(Effect.orDie); + return yield* HttpServerResponse.json({ + fn: "a", + targetUrl: yield* b.url, + target: body, + }); + } + if (request.url.startsWith("/echo")) { + return yield* HttpServerResponse.json({ + fn: "a", + echo: request.url, + }); + } + return yield* HttpServerResponse.json({ ok: true, fn: "a" }); + }).pipe( + Effect.catchCause((cause) => + Effect.succeed( + HttpServerResponse.text(`a failed: ${String(cause)}`, { + status: 500, + }), + ), + ), + ), + }; + }).pipe(Effect.provide(Vercel.InvokeFunctionHttp)), +) {} diff --git a/packages/alchemy/test/Vercel/Functions/fixtures/invoke-async-caller.ts b/packages/alchemy/test/Vercel/Functions/fixtures/invoke-async-caller.ts new file mode 100644 index 0000000000..c4e7537061 --- /dev/null +++ b/packages/alchemy/test/Vercel/Functions/fixtures/invoke-async-caller.ts @@ -0,0 +1,29 @@ +/** + * Async-mode caller fixture for the one-way InvokeFunction env test: a + * plain web-standard `{ fetch }` export (no Effect runtime) that calls the + * target function through `TARGET_URL` (an attribute Output bound on the + * env channel), sending the target's protection-bypass secret when bound. + */ +export default { + async fetch(request: Request): Promise { + const url = new URL(request.url); + if (url.pathname === "/call") { + const target = process.env.TARGET_URL; + if (target === undefined || target === "") { + return Response.json({ error: "TARGET_URL not set" }, { status: 500 }); + } + const headers: Record = {}; + const bypass = process.env.TARGET_BYPASS; + if (bypass !== undefined && bypass !== "") { + headers["x-vercel-protection-bypass"] = bypass; + } + const res = await fetch(`${target}/echo`, { + method: "POST", + headers, + body: "hi from caller", + }); + return Response.json({ status: res.status, body: await res.json() }); + } + return Response.json({ ok: true }); + }, +}; diff --git a/packages/alchemy/test/Vercel/Functions/fixtures/invoke-b.ts b/packages/alchemy/test/Vercel/Functions/fixtures/invoke-b.ts new file mode 100644 index 0000000000..194fb2e572 --- /dev/null +++ b/packages/alchemy/test/Vercel/Functions/fixtures/invoke-b.ts @@ -0,0 +1,55 @@ +/** + * Effect-mode fixture B of the circular InvokeFunction pair — see + * invoke-a.ts. B binds `invoke({ LogicalId: "InvokeEchoA" })`, completing + * the A↔B cycle by logical-id forward reference instead of importing + * invoke-a.ts: a mutual class import would make each class's type depend + * on its own initializer via the sibling's impl (TS7022 → `any`), so the + * by-id form is the documented way to close a circular pair. + */ +import * as Vercel from "@/Vercel/index.ts"; +import * as Effect from "effect/Effect"; +import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; + +export default class InvokeEchoB extends Vercel.Function()( + "InvokeEchoB", + { + main: import.meta.url, + }, + Effect.gen(function* () { + // By-id forward reference — resolves through the engine's pre-created + // stub for InvokeEchoA, never through the sibling module. + const a = yield* Vercel.invoke({ LogicalId: "InvokeEchoA" }); + + return { + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; + if (request.url.startsWith("/call-a")) { + // Round trip B → A: proves the cycle works in both directions. + const res = yield* a.get("/echo?from=b").pipe(Effect.orDie); + const body = yield* res.json.pipe(Effect.orDie); + return yield* HttpServerResponse.json({ + fn: "b", + targetUrl: yield* a.url, + target: body, + }); + } + if (request.url.startsWith("/echo")) { + return yield* HttpServerResponse.json({ + fn: "b", + echo: request.url, + }); + } + return yield* HttpServerResponse.json({ ok: true, fn: "b" }); + }).pipe( + Effect.catchCause((cause) => + Effect.succeed( + HttpServerResponse.text(`b failed: ${String(cause)}`, { + status: 500, + }), + ), + ), + ), + }; + }).pipe(Effect.provide(Vercel.InvokeFunctionHttp)), +) {} diff --git a/packages/alchemy/test/Vercel/Functions/fixtures/local-async-fn.ts b/packages/alchemy/test/Vercel/Functions/fixtures/local-async-fn.ts new file mode 100644 index 0000000000..490ce563b3 --- /dev/null +++ b/packages/alchemy/test/Vercel/Functions/fixtures/local-async-fn.ts @@ -0,0 +1,31 @@ +/** + * Local-dev async fixture: web-standard default `{ fetch }` export (arm 1 + * of the launcher matrix). Reads the env the local provider injects. + */ +export default { + async fetch(request: Request): Promise { + const url = new URL(request.url); + if (url.pathname === "/env") { + return Response.json({ + greeting: process.env.GREETING ?? null, + vercel: process.env.VERCEL ?? null, + vercelEnv: process.env.VERCEL_ENV ?? null, + vercelUrl: process.env.VERCEL_URL ?? null, + deploymentId: process.env.VERCEL_DEPLOYMENT_ID ?? null, + selfUrl: process.env.SELF_URL ?? null, + }); + } + if (url.pathname === "/headers") { + return Response.json({ + vercelId: request.headers.get("x-vercel-id"), + country: request.headers.get("x-vercel-ip-country"), + proto: request.headers.get("x-forwarded-proto"), + host: request.headers.get("host"), + }); + } + if (url.pathname === "/echo" && request.method === "POST") { + return Response.json({ echo: await request.text() }); + } + return Response.json({ ok: true, path: url.pathname }); + }, +}; diff --git a/packages/alchemy/test/Vercel/Functions/fixtures/local-effect-fn.ts b/packages/alchemy/test/Vercel/Functions/fixtures/local-effect-fn.ts new file mode 100644 index 0000000000..fedce4efd4 --- /dev/null +++ b/packages/alchemy/test/Vercel/Functions/fixtures/local-effect-fn.ts @@ -0,0 +1,45 @@ +/** + * Local-dev Effect-mode fixture: the two-phase class pattern running + * through `makeVercelBridge` inside the dev shim — pins that the bridge + * entry works locally (mode inline, no cloud), that the cron event source + * registers its guarded route, and that injected env is visible. + */ +import * as Vercel from "@/Vercel/index.ts"; +import * as Effect from "effect/Effect"; +import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; + +// Instance-global counter: written by the cron handler, read back over +// HTTP on the same dev-server instance. +let cronFires = 0; + +export default class LocalEffectFn extends Vercel.Function()( + "LocalEffectFn", + { + main: import.meta.url, + }, + Effect.gen(function* () { + yield* Vercel.cron( + "0 3 * * *", + Effect.sync(() => { + cronFires++; + }), + ); + + return { + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; + if (request.url.startsWith("/cron-fires")) { + return yield* HttpServerResponse.json({ cronFires }); + } + if (request.url.startsWith("/env")) { + return yield* HttpServerResponse.json({ + vercelEnv: process.env.VERCEL_ENV ?? null, + deploymentId: process.env.VERCEL_DEPLOYMENT_ID ?? null, + }); + } + return yield* HttpServerResponse.json({ ok: true, path: request.url }); + }), + }; + }).pipe(Effect.provide(Vercel.CronEventSourceLive)), +) {} diff --git a/packages/alchemy/test/Vercel/Functions/fixtures/local-method-fn.ts b/packages/alchemy/test/Vercel/Functions/fixtures/local-method-fn.ts new file mode 100644 index 0000000000..dbfec8a8a3 --- /dev/null +++ b/packages/alchemy/test/Vercel/Functions/fixtures/local-method-fn.ts @@ -0,0 +1,12 @@ +/** + * Local-dev async fixture: method exports (arm 3 of the launcher matrix). + * No default export — the shim must route by request method, serve HEAD + * with GET (body stripped), and 405 unexported methods. + */ +export function GET(request: Request): Response { + return Response.json({ method: "GET", path: new URL(request.url).pathname }); +} + +export async function POST(request: Request): Promise { + return Response.json({ method: "POST", echo: await request.text() }); +} diff --git a/packages/alchemy/test/Vercel/Functions/fixtures/local-node-fn.ts b/packages/alchemy/test/Vercel/Functions/fixtures/local-node-fn.ts new file mode 100644 index 0000000000..5f22edc42c --- /dev/null +++ b/packages/alchemy/test/Vercel/Functions/fixtures/local-node-fn.ts @@ -0,0 +1,14 @@ +/** + * Local-dev async fixture: legacy Node `(req, res)` default export (arm 2 + * of the launcher matrix — detected by the handler's 2-parameter arity). + */ +import type { IncomingMessage, ServerResponse } from "node:http"; + +export default function handler( + req: IncomingMessage, + res: ServerResponse, +): void { + res.statusCode = 200; + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ node: true, url: req.url ?? null })); +} diff --git a/packages/alchemy/test/Vercel/Functions/fixtures/logs-fn.ts b/packages/alchemy/test/Vercel/Functions/fixtures/logs-fn.ts new file mode 100644 index 0000000000..c1678c37c4 --- /dev/null +++ b/packages/alchemy/test/Vercel/Functions/fixtures/logs-fn.ts @@ -0,0 +1,12 @@ +/** + * Fixture for the logs/tail wiring test: logs a recognizable marker line on + * every request so the runtime-logs stream has something deterministic to + * capture. + */ +export default { + async fetch(request: Request): Promise { + const url = new URL(request.url); + console.log(`alchemy-logs-probe ${url.pathname}`); + return Response.json({ ok: true, path: url.pathname }); + }, +}; diff --git a/packages/alchemy/test/Vercel/Microfrontends/MicrofrontendsGroup.test.ts b/packages/alchemy/test/Vercel/Microfrontends/MicrofrontendsGroup.test.ts new file mode 100644 index 0000000000..db53315391 --- /dev/null +++ b/packages/alchemy/test/Vercel/Microfrontends/MicrofrontendsGroup.test.ts @@ -0,0 +1,160 @@ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as microfrontends from "@distilled.cloud/vercel/microfrontends"; +import * as projects from "@distilled.cloud/vercel/projects"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +/** + * ⚠️ BILLING GATE — Microfrontends is a PAID flat-rate add-on: enabling it + * bills **$250/month per group, immediately**. Three ungated runs of this + * suite billed $750 on 2026-08-13. Every live test here MUST stay behind + * VERCEL_TEST_MICROFRONTENDS=1; never remove this gate without an explicit + * billing sign-off. + */ +const MFE_BILLING_GATE = !process.env.VERCEL_TEST_MICROFRONTENDS; + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +// Deterministic host-project names. +const HOST_DEFAULT = "alchemy-mfe-host-default"; +const HOST_CHILD = "alchemy-mfe-host-child"; +const GROUP_RENAMED = "alchemy-test-mfe-renamed"; + +const teamScopeOf = Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + return teamId === undefined ? {} : { teamId }; +}); + +// Out-of-band host-project fixtures (NOT the Vercel.Project resource). +const ensureHostProject = (name: string, scope: { teamId?: string }) => + Effect.gen(function* () { + yield* projects + .deleteProject({ idOrName: name, ...scope }) + .pipe(Effect.catchTag("NotFound", () => Effect.void)); + const created = yield* projects.createProject({ name, ...scope }); + return created.id; + }); + +const deleteHostProject = (name: string, scope: { teamId?: string }) => + projects.deleteProject({ idOrName: name, ...scope }).pipe(Effect.ignore); + +const findGroup = (groupId: string, scope: { teamId?: string }) => + microfrontends + .getMicrofrontendsGroups(scope) + .pipe( + Effect.map(({ groups }) => groups.find((g) => g.group.id === groupId)), + ); + +/** Bounded typed wait-until-gone (the group listing is the observe surface). */ +const expectGroupGone = (groupId: string, scope: { teamId?: string }) => + Effect.gen(function* () { + const gone = yield* findGroup(groupId, scope).pipe( + Effect.map((entry) => entry === undefined), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (g) => g, + times: 10, + }), + ); + expect(gone).toBe(true); + }); + +test.provider.skipIf(MFE_BILLING_GATE)( + "create, sync membership, rename, and destroy a microfrontends group", + (stack) => + Effect.gen(function* () { + const scope = yield* teamScopeOf; + const defaultId = yield* ensureHostProject(HOST_DEFAULT, scope); + const childId = yield* ensureHostProject(HOST_CHILD, scope); + yield* Effect.gen(function* () { + yield* stack.destroy(); + + // Create with the engine-generated deterministic name, a default + // app, and one child application. + const created = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.MicrofrontendsGroup("Group", { + defaultApp: { projectId: defaultId }, + applications: [{ projectId: childId, defaultRoute: "/docs" }], + }); + }), + ); + expect(created.groupId).toMatch(/^mfe_/); + expect(created.defaultAppProjectId).toEqual(defaultId); + expect(created.applicationProjectIds).toEqual([childId]); + + // Out-of-band verification via distilled: group listed, both + // projects enabled, child carries its default route. + const observed = yield* findGroup(created.groupId, scope); + expect(observed).toBeDefined(); + expect(observed?.group.name).toEqual(created.name); + const observedChild = observed?.projects.find((p) => p.id === childId); + expect(observedChild?.microfrontends?.enabled).toEqual(true); + expect(observedChild?.microfrontends?.defaultRoute).toEqual("/docs"); + expect(observedChild?.microfrontends?.isDefaultApp).toEqual(false); + + // No-op redeploy: same identity. + const second = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.MicrofrontendsGroup("Group", { + defaultApp: { projectId: defaultId }, + applications: [{ projectId: childId, defaultRoute: "/docs" }], + }); + }), + ); + expect(second.groupId).toEqual(created.groupId); + + // Rename + change the child's default route — update, not replace. + const renamed = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.MicrofrontendsGroup("Group", { + name: GROUP_RENAMED, + defaultApp: { projectId: defaultId }, + applications: [{ projectId: childId, defaultRoute: "/kb" }], + }); + }), + ); + expect(renamed.groupId).toEqual(created.groupId); + expect(renamed.name).toEqual(GROUP_RENAMED); + + const observedRenamed = yield* findGroup(created.groupId, scope); + expect(observedRenamed?.group.name).toEqual(GROUP_RENAMED); + expect( + observedRenamed?.projects.find((p) => p.id === childId) + ?.microfrontends?.defaultRoute, + ).toEqual("/kb"); + + // Remove the child — membership converges exactly. + const shrunk = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.MicrofrontendsGroup("Group", { + name: GROUP_RENAMED, + defaultApp: { projectId: defaultId }, + applications: [], + }); + }), + ); + expect(shrunk.groupId).toEqual(created.groupId); + expect(shrunk.applicationProjectIds).toEqual([]); + + yield* stack.destroy(); + yield* expectGroupGone(created.groupId, scope); + }).pipe( + Effect.ensuring( + Effect.all([ + deleteHostProject(HOST_DEFAULT, scope), + deleteHostProject(HOST_CHILD, scope), + ]), + ), + ); + }).pipe(logLevel), + { timeout: 120_000 }, +); diff --git a/packages/alchemy/test/Vercel/ProjectMembers/ProjectMember.test.ts b/packages/alchemy/test/Vercel/ProjectMembers/ProjectMember.test.ts new file mode 100644 index 0000000000..26eb6f7d09 --- /dev/null +++ b/packages/alchemy/test/Vercel/ProjectMembers/ProjectMember.test.ts @@ -0,0 +1,208 @@ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as projectMembers from "@distilled.cloud/vercel/project_members"; +import * as projects from "@distilled.cloud/vercel/projects"; +import * as teams from "@distilled.cloud/vercel/teams"; +import * as user from "@distilled.cloud/vercel/user"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Result from "effect/Result"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +const teamScope = Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + return teamId !== undefined ? { teamId } : {}; +}); + +const withHostProject = ( + hostName: string, + body: (projectId: string) => Effect.Effect, +) => + Effect.gen(function* () { + const team = yield* teamScope; + const host = yield* projects + .createProject({ name: hostName, ...team }) + .pipe( + Effect.map((p) => ({ id: p.id })), + Effect.catchTag("Conflict", () => + projects + .getProject({ idOrName: hostName, ...team }) + .pipe(Effect.map((p) => ({ id: p.id }))), + ), + ); + return yield* body(host.id).pipe( + Effect.ensuring( + projects + .deleteProject({ idOrName: host.id, ...team }) + .pipe(Effect.ignore), + ), + ); + }); + +/** + * Ungated probes: on this account the API token belongs to the team OWNER, + * whom the platform refuses to assign any project role + * (`invalid_team_and_project_role_combination`). These tests pin the typed + * error surface and the member-list decode at near-zero cost; the full + * lifecycle below is env-gated for accounts with a non-owner member. + */ +test.provider( + "member list decodes on a fresh project; owner add and bogus removal are typed", + (stack) => + withHostProject("alchemy-test-members-probe", (projectId) => + Effect.gen(function* () { + yield* stack.destroy(); + const team = yield* teamScope; + + // Fresh project: `{members: [], pagination: {}}` decodes cleanly + // (pins the response patch — the live pagination object is empty). + const empty = yield* projectMembers.getProjectMembers({ + idOrName: projectId, + ...team, + }); + expect(empty.members).toHaveLength(0); + + // Adding the token user (team OWNER) is rejected with a typed + // BadRequest carrying the role-combination code. + const me = yield* user.getAuthUser({}); + const rejection = yield* Effect.result( + projectMembers.addProjectMember({ + idOrName: projectId, + ...team, + uid: me.user.id, + role: "PROJECT_DEVELOPER", + }), + ); + expect(Result.isFailure(rejection)).toBe(true); + if (Result.isFailure(rejection)) { + const error = rejection.failure; + expect(error._tag).toEqual("BadRequest"); + if (error._tag === "BadRequest") { + expect(error.message).toContain("Invalid role combination"); + } + } + + // Removing a non-member surfaces as a typed NotFound (pins the + // 404 patch on removeProjectMember). + const removal = yield* Effect.result( + projectMembers.removeProjectMember({ + idOrName: projectId, + uid: me.user.id, + ...team, + }), + ); + expect(Result.isFailure(removal)).toBe(true); + if (Result.isFailure(removal)) { + expect(removal.failure._tag).toEqual("NotFound"); + } + + // Member ops on a MISSING project surface as typed NotFound (pins + // the 404 patch on getProjectMembers). + const gone = yield* Effect.result( + projectMembers.getProjectMembers({ + idOrName: "prj_alchemy_does_not_exist_123", + ...team, + }), + ); + expect(Result.isFailure(gone)).toBe(true); + if (Result.isFailure(gone)) { + expect(gone.failure._tag).toEqual("NotFound"); + } + }), + ).pipe(logLevel), + { timeout: 60_000 }, +); + +/** + * Full lifecycle — requires a team with at least one confirmed non-OWNER + * member (the platform refuses project roles for OWNERs, and the testing + * team has only its owner). Run with VERCEL_TEST_PROJECT_MEMBERS=1 on an + * entitled team. + */ +test.provider.skipIf(!process.env.VERCEL_TEST_PROJECT_MEMBERS)( + "member lifecycle: add, no-op redeploy, role change in place, remove on destroy", + (stack) => + withHostProject("alchemy-test-members-life", (projectId) => + Effect.gen(function* () { + yield* stack.destroy(); + const { teamId } = yield* Vercel.VercelEnvironment.current; + if (teamId === undefined) throw new Error("test requires team scope"); + + // Find a confirmed non-owner member to manage. + const teamMembers = yield* teams.getTeamMembers({ + teamId, + limit: 100, + }); + const candidate = teamMembers.members.find( + (m) => m.confirmed && m.role !== "OWNER", + ); + if (candidate === undefined) { + throw new Error( + "VERCEL_TEST_PROJECT_MEMBERS requires a confirmed non-OWNER team member", + ); + } + + const created = yield* stack.deploy( + Vercel.ProjectMember("Member", { + projectId, + uid: candidate.uid, + role: "PROJECT_DEVELOPER", + }), + ); + expect(created.projectId).toEqual(projectId); + expect(created.uid).toEqual(candidate.uid); + expect(created.role).toEqual("PROJECT_DEVELOPER"); + expect(created.createdAt).toBeGreaterThan(0); + + // Out-of-band: the membership is visible. + const team = yield* teamScope; + const observed = yield* projectMembers.getProjectMembers({ + idOrName: projectId, + ...team, + }); + expect(observed.members.some((m) => m.uid === candidate.uid)).toEqual( + true, + ); + + // Identical redeploy is a no-op. + const noop = yield* stack.deploy( + Vercel.ProjectMember("Member", { + projectId, + uid: candidate.uid, + role: "PROJECT_DEVELOPER", + }), + ); + expect(noop.createdAt).toEqual(created.createdAt); + + // Role change is applied in place (remove + re-add), not a replace. + const changed = yield* stack.deploy( + Vercel.ProjectMember("Member", { + projectId, + uid: candidate.uid, + role: "PROJECT_VIEWER", + }), + ); + expect(changed.uid).toEqual(candidate.uid); + expect(changed.role).toEqual("PROJECT_VIEWER"); + + // Destroy removes the membership; second destroy is idempotent. + yield* stack.destroy(); + const afterDestroy = yield* projectMembers.getProjectMembers({ + idOrName: projectId, + ...team, + }); + expect( + afterDestroy.members.some((m) => m.uid === candidate.uid), + ).toEqual(false); + yield* stack.destroy(); + }), + ).pipe(logLevel), + { timeout: 120_000 }, +); diff --git a/packages/alchemy/test/Vercel/Projects/Env.test.ts b/packages/alchemy/test/Vercel/Projects/Env.test.ts new file mode 100644 index 0000000000..624746ba40 --- /dev/null +++ b/packages/alchemy/test/Vercel/Projects/Env.test.ts @@ -0,0 +1,343 @@ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as projects from "@distilled.cloud/vercel/projects"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import { MinimumLogLevel } from "effect/References"; +import * as Result from "effect/Result"; +import * as Schedule from "effect/Schedule"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +// Deterministic host-project names — one per test so concurrently running +// tests never fight over a fixture. +const HOST_LIFECYCLE = "alchemy-project-env-host-lifecycle"; +const HOST_SENSITIVE = "alchemy-project-env-host-sensitive"; +const HOST_BRANCH = "alchemy-project-env-host-branch"; + +const teamScopeOf = Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + return teamId === undefined ? {} : { teamId }; +}); + +// Out-of-band host-project fixture (NOT the Vercel.Project resource — these +// tests must not depend on a concurrently-owned provider). Delete-if-exists +// first so an interrupted previous run can't wedge the deterministic name. +const ensureHostProject = (name: string, scope: { teamId?: string }) => + Effect.gen(function* () { + yield* projects + .deleteProject({ idOrName: name, ...scope }) + .pipe(Effect.catchTag("NotFound", () => Effect.void)); + const created = yield* projects.createProject({ name, ...scope }); + return created.id; + }); + +// Finalizer-safe (used with `Effect.ensuring`): never fails. +const deleteHostProject = (name: string, scope: { teamId?: string }) => + projects.deleteProject({ idOrName: name, ...scope }).pipe(Effect.ignore); + +/** Observed rows for a key (normalized across the response union). */ +const rowsForKey = ( + projectId: string, + key: string, + scope: { teamId?: string }, +) => + Effect.gen(function* () { + const body = yield* projects.filterProjectEnvs({ + idOrName: projectId, + decrypt: "true", + ...scope, + }); + const rows = + typeof body === "object" && body !== null && "envs" in body + ? (body.envs as Array<{ + id?: string; + key: string; + value?: string; + type: string; + comment?: string; + target?: unknown; + gitBranch?: string | null; + }>) + : []; + return rows.filter((row) => row.key === key); + }); + +/** + * Decrypted single-row value. The LISTING returns `encrypted` values as a + * sealed ciphertext envelope on teams with v2 env encryption (live reality + * on this team — see PROBES.md), but the single-env GET returns plaintext. + */ +const decryptedValue = ( + projectId: string, + envId: string, + scope: { teamId?: string }, +) => + Effect.gen(function* () { + const row = yield* projects.getProjectEnv({ + idOrName: projectId, + id: envId, + ...scope, + }); + return (row as { value?: string }).value; + }); + +/** Bounded typed wait until no row with the key remains. */ +const expectKeyGone = ( + projectId: string, + key: string, + scope: { teamId?: string }, +) => + Effect.gen(function* () { + const gone = yield* rowsForKey(projectId, key, scope).pipe( + Effect.map((rows) => rows.length === 0), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (g) => g, + times: 10, + }), + ); + expect(gone).toBe(true); + }); + +test.provider( + "create, no-op, update in place, replace on key change, destroy", + (stack) => + Effect.gen(function* () { + const scope = yield* teamScopeOf; + const projectId = yield* ensureHostProject(HOST_LIFECYCLE, scope); + yield* Effect.gen(function* () { + yield* stack.destroy(); + + const created = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.ProjectEnv("Flag", { + project: projectId, + key: "FLAG", + value: "on", + target: ["production"], + }); + }), + ); + expect(created.projectId).toEqual(projectId); + expect(created.envId).not.toEqual(""); + expect(created.key).toEqual("FLAG"); + expect(created.type).toEqual("encrypted"); + expect(created.target).toEqual(["production"]); + expect(created.fingerprint).toMatch(/^alchemy:sha256:/); + + // Out-of-band verification via distilled. The listing's value is a + // sealed ciphertext envelope on v2-env-encryption teams, so the + // decrypted value comes from the single-env GET. + const observed = yield* rowsForKey(projectId, "FLAG", scope); + expect(observed.length).toEqual(1); + expect(yield* decryptedValue(projectId, created.envId, scope)).toEqual( + "on", + ); + + // No-op redeploy: same identity, no spurious write. + const second = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.ProjectEnv("Flag", { + project: projectId, + key: "FLAG", + value: "on", + target: ["production"], + }); + }), + ); + expect(second.envId).toEqual(created.envId); + expect(second.updatedAt).toEqual(created.updatedAt); + + // Update in place: value + targets + comment. + const updated = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.ProjectEnv("Flag", { + project: projectId, + key: "FLAG", + value: "off", + target: ["production", "preview"], + comment: "feature flag", + }); + }), + ); + expect(updated.envId).toEqual(created.envId); + expect(updated.target).toEqual(["preview", "production"]); + expect(updated.comment).toEqual("feature flag"); + expect(yield* decryptedValue(projectId, updated.envId, scope)).toEqual( + "off", + ); + + // Renaming the key replaces the resource (new row, old key gone). + const replaced = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.ProjectEnv("Flag", { + project: projectId, + key: "FLAG_V2", + value: "off", + target: ["production"], + }); + }), + ); + expect(replaced.key).toEqual("FLAG_V2"); + expect(replaced.envId).not.toEqual(created.envId); + yield* expectKeyGone(projectId, "FLAG", scope); + + yield* stack.destroy(); + yield* expectKeyGone(projectId, "FLAG_V2", scope); + + // Idempotent destroy: a second destroy of the (now empty) stack + // must not fail even though the row is gone. + yield* stack.destroy(); + }).pipe(Effect.ensuring(deleteHostProject(HOST_LIFECYCLE, scope))); + }).pipe(logLevel), +); + +test.provider( + "sensitive env var: Redacted value, write-only drift via fingerprint", + (stack) => + Effect.gen(function* () { + const scope = yield* teamScopeOf; + const projectId = yield* ensureHostProject(HOST_SENSITIVE, scope); + yield* Effect.gen(function* () { + yield* stack.destroy(); + + const created = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.ProjectEnv("Secret", { + project: projectId, + key: "API_SECRET", + value: Redacted.make("hunter2-v1"), + }); + }), + ); + expect(created.type).toEqual("sensitive"); + // `development` is dropped from sensitive targets (live-verified: + // the API rejects it). + expect(created.target).toEqual(["preview", "production"]); + expect(created.fingerprint).toMatch(/^alchemy:sha256:/); + + // Live-verified: sensitive rows list with an EMPTY value even with + // decrypt=true — the value is write-only. + const observed = yield* rowsForKey(projectId, "API_SECRET", scope); + expect(observed.length).toEqual(1); + expect(observed[0]!.type).toEqual("sensitive"); + expect(observed[0]!.value ?? "").toEqual(""); + + // No-op redeploy: unchanged fingerprint, no spurious write. + const second = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.ProjectEnv("Secret", { + project: projectId, + key: "API_SECRET", + value: Redacted.make("hunter2-v1"), + }); + }), + ); + expect(second.envId).toEqual(created.envId); + expect(second.updatedAt).toEqual(created.updatedAt); + expect(second.fingerprint).toEqual(created.fingerprint); + + // Rotation: the value is unobservable, so drift is detected via + // the persisted fingerprint and the row is rewritten. + const rotated = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.ProjectEnv("Secret", { + project: projectId, + key: "API_SECRET", + value: Redacted.make("hunter2-v2"), + }); + }), + ); + expect(rotated.fingerprint).not.toEqual(created.fingerprint); + + yield* stack.destroy(); + yield* expectKeyGone(projectId, "API_SECRET", scope); + }).pipe(Effect.ensuring(deleteHostProject(HOST_SENSITIVE, scope))); + }).pipe(logLevel), +); + +// Branch-scoped env vars require the project to have a CONNECTED GIT +// REPOSITORY (live-verified: the API rejects gitBranch on unconnected +// projects), and connecting a repo needs a Git-provider install no test can +// perform. Ungated: pin the exact typed rejection. Gated: the full lifecycle +// runs against a git-connected project named via VERCEL_TEST_GIT_PROJECT. +const GIT_PROJECT = process.env.VERCEL_TEST_GIT_PROJECT; + +test.provider( + "gitBranch on a project without a connected Git repository: typed BadRequest", + () => + Effect.gen(function* () { + const scope = yield* teamScopeOf; + const projectId = yield* ensureHostProject(HOST_BRANCH, scope); + yield* Effect.gen(function* () { + const result = yield* Effect.result( + projects.createProjectEnv({ + idOrName: projectId, + upsert: "true", + ...scope, + body: [ + { + key: "API_URL", + value: "https://staging.api.example.com", + type: "encrypted", + target: ["preview"], + gitBranch: "staging", + }, + ], + }), + ); + if (Result.isSuccess(result)) { + // Some plans may accept it via the per-item failed[] envelope + // instead of a top-level 400 — accept either rejection shape. + expect(result.success.failed.length).toBeGreaterThan(0); + return; + } + expect(result.failure._tag).toBe("BadRequest"); + if (result.failure._tag === "BadRequest") { + expect(result.failure.message).toContain("connected Git repository"); + } + }).pipe(Effect.ensuring(deleteHostProject(HOST_BRANCH, scope))); + }).pipe(logLevel), +); + +test.provider.skipIf(!GIT_PROJECT)( + "branch-scoped preview variable on a git-connected project", + (stack) => + Effect.gen(function* () { + const scope = yield* teamScopeOf; + const projectId = GIT_PROJECT!; + yield* stack.destroy(); + + const created = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.ProjectEnv("StagingUrl", { + project: projectId, + key: "ALCHEMY_TEST_BRANCH_URL", + value: "https://staging.api.example.com", + target: ["preview"], + gitBranch: "staging", + }); + }), + ); + expect(created.target).toEqual(["preview"]); + expect(created.gitBranch).toEqual("staging"); + + const observed = yield* rowsForKey( + projectId, + "ALCHEMY_TEST_BRANCH_URL", + scope, + ); + expect(observed.length).toEqual(1); + expect(observed[0]!.gitBranch).toEqual("staging"); + + yield* stack.destroy(); + yield* expectKeyGone(projectId, "ALCHEMY_TEST_BRANCH_URL", scope); + }).pipe(logLevel), +); diff --git a/packages/alchemy/test/Vercel/Projects/Project.test.ts b/packages/alchemy/test/Vercel/Projects/Project.test.ts new file mode 100644 index 0000000000..edf0b4ce5e --- /dev/null +++ b/packages/alchemy/test/Vercel/Projects/Project.test.ts @@ -0,0 +1,116 @@ +/** + * Vercel Project lifecycle tests. + * + * NOTE: the standing Vercel test team is currently SUSPENDED (billing + * expired) — every resource-creating call answers a typed + * `PaymentRequired` (402 `resource_creation_blocked`). These tests are + * written and ready to run once billing is reactivated; until then they + * fail fast with that typed tag. + */ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as projects from "@distilled.cloud/vercel/projects"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; +import { MinimumLogLevel } from "effect/References"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +/** Poll (bounded) until the project is gone — typed wait-until-gone. */ +const expectProjectGone = (projectId: string) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + const gone = yield* projects + .getProject({ idOrName: projectId, teamId }) + .pipe( + Effect.map(() => false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (g) => g, + times: 10, + }), + ); + expect(gone).toBe(true); + }); + +test.provider( + "create, update, and delete a project", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const created = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.Project("DefaultProject"); + }), + ); + + expect(created.projectId).toBeDefined(); + expect(created.projectName).toBeDefined(); + + // Out-of-band verification via distilled. + const { teamId } = yield* Vercel.VercelEnvironment.current; + const fetched = yield* projects.getProject({ + idOrName: created.projectId, + teamId, + }); + expect(fetched.id).toEqual(created.projectId); + expect(fetched.name).toEqual(created.projectName); + + // The ownership stamp (ALCHEMY_META) must be present on the project. + const envs = yield* projects.filterProjectEnvs({ + idOrName: created.projectId, + teamId, + decrypt: "true", + }); + const rows = Array.isArray(envs) + ? envs + : typeof envs === "object" && envs !== null && "envs" in envs + ? envs.envs + : []; + expect( + rows.some((row: { key: string }) => row.key === "ALCHEMY_META"), + ).toBe(true); + + // Update: settings converge without replacement. + const updated = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.Project("DefaultProject", { + nodeVersion: "22.x", + }); + }), + ); + expect(updated.projectId).toEqual(created.projectId); + expect(updated.nodeVersion).toEqual("22.x"); + + yield* stack.destroy(); + yield* expectProjectGone(created.projectId); + }).pipe(logLevel), + { timeout: 120_000 }, +); + +test.provider( + "project with default props does not change on redeploy", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const deploy = stack.deploy(Vercel.Project("StableProject")); + const created = yield* deploy; + const redeployed = yield* deploy; + + expect(redeployed.projectId).toEqual(created.projectId); + expect(redeployed.projectName).toEqual(created.projectName); + + yield* stack.destroy(); + yield* expectProjectGone(created.projectId); + }).pipe(logLevel), + { timeout: 120_000 }, +); diff --git a/packages/alchemy/test/Vercel/Projects/RollingRelease.test.ts b/packages/alchemy/test/Vercel/Projects/RollingRelease.test.ts new file mode 100644 index 0000000000..3b2c94935d --- /dev/null +++ b/packages/alchemy/test/Vercel/Projects/RollingRelease.test.ts @@ -0,0 +1,175 @@ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as projects from "@distilled.cloud/vercel/projects"; +import * as rolling_release from "@distilled.cloud/vercel/rolling_release"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Result from "effect/Result"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +// Deterministic host-project names — one per test so concurrently running +// tests never fight over a fixture. +const HOST_LIFECYCLE = "alchemy-rolling-release-host-lifecycle"; +const HOST_PROBE = "alchemy-rolling-release-host-probe"; + +const teamScopeOf = Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + return teamId === undefined ? {} : { teamId }; +}); + +// Out-of-band host-project fixture (NOT the Vercel.Project resource — these +// tests must not depend on a concurrently-owned provider). Delete-if-exists +// first so an interrupted previous run can't wedge the deterministic name. +const ensureHostProject = (name: string, scope: { teamId?: string }) => + Effect.gen(function* () { + yield* projects + .deleteProject({ idOrName: name, ...scope }) + .pipe(Effect.catchTag("NotFound", () => Effect.void)); + const created = yield* projects.createProject({ name, ...scope }); + return created.id; + }); + +// Finalizer-safe (used with `Effect.ensuring`): never fails. +const deleteHostProject = (name: string, scope: { teamId?: string }) => + projects.deleteProject({ idOrName: name, ...scope }).pipe(Effect.ignore); + +test.provider( + "create, no-op, update, and destroy a rolling release config", + (stack) => + Effect.gen(function* () { + const scope = yield* teamScopeOf; + const projectId = yield* ensureHostProject(HOST_LIFECYCLE, scope); + yield* Effect.gen(function* () { + yield* stack.destroy(); + + const created = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.RollingRelease("Rollout", { + project: projectId, + advancementType: "manual-approval", + stages: [ + { targetPercentage: 10, requireApproval: true }, + { targetPercentage: 100 }, + ], + }); + }), + ); + expect(created.projectId).toEqual(projectId); + expect(created.target).toEqual("production"); + expect(created.advancementType).toEqual("manual-approval"); + expect(created.stages).toEqual([ + { targetPercentage: 10, requireApproval: true }, + { targetPercentage: 100 }, + ]); + expect(created.canaryResponseHeader).toEqual(false); + + // Out-of-band verification via distilled. + const observed = yield* rolling_release.getRollingReleaseConfig({ + idOrName: projectId, + ...scope, + }); + expect(observed.rollingRelease).not.toBeNull(); + expect(observed.rollingRelease?.target).toEqual("production"); + expect(observed.rollingRelease?.stages?.length).toEqual(2); + + // No-op redeploy: same config, converges without drift. + const second = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.RollingRelease("Rollout", { + project: projectId, + advancementType: "manual-approval", + stages: [ + { targetPercentage: 10, requireApproval: true }, + { targetPercentage: 100 }, + ], + }); + }), + ); + expect(second.stages).toEqual(created.stages); + + // Update in place: automatic advancement + canary header. + const updated = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.RollingRelease("Rollout", { + project: projectId, + advancementType: "automatic", + stages: [ + { targetPercentage: 25, duration: 5 }, + { targetPercentage: 100 }, + ], + canaryResponseHeader: true, + }); + }), + ); + expect(updated.advancementType).toEqual("automatic"); + expect(updated.stages).toEqual([ + { targetPercentage: 25, duration: 5 }, + { targetPercentage: 100 }, + ]); + expect(updated.canaryResponseHeader).toEqual(true); + + yield* stack.destroy(); + // Live-verified: a deleted config reads back as null. + const afterDestroy = yield* rolling_release.getRollingReleaseConfig({ + idOrName: projectId, + ...scope, + }); + expect(afterDestroy.rollingRelease).toBeNull(); + + // Idempotent destroy: a second destroy of the (now empty) stack + // must not fail even though the config is gone. + yield* stack.destroy(); + }).pipe(Effect.ensuring(deleteHostProject(HOST_LIFECYCLE, scope))); + }).pipe(logLevel), +); + +// Ungated probes: billing entitlement surface + the patched PATCH body. +// These pin (a) the typed billing union, and (b) that the distilled +// requestBody patch actually puts the body on the wire (the API validates +// the stages it receives), at near-zero cost. +test.provider( + "billing status reports a known entitlement reason; invalid stages are a typed BadRequest", + () => + Effect.gen(function* () { + const scope = yield* teamScopeOf; + const projectId = yield* ensureHostProject(HOST_PROBE, scope); + yield* Effect.gen(function* () { + const billing = yield* rolling_release.getRollingReleaseBillingStatus({ + idOrName: projectId, + ...scope, + }); + expect([ + "plan_not_supported", + "unlimited_slots", + "no_available_slots", + "available_slots", + ]).toContain((billing as { reason: string }).reason); + + // A config whose final stage is not 100% is rejected with a typed + // BadRequest (live-verified `invalid_request`). The body reaching + // the wire at all proves the requestBody patch. + const invalid = yield* Effect.result( + rolling_release.updateRollingReleaseConfig({ + idOrName: projectId, + enabled: true, + advancementType: "manual-approval", + stages: [{ targetPercentage: 10 }], + ...scope, + }), + ); + if (Result.isSuccess(invalid)) { + return yield* Effect.die( + "updateRollingReleaseConfig unexpectedly accepted a config without a final 100% stage", + ); + } + expect(invalid.failure._tag).toBe("BadRequest"); + }).pipe(Effect.ensuring(deleteHostProject(HOST_PROBE, scope))); + }).pipe(logLevel), +); diff --git a/packages/alchemy/test/Vercel/Providers.test.ts b/packages/alchemy/test/Vercel/Providers.test.ts new file mode 100644 index 0000000000..16819576d8 --- /dev/null +++ b/packages/alchemy/test/Vercel/Providers.test.ts @@ -0,0 +1,47 @@ +import { AlchemyContext } from "@/AlchemyContext.ts"; +import { AuthProviders } from "@/Auth/AuthProvider.ts"; +import { Stack } from "@/Stack.ts"; +import { Stage } from "@/Stage.ts"; +import * as Vercel from "@/Vercel"; +import { NodeServices } from "@effect/platform-node"; +import { it } from "alchemy-test"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { v4 as uuidv4 } from "uuid"; + +it.live( + "building the Vercel provider layers should not fail for unknown profile", + () => + Effect.gen(function* () { + yield* Layer.build(Vercel.providers()); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(AuthProviders, {}), + Layer.succeed(Stage, "test"), + Layer.succeed(Stack, { + name: "test", + stage: "test", + resources: {}, + bindings: {}, + actions: {}, + }), + Layer.succeed(AlchemyContext, { + dev: false, + adopt: false, + dotAlchemy: ".alchemy", + }), + Layer.succeed( + ConfigProvider.ConfigProvider, + ConfigProvider.fromUnknown({ + ALCHEMY_PROFILE: `non-existent-${uuidv4()}`, + }), + ), + NodeServices.layer, + FetchHttpClient.layer, + ), + ), + ), +); diff --git a/packages/alchemy/test/Vercel/Queues/Queue.local.test.ts b/packages/alchemy/test/Vercel/Queues/Queue.local.test.ts new file mode 100644 index 0000000000..cc0cac0623 --- /dev/null +++ b/packages/alchemy/test/Vercel/Queues/Queue.local.test.ts @@ -0,0 +1,201 @@ +/** + * Vercel Queues dev-mode (`alchemy dev`) tests — the local queue broker per + * the Local-tests doctrine: `dev: true` runs the RPC sidecar topology, so + * the in-memory broker (and the local Function provider it delivers + * through) lives in the sidecar process exactly like the real + * `alchemy dev`. + * + * Local arm (no cloud calls — `dev:` identity proves it): + * + * HTTP GET /send ──`SendMessage(DevOrders)` targets the sidecar broker + * via `VERCEL_QUEUE_BASE_URL`──▶ broker POSTs the CloudEvents v2beta + * delivery to the dev child's queue endpoint──▶ `subscribe(DevOrders)` + * decodes + echoes into `DevEchoes`──▶ GET /received drains the echo + * topic through `ReceiveMessages` (a dynamic consumer group against the + * broker) and acks each message. + * + * Remote arm: the SAME fixture piped through `Alchemy.remote()` deploys + * live from a dev run — real project, real queue triggers, real platform + * push — verified out-of-band via distilled and destroyed at the end. + */ +import * as Alchemy from "@/index.ts"; +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as projects from "@distilled.cloud/vercel/projects"; +import { expect } from "alchemy-test"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import LocalQueueFn, { + DevOrders, + type DevEcho, +} from "./fixtures/local-queue-fn.ts"; + +const { test } = Test.make({ + providers: Vercel.providers(), + dev: true, +}); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +class NotReady extends Data.TaggedError("NotReady")<{ status: number }> {} + +// The first request races the dev bundle's first build (rolldown over the +// alchemy barrel) locally, and workers.dev-style URL propagation remotely — +// retry with a bounded cap. +const readiness = Schedule.max([ + Schedule.min([ + Schedule.exponential("500 millis"), + Schedule.spaced("2 seconds"), + ]), + Schedule.recurs(45), +]); + +const getJsonReady = (url: string) => + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const res = yield* client.get(url).pipe( + Effect.flatMap((res) => + res.status === 200 + ? Effect.succeed(res) + : Effect.fail(new NotReady({ status: res.status })), + ), + Effect.retry({ + while: (e): e is NotReady => e instanceof NotReady, + schedule: readiness, + }), + ); + return yield* res.json; + }).pipe(Effect.orDie); + +test.provider( + "local queue: broker push delivery end-to-end through the dev child", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const fn = yield* stack.deploy( + Effect.gen(function* () { + return yield* LocalQueueFn; + }), + ); + + // dev identity markers — proof no cloud call ran. + expect(fn.projectId).toMatch(/^dev:/); + expect(fn.deploymentId).toMatch(/^dev:/); + expect(fn.url).toMatch(/^http:\/\/localhost:\d+$/); + + // Produce through the public route: the in-child producer pins the + // send to the dev deployment partition and targets the local broker. + const runId = yield* Effect.sync(() => crypto.randomUUID()); + const queued = (yield* getJsonReady( + `${fn.url}/send?runId=${runId}&orderId=local-1`, + )) as { queued: boolean; orderId: string }; + expect(queued.queued).toBe(true); + expect(queued.orderId).toBe("local-1"); + + // The broker push-delivers the CloudEvent to the child's queue-mode + // bridge; the subscribe handler echoes into DevEchoes; /received + // drains the echo topic back from the broker (dynamic group). + const drained = yield* getJsonReady(`${fn.url}/received`).pipe( + Effect.repeat({ + schedule: Schedule.spaced("1 second"), + until: (body) => + (body as unknown as { received: DevEcho[] }).received.some( + (echo) => echo.runId === runId, + ), + times: 45, + }), + ); + const echo = ( + drained as unknown as { received: DevEcho[] } + ).received.find((e) => e.runId === runId); + expect(echo).toBeDefined(); + // The full payload round-tripped through the local push consumer… + expect(echo!.orderId).toBe("local-1"); + // …with delivery metadata from the broker's CloudEvent POST… + expect(echo!.deliveryCount).toBeGreaterThanOrEqual(1); + expect(echo!.topicName).toBe(DevOrders.topicName); + // …under the stable per-Function default consumer group. + expect(echo!.consumerGroup).toBe("alchemy-LocalQueueFn"); + + // Public HTTP routing stays intact alongside queue delivery. + const root = (yield* getJsonReady(`${fn.url}/`)) as { ok: boolean }; + expect(root.ok).toBe(true); + + yield* stack.destroy(); + }).pipe(logLevel), + { timeout: 240_000 }, +); + +test.provider( + "Alchemy.remote() queue Function deploys live during dev (real push)", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const fn = yield* stack.deploy( + Effect.gen(function* () { + return yield* LocalQueueFn; + }).pipe(Alchemy.remote()), + ); + + // Real cloud identity — not the local emulator. + expect(fn.projectId).not.toMatch(/^dev:/); + expect(fn.url).toMatch(/^https:\/\//); + + // Real platform push delivery: produce, then drain the echo topic. + const runId = yield* Effect.sync(() => crypto.randomUUID()); + const queued = (yield* getJsonReady( + `${fn.url}/send?runId=${runId}&orderId=remote-1`, + )) as { queued: boolean }; + expect(queued.queued).toBe(true); + + const drained = yield* getJsonReady(`${fn.url}/received`).pipe( + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (body) => + (body as unknown as { received: DevEcho[] }).received.some( + (echo) => echo.runId === runId, + ), + times: 40, + }), + ); + const echo = ( + drained as unknown as { received: DevEcho[] } + ).received.find((e) => e.runId === runId); + expect(echo).toBeDefined(); + expect(echo!.orderId).toBe("remote-1"); + expect(echo!.consumerGroup).toBe("alchemy-LocalQueueFn"); + + // Out-of-band via distilled: the project exists on real Vercel. + const { teamId } = yield* Vercel.VercelEnvironment.current; + const project = yield* projects.getProject({ + idOrName: fn.projectId, + teamId, + }); + expect(project.id).toBe(fn.projectId); + + // Cleanup: the stamped-live row deletes from the real cloud even in + // a dev run; queue messages expire on their own (300s retention). + yield* stack.destroy(); + const gone = yield* projects + .getProject({ idOrName: fn.projectId, teamId }) + .pipe( + Effect.map(() => false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (g) => g, + times: 10, + }), + ); + expect(gone).toBe(true); + }).pipe(logLevel), + { timeout: 240_000 }, +); diff --git a/packages/alchemy/test/Vercel/Queues/QueueData.test.ts b/packages/alchemy/test/Vercel/Queues/QueueData.test.ts new file mode 100644 index 0000000000..70fa3a90ef --- /dev/null +++ b/packages/alchemy/test/Vercel/Queues/QueueData.test.ts @@ -0,0 +1,91 @@ +/** + * Pure unit tests pinning the DISTILLED multipart/mixed parser the queue + * receive path rides (`@distilled.cloud/vercel` data-protocol) — no cloud + * access. The live protocol is pinned by `Queues.test.ts`. + */ +import { + parseMultipartBoundary, + parseMultipartMixed, +} from "@distilled.cloud/vercel"; +import { expect, it } from "alchemy-test"; + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +it("parseMultipartBoundary extracts quoted and unquoted boundaries", () => { + expect(parseMultipartBoundary("multipart/mixed; boundary=abc123")).toEqual( + "abc123", + ); + expect(parseMultipartBoundary('multipart/mixed; boundary="abc 123"')).toEqual( + "abc 123", + ); + expect( + parseMultipartBoundary("multipart/mixed;boundary=x;charset=utf-8"), + ).toEqual("x"); + expect(parseMultipartBoundary("application/json")).toEqual(undefined); + expect(parseMultipartBoundary(undefined)).toEqual(undefined); +}); + +it("parseMultipartMixed parses a two-part body with Vqs headers", () => { + const boundary = "vqs-test-boundary"; + const body = [ + `--${boundary}`, + "Vqs-Message-Id: m-1", + "Vqs-Receipt-Handle: rh-1", + "Vqs-Timestamp: 2026-08-13T00:00:00.000Z", + "Vqs-Delivery-Count: 2", + "Content-Type: application/json", + "", + '{"hello":"world"}', + `--${boundary}`, + "Vqs-Message-Id: m-2", + "Vqs-Receipt-Handle: rh-2", + "Vqs-Timestamp: 2026-08-13T00:00:01.000Z", + "Vqs-Delivery-Count: 1", + "Content-Type: application/json", + "", + '{"n":2}', + `--${boundary}--`, + "", + ].join("\r\n"); + + const parts = parseMultipartMixed(textEncoder.encode(body), boundary); + expect(parts).toHaveLength(2); + expect(parts[0]!.headers["vqs-message-id"]).toEqual("m-1"); + expect(parts[0]!.headers["vqs-receipt-handle"]).toEqual("rh-1"); + expect(parts[0]!.headers["vqs-delivery-count"]).toEqual("2"); + expect(parts[0]!.headers["content-type"]).toEqual("application/json"); + expect(textDecoder.decode(parts[0]!.payload)).toEqual('{"hello":"world"}'); + expect(parts[1]!.headers["vqs-message-id"]).toEqual("m-2"); + expect(textDecoder.decode(parts[1]!.payload)).toEqual('{"n":2}'); +}); + +it("parseMultipartMixed handles a preamble and an empty body", () => { + const boundary = "b"; + const withPreamble = [ + "ignored preamble", + `--${boundary}`, + "Vqs-Message-Id: m-1", + "", + "payload-bytes", + `--${boundary}--`, + "", + ].join("\r\n"); + const parts = parseMultipartMixed(textEncoder.encode(withPreamble), boundary); + expect(parts).toHaveLength(1); + expect(textDecoder.decode(parts[0]!.payload)).toEqual("payload-bytes"); + + expect(parseMultipartMixed(textEncoder.encode(""), boundary)).toHaveLength(0); + expect( + parseMultipartMixed(textEncoder.encode(`--${boundary}--\r\n`), boundary), + ).toHaveLength(0); +}); + +it("parseMultipartMixed preserves multi-line JSON payload bytes verbatim", () => { + const boundary = "b2"; + const payload = '{\r\n "nested": "line\\r\\nbreaks"\r\n}'; + const body = `--${boundary}\r\nContent-Type: application/json\r\nVqs-Message-Id: m\r\n\r\n${payload}\r\n--${boundary}--\r\n`; + const parts = parseMultipartMixed(textEncoder.encode(body), boundary); + expect(parts).toHaveLength(1); + expect(textDecoder.decode(parts[0]!.payload)).toEqual(payload); +}); diff --git a/packages/alchemy/test/Vercel/Queues/Queues.test.ts b/packages/alchemy/test/Vercel/Queues/Queues.test.ts new file mode 100644 index 0000000000..371c333013 --- /dev/null +++ b/packages/alchemy/test/Vercel/Queues/Queues.test.ts @@ -0,0 +1,289 @@ +/** + * Vercel Queues data-plane foundation tests — live against the standing + * Vercel test team (run with the doppler alchemy-v2/dev env). + * + * There is no `subscribe` event source yet (separate wave), so these tests + * pin the data plane directly: a prebuilt Function carrying a + * `queue/v2beta` trigger provides the project + deployment (and configures + * the project's consumer registry — sends 503 without one), and the test + * drives SendMessage/ReceiveMessages from outside Vercel via a minted + * project OIDC token. + * + * Live-verified platform facts this suite leans on (PROBES.md probe 4): + * - visibility is partitioned by `Vqs-Deployment-Id` — pinned and unpinned + * sends live in disjoint partitions; + * - fresh consumer groups replay the topic from the beginning; + * - messages expire on their own (retention) — there is no delete API; + * - minted OIDC tokens are ALWAYS development-environment-scoped, and the + * queue namespace is per (project, environment) — so this whole suite + * operates in the dev namespace (self-consistent: every send and receive + * uses minted tokens). Deployed-function (production-scoped) queue + * behavior is covered by Subscribe.test.ts via in-scope readback. + */ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as projects from "@distilled.cloud/vercel/projects"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; +import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; +import { MinimumLogLevel } from "effect/References"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +/** Must match the trigger topic in fixtures/queue-output/.vc-config.json. */ +const TOPIC_NAME = "alchemy-queues-foundation"; + +const fixtureDir = new URL("./fixtures/queue-output", import.meta.url).pathname; + +/** + * The typed topic value — schema + region + send defaults in one place. + * `retentionSeconds` keeps test messages short-lived (there is no delete + * API; expiry is the cleanup). + */ +class Orders extends Vercel.Topic()(TOPIC_NAME, { + schema: Schema.Struct({ + orderId: Schema.String, + amountCents: Schema.Int, + runId: Schema.String, + }), + region: "iad1", + retentionSeconds: 300, +}) {} + +/** Poll (bounded) until the function's project is gone. */ +const expectProjectGone = (projectId: string) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + const gone = yield* projects + .getProject({ idOrName: projectId, teamId }) + .pipe( + Effect.map(() => false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (g) => g, + times: 10, + }), + ); + expect(gone).toBe(true); + }); + +test.provider( + "data plane: schema round-trip, partitions, ack, replay, extendLease", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + // ── Deploy a prebuilt function whose queue trigger configures the + // project's consumer registry. + const fn = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.Function("QueueFn", { prebuilt: fixtureDir }); + }), + ); + expect(fn.projectId).toBeDefined(); + expect(fn.deploymentId).toBeTruthy(); + + // ── Region/name pinning comes from the Topic value. + expect(Orders.region).toEqual("iad1"); + expect(Orders.topicName).toEqual(TOPIC_NAME); + expect(Orders.partition).toEqual("deployment"); + + // ── OIDC: minted from the management API (we are outside Vercel). + const token = yield* Vercel.mintProjectOidcToken(fn.projectId); + const runId = yield* Effect.sync(() => crypto.randomUUID()); + + // ── Pinned send (the deployment partition — the load-bearing path). + const producer = yield* Vercel.makeSendMessageClient(Orders, { + token, + deploymentId: fn.deploymentId, + }); + const pinned1 = yield* producer.send({ + orderId: "pinned-1", + amountCents: 4200, + runId, + }); + expect(pinned1.messageId).toBeTruthy(); + + // ── Fresh consumer group A sees it; payload round-trips through + // Schema; Vqs metadata is surfaced. + const consumerA = yield* Vercel.makeReceiveMessagesClient(Orders, { + consumerGroup: "alchemy-test-a", + token, + deploymentId: fn.deploymentId, + }); + const batchA = yield* consumerA + .receive({ maxMessages: 10, visibilityTimeoutSeconds: 5 }) + .pipe( + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (batch) => batch.some((m) => m.payload.runId === runId), + times: 15, + }), + ); + const msgA = batchA.find((m) => m.payload.runId === runId); + expect(msgA).toBeDefined(); + expect(msgA!.payload).toEqual({ + orderId: "pinned-1", + amountCents: 4200, + runId, + }); + expect(msgA!.messageId).toEqual(pinned1.messageId); + expect(msgA!.receiptHandle).toBeTruthy(); + expect(msgA!.deliveryCount).toBeGreaterThanOrEqual(1); + + // ── Ack in group A: after the 5s visibility window has passed, the + // message is NOT redelivered to A. + yield* consumerA.ack(msgA!.receiptHandle); + yield* Effect.sleep("8 seconds"); + const redeliveredA = yield* consumerA.receive({ + maxMessages: 10, + visibilityTimeoutSeconds: 5, + }); + expect( + redeliveredA.filter( + (m) => m.payload.runId === runId && m.payload.orderId === "pinned-1", + ), + ).toHaveLength(0); + + // ── Fresh group B replays the topic even though A acked (per-group + // cursors — live-verified platform behavior). + const consumerB = yield* Vercel.makeReceiveMessagesClient(Orders, { + consumerGroup: "alchemy-test-b", + token, + deploymentId: fn.deploymentId, + }); + const batchB = yield* consumerB + .receive({ maxMessages: 10, visibilityTimeoutSeconds: 30 }) + .pipe( + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (batch) => + batch.some( + (m) => + m.payload.runId === runId && m.payload.orderId === "pinned-1", + ), + times: 15, + }), + ); + const msgB = batchB.find( + (m) => m.payload.runId === runId && m.payload.orderId === "pinned-1", + ); + expect(msgB).toBeDefined(); + yield* consumerB.ack(msgB!.receiptHandle); + + // ── extendLease actually extends: receive with a 6s lease, extend to + // 60s, and after 10s the message is still leased (not redelivered). + const pinned2 = yield* producer.send({ + orderId: "pinned-2", + amountCents: 100, + runId, + }); + expect(pinned2.messageId).toBeTruthy(); + const consumerC = yield* Vercel.makeReceiveMessagesClient(Orders, { + consumerGroup: "alchemy-test-lease", + token, + deploymentId: fn.deploymentId, + }); + const batchC = yield* consumerC + .receive({ maxMessages: 10, visibilityTimeoutSeconds: 6 }) + .pipe( + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (batch) => + batch.some( + (m) => + m.payload.runId === runId && m.payload.orderId === "pinned-2", + ), + times: 15, + }), + ); + const msgC = batchC.find( + (m) => m.payload.runId === runId && m.payload.orderId === "pinned-2", + )!; + // Ack pinned-1 if this fresh group picked it up too, so the + // still-leased assertion below can only be about pinned-2. + for (const other of batchC) { + if (other.messageId !== msgC.messageId) { + yield* consumerC.ack(other.receiptHandle); + } + } + yield* consumerC.extendLease(msgC.receiptHandle, 60); + yield* Effect.sleep("10 seconds"); + const afterExtend = yield* consumerC.receive({ maxMessages: 10 }); + expect( + afterExtend.filter( + (m) => m.payload.runId === runId && m.payload.orderId === "pinned-2", + ), + ).toHaveLength(0); + yield* consumerC.ack(msgC.receiptHandle); + + // ── Acking a completed delivery again fails with a typed tag. + const doubleAck = yield* consumerC + .ack(msgC.receiptHandle) + .pipe(Effect.result); + expect(Result.isFailure(doubleAck)).toBe(true); + if (Result.isFailure(doubleAck)) { + expect([ + "Vercel.Queues.MessageNotFound", + "Vercel.Queues.MessageNotAvailable", + ]).toContain(doubleAck.failure._tag); + } + + // ── The unpinned (shared) partition is disjoint: an unpinned send is + // only visible to unpinned receivers. + const sharedProducer = yield* Vercel.makeSendMessageClient(Orders, { + token, + deploymentId: null, + }); + yield* sharedProducer.send({ + orderId: "unpinned-1", + amountCents: 7, + runId, + }); + const consumerShared = yield* Vercel.makeReceiveMessagesClient(Orders, { + consumerGroup: "alchemy-test-shared", + token, + deploymentId: null, + }); + const batchShared = yield* consumerShared + .receive({ maxMessages: 10, visibilityTimeoutSeconds: 30 }) + .pipe( + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (batch) => + batch.some( + (m) => + m.payload.runId === runId && + m.payload.orderId === "unpinned-1", + ), + times: 15, + }), + ); + const msgShared = batchShared.find( + (m) => m.payload.runId === runId && m.payload.orderId === "unpinned-1", + ); + expect(msgShared).toBeDefined(); + // Disjointness: the shared receiver never saw the pinned messages. + expect( + batchShared.filter( + (m) => + m.payload.runId === runId && m.payload.orderId !== "unpinned-1", + ), + ).toHaveLength(0); + yield* consumerShared.ack(msgShared!.receiptHandle); + + // ── Cleanup: the owned project cascades; messages expire on their own + // (≤300s retention set on the Topic). + yield* stack.destroy(); + yield* expectProjectGone(fn.projectId); + }).pipe(logLevel), + { timeout: 180_000 }, +); diff --git a/packages/alchemy/test/Vercel/Queues/Subscribe.test.ts b/packages/alchemy/test/Vercel/Queues/Subscribe.test.ts new file mode 100644 index 0000000000..f1e68c75b7 --- /dev/null +++ b/packages/alchemy/test/Vercel/Queues/Subscribe.test.ts @@ -0,0 +1,168 @@ +/** + * Vercel `subscribe` event source — the flagship end-to-end queue test, + * live against the standing Vercel test team (doppler alchemy-v2/dev env). + * + * Path under test (all REAL platform push delivery, PROBES.md probe 4): + * + * HTTP GET /send ──producer `SendMessage(Orders)` (deployment-pinned)──▶ + * Vercel queue ──platform POST (queue/v2beta trigger on the separate + * `_alchemy-queue.func`, D9a)──▶ `subscribe(Orders, handler)` decodes the + * payload and echoes it into `Echoes` ──▶ HTTP GET /received drains + * `Echoes` on the PUBLIC function (`ReceiveMessages` binding, ambient + * OIDC) and reports the echo with its delivery metadata. + * + * The verification deliberately stays inside the deployed functions' + * ambient scope: externally-minted project OIDC tokens are ALWAYS + * `environment: development`-scoped (live-verified — there is no documented + * or undocumented way to mint a production-scoped token), and the queue + * namespace is per (project, environment), so an external data-plane client + * cannot observe production queue traffic at all. + * + * Also pins the D9a two-function invariant: the PUBLIC fetch routes keep + * serving even though the deployment carries queue triggers (a trigger on + * the main function would 404 every public route — live-verified). + */ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as projects from "@distilled.cloud/vercel/projects"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import { MinimumLogLevel } from "effect/References"; +import SubscribeFn, { Orders, type Echo } from "./fixtures/subscribe-fn.ts"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +// Fresh .vercel.app URLs take a few seconds to start serving 200s — always +// retry the first request (bounded). +const readiness = Schedule.max([ + Schedule.exponential("500 millis"), + Schedule.recurs(20), +]); + +const getJson = (url: string) => + HttpClient.get(url).pipe( + Effect.flatMap((response) => + response.status === 200 + ? response.json + : Effect.fail(new Error(`status ${response.status}`)), + ), + Effect.retry({ schedule: readiness }), + ); + +/** Poll (bounded) until the function's project is gone. */ +const expectProjectGone = (projectId: string) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + const gone = yield* projects + .getProject({ idOrName: projectId, teamId }) + .pipe( + Effect.map(() => false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (g) => g, + times: 10, + }), + ); + expect(gone).toBe(true); + }); + +test.provider( + "subscribe: platform push delivery end-to-end, public routes intact", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const fn = yield* stack.deploy( + Effect.gen(function* () { + return yield* SubscribeFn; + }), + ); + expect(fn.url).toBeDefined(); + expect(fn.deploymentId).toBeTruthy(); + + // 1. D9a: the PUBLIC function still serves HTTP even though the + // deployment carries queue triggers (they live on the separate + // consumer function). + const root = (yield* getJson(`${fn.url}/`)) as { ok: boolean }; + expect(root.ok).toBe(true); + + // 2. Produce through the public fetch route — the in-function + // producer pins the send to the deployment partition (ambient + // VERCEL_DEPLOYMENT_ID + platform OIDC). + const runId = yield* Effect.sync(() => crypto.randomUUID()); + const queued = (yield* getJson( + `${fn.url}/send?runId=${runId}&orderId=flagship-1`, + )) as { queued: boolean; orderId: string }; + expect(queued.queued).toBe(true); + expect(queued.orderId).toEqual("flagship-1"); + + // 3. The platform push-delivers to the consumer function; the + // subscribe handler decodes the payload and echoes it into + // `Echoes`; the public `/received` route drains the echo topic. + // Poll (bounded) until the echo for this run shows up. + const drained = yield* getJson(`${fn.url}/received`).pipe( + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (body) => + (body as unknown as { received: Echo[] }).received.some( + (echo) => echo.runId === runId, + ), + times: 40, + }), + ); + const echo = (drained as unknown as { received: Echo[] }).received.find( + (e) => e.runId === runId, + ); + expect(echo).toBeDefined(); + // The full producer payload round-tripped through the push consumer… + expect(echo!.orderId).toEqual("flagship-1"); + // …with real delivery metadata from the platform POST… + expect(echo!.deliveryCount).toBeGreaterThanOrEqual(1); + expect(echo!.topicName).toEqual(Orders.topicName); + // …under the stable per-Function consumer group the trigger declares. + expect(echo!.consumerGroup).toEqual("alchemy-SubscribeFn"); + + // 4. Async-mode producer (`sendMessageFromEnv`): the Promise-based + // fromEnv client resolves the ambient OIDC token + deployment pin + // itself — same push-delivery loop verifies the send landed. + const asyncRunId = yield* Effect.sync(() => crypto.randomUUID()); + const queuedAsync = (yield* getJson( + `${fn.url}/send-async?runId=${asyncRunId}&orderId=fromenv-1`, + )) as { queued: boolean; via: string }; + expect(queuedAsync.queued).toBe(true); + expect(queuedAsync.via).toEqual("fromEnv"); + const drainedAsync = yield* getJson(`${fn.url}/received`).pipe( + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (body) => + (body as unknown as { received: Echo[] }).received.some( + (echo) => echo.runId === asyncRunId, + ), + times: 40, + }), + ); + const asyncEcho = ( + drainedAsync as unknown as { received: Echo[] } + ).received.find((e) => e.runId === asyncRunId); + expect(asyncEcho).toBeDefined(); + expect(asyncEcho!.orderId).toEqual("fromenv-1"); + + // 5. Public routing still intact after deliveries. + const after = (yield* getJson(`${fn.url}/`)) as { ok: boolean }; + expect(after.ok).toBe(true); + + // 6. Cleanup: owned project cascades; queue messages expire on their + // own (300s retention on both topics). + yield* stack.destroy(); + yield* expectProjectGone(fn.projectId); + }).pipe(logLevel), + { timeout: 180_000 }, +); diff --git a/packages/alchemy/test/Vercel/Queues/fixtures/local-queue-fn.ts b/packages/alchemy/test/Vercel/Queues/fixtures/local-queue-fn.ts new file mode 100644 index 0000000000..90b071ceb7 --- /dev/null +++ b/packages/alchemy/test/Vercel/Queues/fixtures/local-queue-fn.ts @@ -0,0 +1,125 @@ +/** + * Effect-mode Vercel Function fixture for the dev-mode queue tests — the + * `subscribe-fn.ts` flagship shape (produce over HTTP → push delivery → + * echo topic → HTTP readback), deployed BOTH against the local dev broker + * (`Queue.local.test.ts` local arm: `SendMessage` targets the sidecar's + * broker via `VERCEL_QUEUE_BASE_URL`, the broker POSTs the CloudEvent to + * the dev child's queue endpoint) and — piped through `Alchemy.remote()` — + * against the real platform from a dev run. + * + * See `fixtures/subscribe-fn.ts` for why readback rides an echo topic and + * stays inside the Function's ambient scope. + */ +import * as Vercel from "@/Vercel/index.ts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; + +export class DevOrders extends Vercel.Topic()( + "alchemy-devq-orders", + { + schema: Schema.Struct({ + orderId: Schema.String, + amountCents: Schema.Int, + runId: Schema.String, + }), + region: "iad1", + retentionSeconds: 300, + }, +) {} + +export interface DevEcho { + readonly orderId: string; + readonly runId: string; + readonly deliveryCount: number; + readonly topicName: string; + readonly consumerGroup: string; +} + +export class DevEchoes extends Vercel.Topic()( + "alchemy-devq-echoes", + { + schema: Schema.Struct({ + orderId: Schema.String, + runId: Schema.String, + deliveryCount: Schema.Int, + topicName: Schema.String, + consumerGroup: Schema.String, + }), + region: "iad1", + retentionSeconds: 300, + }, +) {} + +// Public-instance readback state, filled by the `/received` route's drains. +const received: DevEcho[] = []; + +export default class LocalQueueFn extends Vercel.Function()( + "LocalQueueFn", + { + main: import.meta.url, + }, + Effect.gen(function* () { + const orders = yield* Vercel.SendMessage(DevOrders); + const echoes = yield* Vercel.SendMessage(DevEchoes); + const echoReader = yield* Vercel.ReceiveMessages(DevEchoes, { + consumerGroup: "alchemy-devq-readback", + }); + + // The consumer half: schema-decoded payload + delivery metadata, + // re-published into the echo topic for cross-instance readback. + yield* Vercel.subscribe(DevOrders, (order, meta) => + echoes + .send({ + orderId: order.orderId, + runId: order.runId, + deliveryCount: meta.deliveryCount, + topicName: meta.topicName, + consumerGroup: meta.consumerGroup, + }) + .pipe(Effect.orDie, Effect.asVoid), + ); + + // The echo topic needs a consumer trigger too (live sends into a topic + // with no trigger fail 503 ConsumerRegistryNotConfigured). + yield* Vercel.subscribe(DevEchoes, () => Effect.void); + + return { + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; + const url = new URL(request.url, "http://localhost"); + if (url.pathname === "/send") { + const runId = url.searchParams.get("runId") ?? "missing-run-id"; + const orderId = url.searchParams.get("orderId") ?? "order-1"; + yield* orders + .send({ orderId, amountCents: 4200, runId }) + .pipe(Effect.orDie); + return yield* HttpServerResponse.json({ + queued: true, + orderId, + runId, + }); + } + if (url.pathname === "/received") { + // One bounded drain per call; the test polls this route. + const batch = yield* echoReader + .receive({ maxMessages: 10, visibilityTimeoutSeconds: 60 }) + .pipe(Effect.orDie); + for (const message of batch) { + received.push(message.payload as DevEcho); + yield* echoReader.ack(message.receiptHandle).pipe(Effect.orDie); + } + return yield* HttpServerResponse.json({ received }); + } + return yield* HttpServerResponse.json({ ok: true, path: request.url }); + }), + }; + }).pipe( + Effect.provide([ + Vercel.SendMessageHttp, + Vercel.ReceiveMessagesHttp, + Vercel.QueueEventSourceLive, + ]), + ), +) {} diff --git a/packages/alchemy/test/Vercel/Queues/fixtures/queue-output/config.json b/packages/alchemy/test/Vercel/Queues/fixtures/queue-output/config.json new file mode 100644 index 0000000000..21f615f251 --- /dev/null +++ b/packages/alchemy/test/Vercel/Queues/fixtures/queue-output/config.json @@ -0,0 +1,4 @@ +{ + "version": 3, + "routes": [{ "handle": "filesystem" }, { "src": "/.*", "dest": "/index" }] +} diff --git a/packages/alchemy/test/Vercel/Queues/fixtures/queue-output/functions/index.func/.vc-config.json b/packages/alchemy/test/Vercel/Queues/fixtures/queue-output/functions/index.func/.vc-config.json new file mode 100644 index 0000000000..bda84bdd57 --- /dev/null +++ b/packages/alchemy/test/Vercel/Queues/fixtures/queue-output/functions/index.func/.vc-config.json @@ -0,0 +1,13 @@ +{ + "runtime": "nodejs22.x", + "handler": "index.mjs", + "launcherType": "Nodejs", + "supportsResponseStreaming": true, + "experimentalTriggers": [ + { + "type": "queue/v2beta", + "topic": "alchemy-queues-foundation", + "consumer": "alchemy-platform" + } + ] +} diff --git a/packages/alchemy/test/Vercel/Queues/fixtures/queue-output/functions/index.func/index.mjs b/packages/alchemy/test/Vercel/Queues/fixtures/queue-output/functions/index.func/index.mjs new file mode 100644 index 0000000000..dd69bf62ec --- /dev/null +++ b/packages/alchemy/test/Vercel/Queues/fixtures/queue-output/functions/index.func/index.mjs @@ -0,0 +1,14 @@ +// Minimal queue-consumer function: the queue trigger in .vc-config.json is +// what matters (it configures the project's consumer registry so the data +// plane accepts sends). Queue deliveries arrive as POSTs; note that a +// function carrying a queue trigger loses ALL public HTTP routing +// (live-verified), so this handler is effectively private. +export default { + async fetch(request) { + if (request.method === "POST") { + await request.text(); + return Response.json({ received: true }); + } + return Response.json({ ok: true }); + }, +}; diff --git a/packages/alchemy/test/Vercel/Queues/fixtures/subscribe-fn.ts b/packages/alchemy/test/Vercel/Queues/fixtures/subscribe-fn.ts new file mode 100644 index 0000000000..b1b297793f --- /dev/null +++ b/packages/alchemy/test/Vercel/Queues/fixtures/subscribe-fn.ts @@ -0,0 +1,149 @@ +/** + * Effect-mode Vercel Function fixture for the `subscribe` event source + * (D9a two-function shape): a public fetch route produces into `Orders` + * via `SendMessage`; the platform push-delivers to the generated consumer + * function, whose `subscribe` handler echoes each processed payload into + * `Echoes`; the public `/received` route drains `Echoes` with the + * `ReceiveMessages` binding into module-global state for HTTP readback. + * + * Why the echo topic instead of module-global readback from the handler: + * the consumer function is a SEPARATE deployment artifact from the public + * one (separate Fluid instances), so module globals written by the + * subscribe handler are invisible to the public function. And why not an + * externally-minted OIDC client: `getProjectToken` mints are ALWAYS + * `environment: development`-scoped (live-verified), while the queue + * namespace is per (project, environment) — an external client can never + * see production queue traffic. All queue traffic here stays inside the + * deployed functions' ambient production scope. + * + * `Echoes` gets its own no-op subscription so the deployment carries a + * trigger for it too (a topic without a consumer trigger rejects sends with + * 503 ConsumerRegistryNotConfigured). + */ +import * as Vercel from "@/Vercel/index.ts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; + +export class Orders extends Vercel.Topic()("alchemy-subscribe-orders", { + schema: Schema.Struct({ + orderId: Schema.String, + amountCents: Schema.Int, + runId: Schema.String, + }), + region: "iad1", + retentionSeconds: 300, +}) {} + +export interface Echo { + readonly orderId: string; + readonly runId: string; + readonly deliveryCount: number; + readonly topicName: string; + readonly consumerGroup: string; +} + +export class Echoes extends Vercel.Topic()("alchemy-subscribe-echoes", { + schema: Schema.Struct({ + orderId: Schema.String, + runId: Schema.String, + deliveryCount: Schema.Int, + topicName: Schema.String, + consumerGroup: Schema.String, + }), + region: "iad1", + retentionSeconds: 300, +}) {} + +// Public-instance readback state, filled by the `/received` route's drains. +const received: Echo[] = []; + +export default class SubscribeFn extends Vercel.Function()( + "SubscribeFn", + { + main: import.meta.url, + }, + Effect.gen(function* () { + const orders = yield* Vercel.SendMessage(Orders); + const echoes = yield* Vercel.SendMessage(Echoes); + const echoReader = yield* Vercel.ReceiveMessages(Echoes, { + consumerGroup: "alchemy-readback", + }); + + // The consumer half: schema-decoded payload + delivery metadata, + // re-published into the echo topic for cross-function readback. + yield* Vercel.subscribe(Orders, (order, meta) => + echoes + .send({ + orderId: order.orderId, + runId: order.runId, + deliveryCount: meta.deliveryCount, + topicName: meta.topicName, + consumerGroup: meta.consumerGroup, + }) + .pipe(Effect.orDie, Effect.asVoid), + ); + + // Ensure the echo topic has a consumer trigger too (sends into a topic + // with no trigger fail 503); it acks under the trigger's group while + // the readback group above keeps its own independent cursor. + yield* Vercel.subscribe(Echoes, () => Effect.void); + + return { + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; + const url = new URL(request.url, "http://localhost"); + if (url.pathname === "/send") { + const runId = url.searchParams.get("runId") ?? "missing-run-id"; + const orderId = url.searchParams.get("orderId") ?? "order-1"; + yield* orders + .send({ orderId, amountCents: 4200, runId }) + .pipe(Effect.orDie); + return yield* HttpServerResponse.json({ + queued: true, + orderId, + runId, + }); + } + if (url.pathname === "/send-async") { + // Async-mode producer path: `sendMessageFromEnv` resolves the + // ambient OIDC token + deployment pin from the live environment + // and returns a Promise — the exact client a plain (non-Effect) + // Function would use. Driving it from here pins the fromEnv + // variant without a second deployed fixture. + const runId = url.searchParams.get("runId") ?? "missing-run-id"; + const orderId = url.searchParams.get("orderId") ?? "order-async-1"; + const asyncOrders = Vercel.sendMessageFromEnv(Orders); + yield* Effect.tryPromise(() => + asyncOrders.send({ orderId, amountCents: 100, runId }), + ).pipe(Effect.orDie); + return yield* HttpServerResponse.json({ + queued: true, + orderId, + runId, + via: "fromEnv", + }); + } + if (url.pathname === "/received") { + // One bounded drain per call; the test polls this route. + const batch = yield* echoReader + .receive({ maxMessages: 10, visibilityTimeoutSeconds: 60 }) + .pipe(Effect.orDie); + for (const message of batch) { + received.push(message.payload as Echo); + yield* echoReader.ack(message.receiptHandle).pipe(Effect.orDie); + } + return yield* HttpServerResponse.json({ received }); + } + return yield* HttpServerResponse.json({ ok: true, path: request.url }); + }), + }; + }).pipe( + Effect.provide([ + Vercel.SendMessageHttp, + Vercel.ReceiveMessagesHttp, + Vercel.QueueEventSourceLive, + ]), + ), +) {} diff --git a/packages/alchemy/test/Vercel/Routes/BulkRedirects.test.ts b/packages/alchemy/test/Vercel/Routes/BulkRedirects.test.ts new file mode 100644 index 0000000000..94185354a2 --- /dev/null +++ b/packages/alchemy/test/Vercel/Routes/BulkRedirects.test.ts @@ -0,0 +1,211 @@ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as bulkRedirects from "@distilled.cloud/vercel/bulk_redirects"; +import * as projects from "@distilled.cloud/vercel/projects"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +const teamScope = Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + return teamId !== undefined ? { teamId } : {}; +}); + +/** + * The stage API requires the owner teamId in the request BODY; the testing + * profile rides the token's default team (no explicit teamId), so derive it + * from the host project like the provider does. + */ +const ownerTeamId = (projectId: string) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + if (teamId !== undefined) return teamId; + const project = yield* projects.getProject({ idOrName: projectId }); + return project.accountId; + }); + +/** Out-of-band read of the promoted production redirects via distilled. */ +const getRedirectsDoc = (projectId: string) => + Effect.gen(function* () { + const team = yield* teamScope; + return yield* bulkRedirects.getRedirects({ + projectId, + ...team, + per_page: 100, + }); + }); + +const withHostProject = ( + hostName: string, + body: (projectId: string) => Effect.Effect, +) => + Effect.gen(function* () { + const team = yield* teamScope; + const host = yield* projects + .createProject({ name: hostName, ...team }) + .pipe( + Effect.map((p) => ({ id: p.id })), + Effect.catchTag("Conflict", () => + projects + .getProject({ idOrName: hostName, ...team }) + .pipe(Effect.map((p) => ({ id: p.id }))), + ), + ); + return yield* body(host.id).pipe( + Effect.ensuring( + projects + .deleteProject({ idOrName: host.id, ...team }) + .pipe(Effect.ignore), + ), + ); + }); + +test.provider( + "bulk redirects lifecycle: stage+promote, no-op redeploy, drift converge, reset on destroy", + (stack) => + withHostProject("alchemy-test-redirects-life", (projectId) => + Effect.gen(function* () { + yield* stack.destroy(); + + const props: Vercel.BulkRedirectsProps = { + projectId, + name: "alchemy-test-v1", + redirects: [ + // No statusCode/permanent — pins the platform default (307). + { source: "/old-home", destination: "/" }, + { source: "/old-blog", destination: "/blog", statusCode: 308 }, + { source: "/campaign", destination: "/sale", permanent: true }, + ], + }; + + const created = yield* stack.deploy( + Vercel.BulkRedirects("Redirects", props), + ); + expect(created.projectId).toEqual(projectId); + expect(created.redirectCount).toEqual(3); + expect(created.versionId.length).toBeGreaterThan(0); + expect(created.versionName).toEqual("alchemy-test-v1"); + + // Out-of-band: the production document holds all three, staging is + // empty (the staged version was promoted). + const doc = yield* getRedirectsDoc(projectId); + expect(doc.redirects).toHaveLength(3); + const bySource = new Map(doc.redirects.map((r) => [r.source, r])); + expect(bySource.get("/old-home")!.statusCode).toEqual(307); + expect(bySource.get("/old-blog")!.statusCode).toEqual(308); + expect(bySource.get("/campaign")!.statusCode).toEqual(308); + const versions = yield* Effect.gen(function* () { + const team = yield* teamScope; + return yield* bulkRedirects.getVersions({ projectId, ...team }); + }); + expect(versions.versions.some((v) => v.isStaging === true)).toEqual( + false, + ); + expect(versions.versions.find((v) => v.isLive === true)?.id).toEqual( + created.versionId, + ); + + // Identical redeploy is a true no-op — version untouched. + const noop = yield* stack.deploy( + Vercel.BulkRedirects("Redirects", props), + ); + expect(noop.versionId).toEqual(created.versionId); + + // Drift out-of-band: stage+promote a different document. + const teamId = yield* ownerTeamId(projectId); + const driftStaged = yield* bulkRedirects.stageRedirects({ + projectId, + teamId, + overwrite: true, + redirects: [{ source: "/drift", destination: "/elsewhere" }], + }); + yield* bulkRedirects.updateVersion({ + projectId, + teamId, + id: driftStaged.version.id, + action: "promote", + }); + + // Reconcile with updated props (drop one redirect, change a + // destination): the engine plans an update and the provider's + // full-replace converges production — the drift redirect is gone. + const updated = yield* stack.deploy( + Vercel.BulkRedirects("Redirects", { + projectId, + name: "alchemy-test-v2", + redirects: [ + { source: "/old-home", destination: "/home" }, + { source: "/old-blog", destination: "/blog" }, + ], + }), + ); + expect(updated.redirectCount).toEqual(2); + expect(updated.versionName).toEqual("alchemy-test-v2"); + expect(updated.versionId).not.toEqual(created.versionId); + const afterUpdate = yield* getRedirectsDoc(projectId); + expect(afterUpdate.redirects).toHaveLength(2); + expect(afterUpdate.redirects.map((r) => r.source).sort()).toEqual([ + "/old-blog", + "/old-home", + ]); + expect( + afterUpdate.redirects.find((r) => r.source === "/old-home")! + .destination, + ).toEqual("/home"); + + // Destroy resets the document (empty promoted version). + yield* stack.destroy(); + const afterDestroy = yield* getRedirectsDoc(projectId); + expect(afterDestroy.redirects).toHaveLength(0); + + // Destroy again — delete path is idempotent. + yield* stack.destroy(); + }), + ).pipe(logLevel), + { timeout: 120_000 }, +); + +test.provider( + "changing projectId replaces: new project gets the redirects, old project is reset", + (stack) => + withHostProject("alchemy-test-redirects-repl-a", (projectA) => + withHostProject("alchemy-test-redirects-repl-b", (projectB) => + Effect.gen(function* () { + yield* stack.destroy(); + + const created = yield* stack.deploy( + Vercel.BulkRedirects("Redirects", { + projectId: projectA, + redirects: [{ source: "/a", destination: "/b" }], + }), + ); + expect(created.projectId).toEqual(projectA); + + const replaced = yield* stack.deploy( + Vercel.BulkRedirects("Redirects", { + projectId: projectB, + redirects: [{ source: "/a", destination: "/b" }], + }), + ); + expect(replaced.projectId).toEqual(projectB); + + const onB = yield* getRedirectsDoc(projectB); + expect(onB.redirects).toHaveLength(1); + const onA = yield* getRedirectsDoc(projectA); + expect(onA.redirects).toHaveLength(0); + + yield* stack.destroy(); + const afterDestroy = yield* getRedirectsDoc(projectB); + expect(afterDestroy.redirects).toHaveLength(0); + }), + ), + ).pipe(logLevel), + { timeout: 120_000 }, +); diff --git a/packages/alchemy/test/Vercel/Routes/ProjectRoutes.test.ts b/packages/alchemy/test/Vercel/Routes/ProjectRoutes.test.ts new file mode 100644 index 0000000000..f76309ac7f --- /dev/null +++ b/packages/alchemy/test/Vercel/Routes/ProjectRoutes.test.ts @@ -0,0 +1,240 @@ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as projectRoutes from "@distilled.cloud/vercel/project_routes"; +import * as projects from "@distilled.cloud/vercel/projects"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +const teamScope = Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + return teamId !== undefined ? { teamId } : {}; +}); + +/** Out-of-band read of the current routes document via distilled. */ +const getRoutesDoc = (projectId: string) => + Effect.gen(function* () { + const team = yield* teamScope; + return yield* projectRoutes.getRoutes({ projectId, ...team }); + }); + +const getVersions = (projectId: string) => + Effect.gen(function* () { + const team = yield* teamScope; + return yield* projectRoutes.getRouteVersions({ projectId, ...team }); + }); + +/** + * Create (or reuse, after an interrupted run) a host project out-of-band via + * distilled — deterministic name — run `body` against it, and always delete + * the project again. + */ +const withHostProject = ( + hostName: string, + body: (projectId: string) => Effect.Effect, +) => + Effect.gen(function* () { + const team = yield* teamScope; + const host = yield* projects + .createProject({ name: hostName, ...team }) + .pipe( + Effect.map((p) => ({ id: p.id })), + Effect.catchTag("Conflict", () => + projects + .getProject({ idOrName: hostName, ...team }) + .pipe(Effect.map((p) => ({ id: p.id }))), + ), + ); + return yield* body(host.id).pipe( + Effect.ensuring( + projects + .deleteProject({ idOrName: host.id, ...team }) + .pipe(Effect.ignore), + ), + ); + }); + +const redirectRule = ( + dest: string, + status: number = 308, +): Vercel.ProjectRouteRule => ({ + id: "legacy", + name: "Legacy redirect", + route: { src: "/old-path", dest, status }, +}); + +const rewriteRule: Vercel.ProjectRouteRule = { + id: "docs", + name: "Docs rewrite", + enabled: false, + route: { src: "/docs/(.*)", dest: "/documentation/$1" }, +}; + +test.provider( + "routes lifecycle: stage+promote, no-op redeploy, drift converge, draft replace, reset on destroy", + (stack) => + withHostProject("alchemy-test-routes-life", (projectId) => + Effect.gen(function* () { + yield* stack.destroy(); + + const props: Vercel.ProjectRoutesProps = { + projectId, + routes: [redirectRule("/new-path"), rewriteRule], + }; + + const created = yield* stack.deploy( + Vercel.ProjectRoutes("Routes", props), + ); + expect(created.projectId).toEqual(projectId); + expect(created.ruleCount).toEqual(2); + expect(created.versionId.length).toBeGreaterThan(0); + expect(created.routes.map((r) => r.id)).toEqual(["legacy", "docs"]); + expect(created.routes[0]!.enabled).toEqual(true); + expect(created.routes[1]!.enabled).toEqual(false); + expect(created.routes[0]!.routeType).toEqual("redirect"); + + // Out-of-band: the document is PROMOTED (no staging version left). + const versions = yield* getVersions(projectId); + expect(versions.versions.some((v) => v.isStaging === true)).toEqual( + false, + ); + const doc = yield* getRoutesDoc(projectId); + expect(doc.routes).toHaveLength(2); + expect(doc.routes.every((r) => r.staged !== true)).toEqual(true); + + // Identical redeploy is a true no-op — version untouched. + const noop = yield* stack.deploy(Vercel.ProjectRoutes("Routes", props)); + expect(noop.versionId).toEqual(created.versionId); + + // Drift out-of-band: stage+promote a different document. + const team = yield* teamScope; + const driftStaged = yield* projectRoutes.stageRoutes({ + projectId, + ...team, + overwrite: true, + routes: [ + { + id: "drift", + name: "Drift rule", + route: { src: "/drift", dest: "/elsewhere" }, + }, + ], + }); + yield* projectRoutes.updateRouteVersions({ + projectId, + ...team, + id: driftStaged.version.id, + action: "promote", + }); + + // Reconcile with updated props: the engine plans an update (props + // changed) and the provider's full-replace converges production — + // the drift rule is gone, the desired document is live. + const converged = yield* stack.deploy( + Vercel.ProjectRoutes("Routes", { + projectId, + routes: [redirectRule("/moved"), rewriteRule], + }), + ); + expect(converged.ruleCount).toEqual(2); + expect(converged.versionId).not.toEqual(created.versionId); + const afterConverge = yield* getRoutesDoc(projectId); + expect(afterConverge.routes.map((r) => r.id)).toEqual([ + "legacy", + "docs", + ]); + expect( + afterConverge.routes[0]!.rawDest ?? + afterConverge.routes[0]!.route.dest, + ).toEqual("/moved"); + + // A foreign unpromoted draft is replaced by the next reconcile that + // writes (staging is a single slot owned by the resource). + yield* projectRoutes.stageRoutes({ + projectId, + ...team, + overwrite: true, + routes: [ + { + id: "foreign-draft", + name: "Foreign draft", + route: { src: "/draft", dest: "/nowhere" }, + }, + ], + }); + const afterDraft = yield* stack.deploy( + Vercel.ProjectRoutes("Routes", { + projectId, + routes: [redirectRule("/moved-again", 307), rewriteRule], + }), + ); + expect(afterDraft.ruleCount).toEqual(2); + const postDraftVersions = yield* getVersions(projectId); + expect( + postDraftVersions.versions.some((v) => v.isStaging === true), + ).toEqual(false); + const postDraftDoc = yield* getRoutesDoc(projectId); + expect(postDraftDoc.routes.map((r) => r.id)).toEqual([ + "legacy", + "docs", + ]); + expect( + postDraftDoc.routes[0]!.rawDest ?? postDraftDoc.routes[0]!.route.dest, + ).toEqual("/moved-again"); + + // Destroy resets the document (empty promoted version). + yield* stack.destroy(); + const afterDestroy = yield* getRoutesDoc(projectId); + expect(afterDestroy.routes).toHaveLength(0); + + // Destroy again — delete path is idempotent. + yield* stack.destroy(); + }), + ).pipe(logLevel), + { timeout: 120_000 }, +); + +test.provider( + "changing projectId replaces: new project gets the rules, old project is reset", + (stack) => + withHostProject("alchemy-test-routes-repl-a", (projectA) => + withHostProject("alchemy-test-routes-repl-b", (projectB) => + Effect.gen(function* () { + yield* stack.destroy(); + + const created = yield* stack.deploy( + Vercel.ProjectRoutes("Routes", { + projectId: projectA, + routes: [redirectRule("/new-path")], + }), + ); + expect(created.projectId).toEqual(projectA); + + const replaced = yield* stack.deploy( + Vercel.ProjectRoutes("Routes", { + projectId: projectB, + routes: [redirectRule("/new-path")], + }), + ); + expect(replaced.projectId).toEqual(projectB); + + const onB = yield* getRoutesDoc(projectB); + expect(onB.routes).toHaveLength(1); + const onA = yield* getRoutesDoc(projectA); + expect(onA.routes).toHaveLength(0); + + yield* stack.destroy(); + const afterDestroy = yield* getRoutesDoc(projectB); + expect(afterDestroy.routes).toHaveLength(0); + }), + ), + ).pipe(logLevel), + { timeout: 120_000 }, +); diff --git a/packages/alchemy/test/Vercel/Sandboxes/SandboxDrive.test.ts b/packages/alchemy/test/Vercel/Sandboxes/SandboxDrive.test.ts new file mode 100644 index 0000000000..0fc31b81cb --- /dev/null +++ b/packages/alchemy/test/Vercel/Sandboxes/SandboxDrive.test.ts @@ -0,0 +1,135 @@ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as projects from "@distilled.cloud/vercel/projects"; +import * as sandboxes from "@distilled.cloud/vercel/sandboxes"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Result from "effect/Result"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +// Sandbox Drives are in PRIVATE BETA: on accounts without access every +// drive API call (including reads) answers a typed 403 Forbidden ("Drives +// are in private beta…" — live-verified Aug 2026). The ungated probe below +// pins that typed rejection; the full lifecycle runs only on an entitled +// account with VERCEL_TEST_SANDBOX_DRIVES=1. +const ENTITLED = !!process.env.VERCEL_TEST_SANDBOX_DRIVES; + +// Deterministic out-of-band probe project — same name on every run. +const PROBE_PROJECT = "alchemy-test-sandbox-drive"; + +const teamScopeOf = Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + return teamId; +}); + +const ensureProbeProject = Effect.gen(function* () { + const teamId = yield* teamScopeOf; + return yield* projects.getProject({ idOrName: PROBE_PROJECT, teamId }).pipe( + Effect.map((p) => p.id), + Effect.catchTag("NotFound", () => + projects + .createProject({ name: PROBE_PROJECT, teamId }) + .pipe(Effect.map((p) => p.id)), + ), + ); +}); + +const deleteProbeProject = Effect.gen(function* () { + const teamId = yield* teamScopeOf; + yield* projects + .deleteProject({ idOrName: PROBE_PROJECT, teamId }) + .pipe(Effect.catchTag("NotFound", () => Effect.void)); +}); + +// Ungated probe: pins the private-beta gate as the TYPED Forbidden tag on +// both a read and a write. +test.provider.skipIf(ENTITLED)( + "drives are private-beta gated: typed Forbidden on list and create", + () => + Effect.gen(function* () { + const teamId = yield* teamScopeOf; + const projectId = yield* ensureProbeProject; + + const listed = yield* Effect.result( + sandboxes.listDrives({ projectId, teamId }), + ); + expect(Result.isFailure(listed)).toBe(true); + if (Result.isFailure(listed)) { + expect(listed.failure._tag).toBe("Forbidden"); + if (listed.failure._tag === "Forbidden") { + expect(listed.failure.message).toContain("private beta"); + } + } + + const created = yield* Effect.result( + sandboxes.getOrCreateDrive({ + name: "alchemy-test-drive-probe", + projectId, + teamId, + }), + ); + if (Result.isSuccess(created)) { + // The account is actually entitled — clean up and direct the + // runner to the gated lifecycle suite. + yield* sandboxes + .deleteDrive({ name: "alchemy-test-drive-probe", projectId, teamId }) + .pipe(Effect.catchTag("NotFound", () => Effect.void)); + return yield* Effect.die( + "getOrCreateDrive unexpectedly succeeded — this account has drive access; run with VERCEL_TEST_SANDBOX_DRIVES=1", + ); + } + expect(created.failure._tag).toBe("Forbidden"); + }).pipe(Effect.ensuring(deleteProbeProject.pipe(Effect.ignore)), logLevel), + { timeout: 120_000 }, +); + +test.provider.skipIf(!ENTITLED)( + "create, verify, and destroy a sandbox drive (VERCEL_TEST_SANDBOX_DRIVES=1)", + (stack) => + Effect.gen(function* () { + const teamId = yield* teamScopeOf; + const projectId = yield* ensureProbeProject; + + yield* stack.destroy(); + + const drive = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.SandboxDrive("Drive", { + project: projectId, + maxSizeBytes: 1024 * 1024 * 1024, + }); + }), + ); + expect(drive.projectId).toEqual(projectId); + expect(drive.name).toBeDefined(); + expect(drive.maxSizeBytes).toEqual(1024 * 1024 * 1024); + + // Out-of-band verification via distilled. + const listed = yield* sandboxes.listDrives({ projectId, teamId }); + expect(listed.drives.some((d) => d.name === drive.name)).toBe(true); + + // Idempotent redeploy — same drive. + const again = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.SandboxDrive("Drive", { + project: projectId, + maxSizeBytes: 1024 * 1024 * 1024, + }); + }), + ); + expect(again.name).toEqual(drive.name); + expect(again.createdAt).toEqual(drive.createdAt); + + yield* stack.destroy(); + const after = yield* sandboxes.listDrives({ projectId, teamId }); + expect(after.drives.some((d) => d.name === drive.name)).toBe(false); + }).pipe(Effect.ensuring(deleteProbeProject.pipe(Effect.ignore)), logLevel), + { timeout: 120_000 }, +); diff --git a/packages/alchemy/test/Vercel/Sandboxes/SandboxSnapshot.test.ts b/packages/alchemy/test/Vercel/Sandboxes/SandboxSnapshot.test.ts new file mode 100644 index 0000000000..8886fe02e9 --- /dev/null +++ b/packages/alchemy/test/Vercel/Sandboxes/SandboxSnapshot.test.ts @@ -0,0 +1,132 @@ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as projects from "@distilled.cloud/vercel/projects"; +import * as sandboxes from "@distilled.cloud/vercel/sandboxes"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Result from "effect/Result"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +// Deterministic names — same on every run. The sandbox is tiny (default +// resources, 2-minute hard timeout, non-persistent) and is deleted at the +// end of the test regardless of outcome. +const PROBE_PROJECT = "alchemy-test-sandbox-snapshot"; +const SANDBOX_NAME = "alchemy-test-snap-sbx"; + +const teamScopeOf = Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + return teamId; +}); + +const ensureProbeProject = Effect.gen(function* () { + const teamId = yield* teamScopeOf; + return yield* projects.getProject({ idOrName: PROBE_PROJECT, teamId }).pipe( + Effect.map((p) => p.id), + Effect.catchTag("NotFound", () => + projects + .createProject({ name: PROBE_PROJECT, teamId }) + .pipe(Effect.map((p) => p.id)), + ), + ); +}); + +/** Best-effort cleanup: stop the session, delete the sandbox + project. */ +const cleanup = (projectId: string, sessionId: string | undefined) => + Effect.gen(function* () { + const teamId = yield* teamScopeOf; + if (sessionId !== undefined) { + yield* sandboxes.stopSession({ sessionId, teamId }).pipe(Effect.ignore); + } + yield* sandboxes + .deleteSandbox({ name: SANDBOX_NAME, projectId, teamId }) + .pipe(Effect.ignore); + yield* projects + .deleteProject({ idOrName: PROBE_PROJECT, teamId }) + .pipe(Effect.ignore); + }); + +test.provider( + "snapshot a live sandbox session, redeploy idempotently, and destroy", + (stack) => + Effect.gen(function* () { + const teamId = yield* teamScopeOf; + const projectId = yield* ensureProbeProject; + let sessionId: string | undefined; + + yield* Effect.gen(function* () { + yield* stack.destroy(); + + // Boot a tiny throwaway sandbox session to snapshot. + const booted = yield* sandboxes.createSandboxesV3({ + projectId, + name: SANDBOX_NAME, + timeout: 120_000, + persistent: false, + teamId, + }); + sessionId = booted.session.id; + expect(booted.session.status).toEqual("running"); + + // Deploy the snapshot resource against the running session. + const snapshot = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.SandboxSnapshot("Snap", { + sessionId: booted.session.id, + }); + }), + ); + expect(snapshot.snapshotId).toMatch(/^snap_/); + expect(snapshot.sourceSessionId).toEqual(booted.session.id); + expect(snapshot.status).toEqual("created"); + expect(snapshot.sizeBytes).toBeGreaterThan(0); + + // Out-of-band verification via distilled. + const observed = yield* sandboxes.getSessionSnapshot({ + snapshotId: snapshot.snapshotId, + teamId, + }); + expect(observed.snapshot.id).toEqual(snapshot.snapshotId); + expect(observed.snapshot.status).toEqual("created"); + + // Idempotent redeploy — the existing snapshot is observed, not + // re-taken. + const again = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.SandboxSnapshot("Snap", { + sessionId: booted.session.id, + }); + }), + ); + expect(again.snapshotId).toEqual(snapshot.snapshotId); + + // Destroy deletes the snapshot (platform soft-delete). + yield* stack.destroy(); + const after = yield* Effect.result( + sandboxes.getSessionSnapshot({ + snapshotId: snapshot.snapshotId, + teamId, + }), + ); + if (Result.isSuccess(after)) { + expect(after.success.snapshot.status).toEqual("deleted"); + } else { + expect(after.failure._tag).toBe("NotFound"); + } + }).pipe( + // `sessionId` is assigned mid-flight — suspend so cleanup sees it. + Effect.ensuring( + Effect.suspend(() => cleanup(projectId, sessionId)).pipe( + Effect.ignore, + ), + ), + ); + }).pipe(logLevel), + { timeout: 120_000 }, +); diff --git a/packages/alchemy/test/Vercel/Security/FirewallConfig.test.ts b/packages/alchemy/test/Vercel/Security/FirewallConfig.test.ts new file mode 100644 index 0000000000..5361626a88 --- /dev/null +++ b/packages/alchemy/test/Vercel/Security/FirewallConfig.test.ts @@ -0,0 +1,236 @@ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as projects from "@distilled.cloud/vercel/projects"; +import * as security from "@distilled.cloud/vercel/security"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +const teamScope = Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + return teamId !== undefined ? { teamId } : {}; +}); + +/** Out-of-band read of the active firewall config via distilled. */ +const getActiveConfig = (projectId: string) => + Effect.gen(function* () { + const team = yield* teamScope; + return yield* security + .getFirewallConfig({ configVersion: "active", projectId, ...team }) + .pipe( + Effect.map( + (doc): security.GetFirewallConfigResponse | undefined => doc, + ), + Effect.catchTag("NotFound", () => Effect.succeed(undefined)), + ); + }); + +/** + * Create (or reuse, after an interrupted run) a host project out-of-band via + * distilled — deterministic name — run `body` against it, and always delete + * the project again. + */ +const withHostProject = ( + hostName: string, + body: (projectId: string) => Effect.Effect, +) => + Effect.gen(function* () { + const team = yield* teamScope; + const host = yield* projects + .createProject({ name: hostName, ...team }) + .pipe( + Effect.map((p) => ({ id: p.id })), + Effect.catchTag("Conflict", () => + projects + .getProject({ idOrName: hostName, ...team }) + .pipe(Effect.map((p) => ({ id: p.id }))), + ), + ); + return yield* body(host.id).pipe( + Effect.ensuring( + projects + .deleteProject({ idOrName: host.id, ...team }) + .pipe(Effect.ignore), + ), + ); + }); + +const blockAdminRule = ( + action: Vercel.FirewallRuleAction, + description?: string, +): Vercel.FirewallRule => ({ + name: "block-admin", + ...(description !== undefined ? { description } : {}), + conditionGroup: [ + { conditions: [{ type: "path", op: "pre", value: "/admin" }] }, + ], + action: { action }, +}); + +test.provider( + "firewall config lifecycle: put rules, version increments, converge drift, reset on destroy", + (stack) => + withHostProject("alchemy-test-security-fw-life", (projectId) => + Effect.gen(function* () { + yield* stack.destroy(); + + const props: Vercel.FirewallConfigProps = { + projectId, + rules: [blockAdminRule("challenge")], + }; + + const created = yield* stack.deploy( + Vercel.FirewallConfig("Waf", props), + ); + + expect(created.projectId).toEqual(projectId); + expect(created.firewallEnabled).toEqual(true); + expect(created.rules).toHaveLength(1); + expect(created.rules[0]!.name).toEqual("block-admin"); + expect(created.rules[0]!.active).toEqual(true); + expect(created.rules[0]!.id.length).toBeGreaterThan(0); + expect(created.version).toBeGreaterThanOrEqual(1); + expect(created.ips).toHaveLength(0); + + // Out-of-band verification via distilled. + const observed = yield* getActiveConfig(projectId); + expect(observed).toBeDefined(); + expect(observed!.firewallEnabled).toEqual(true); + expect(observed!.version).toEqual(created.version); + expect(observed!.rules).toHaveLength(1); + const observedRule = observed!.rules[0]! as { + name: string; + active: boolean; + action: { mitigate?: { action?: string } }; + }; + expect(observedRule.name).toEqual("block-admin"); + expect(observedRule.action.mitigate?.action).toEqual("challenge"); + + // Same props again is a true no-op — version untouched. + const noop = yield* stack.deploy(Vercel.FirewallConfig("Waf", props)); + expect(noop.version).toEqual(created.version); + expect(noop.configId).toEqual(created.configId); + + // Drift the config out-of-band: full PUT with an extra rule. + const team = yield* teamScope; + yield* security.putFirewallConfig({ + projectId, + ...team, + firewallEnabled: true, + rules: [ + { + name: "block-admin", + active: true, + conditionGroup: [ + { conditions: [{ type: "path", op: "pre", value: "/admin" }] }, + ], + action: { mitigate: { action: "challenge" } }, + }, + { + name: "drift-rule", + active: true, + conditionGroup: [ + { conditions: [{ type: "path", op: "pre", value: "/drift" }] }, + ], + action: { mitigate: { action: "deny" } }, + }, + ], + ips: [], + }); + const drifted = yield* getActiveConfig(projectId); + expect(drifted!.rules).toHaveLength(2); + expect(drifted!.version).toBeGreaterThan(created.version); + + // Reconcile back: deploy the desired document (rule escalated to + // deny) — the full-replace PUT removes the drift rule. + const converged = yield* stack.deploy( + Vercel.FirewallConfig("Waf", { + projectId, + rules: [blockAdminRule("deny", "escalated to deny")], + }), + ); + expect(converged.rules).toHaveLength(1); + expect(converged.rules[0]!.name).toEqual("block-admin"); + expect(converged.version).toBeGreaterThan(drifted!.version); + + const afterConverge = yield* getActiveConfig(projectId); + expect(afterConverge!.rules).toHaveLength(1); + const convergedRule = afterConverge!.rules[0]! as { + name: string; + description?: string; + action: { mitigate?: { action?: string } }; + }; + expect(convergedRule.name).toEqual("block-admin"); + expect(convergedRule.description).toEqual("escalated to deny"); + expect(convergedRule.action.mitigate?.action).toEqual("deny"); + + // Destroy resets the document to empty + disabled (the API's DELETE + // endpoint targets draft versions, so PUT-empty is the delete + // primitive — the config document itself remains, versioned). + yield* stack.destroy(); + const afterDestroy = yield* getActiveConfig(projectId); + expect(afterDestroy!.firewallEnabled).toEqual(false); + expect(afterDestroy!.rules).toHaveLength(0); + expect(afterDestroy!.ips).toHaveLength(0); + + // Destroy again — delete path is idempotent. + yield* stack.destroy(); + }), + ).pipe(logLevel), + { timeout: 120_000 }, +); + +test.provider( + "changing projectId replaces: new project gets the rules, old project is reset", + (stack) => + withHostProject("alchemy-test-security-fw-repl-a", (projectA) => + withHostProject("alchemy-test-security-fw-repl-b", (projectB) => + Effect.gen(function* () { + yield* stack.destroy(); + + const created = yield* stack.deploy( + Vercel.FirewallConfig("Waf", { + projectId: projectA, + rules: [blockAdminRule("deny")], + }), + ); + expect(created.projectId).toEqual(projectA); + expect(created.rules).toHaveLength(1); + + // Re-parenting onto another project is a replacement: the new + // project's config is written, then the old generation's delete + // resets project A. + const replaced = yield* stack.deploy( + Vercel.FirewallConfig("Waf", { + projectId: projectB, + rules: [blockAdminRule("deny")], + }), + ); + expect(replaced.projectId).toEqual(projectB); + expect(replaced.rules).toHaveLength(1); + expect(replaced.configId).not.toEqual(created.configId); + + const onB = yield* getActiveConfig(projectB); + expect(onB!.firewallEnabled).toEqual(true); + expect(onB!.rules).toHaveLength(1); + + const onA = yield* getActiveConfig(projectA); + expect(onA!.firewallEnabled).toEqual(false); + expect(onA!.rules).toHaveLength(0); + + yield* stack.destroy(); + const afterDestroy = yield* getActiveConfig(projectB); + expect(afterDestroy!.firewallEnabled).toEqual(false); + expect(afterDestroy!.rules).toHaveLength(0); + }), + ), + ).pipe(logLevel), + { timeout: 120_000 }, +); diff --git a/packages/alchemy/test/Vercel/StateStore/Codec.test.ts b/packages/alchemy/test/Vercel/StateStore/Codec.test.ts new file mode 100644 index 0000000000..befa01812c --- /dev/null +++ b/packages/alchemy/test/Vercel/StateStore/Codec.test.ts @@ -0,0 +1,197 @@ +/** + * Credential-free unit tests for the Vercel state store's row codec + * (AES-CTR framing) and Blob pathname scheme — the pure halves shared by + * the deployed state Function and the CLI. + */ +import { + decryptRow, + encryptRow, + importStateKey, + NONCE_BYTES, +} from "@/Vercel/StateStore/Codec"; +import { + familyBaseOf, + isFamilyMember, + latestOfFamily, + outputKey, + parseRowKey, + parseStackIndexKey, + pickLatestPerFamily, + revisionedKey, + revisionToken, + rowKey, + stackIndexKey, + stackOutputsPrefix, + stackRowsPrefix, + stagePrefix, +} from "@/Vercel/StateStore/Keys"; +import { describe, expect, test } from "alchemy-test"; +import * as Effect from "effect/Effect"; + +const KEY_HEX = + "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"; +const OTHER_KEY_HEX = + "ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100"; + +describe("Vercel StateStore Codec", () => { + test("encrypt → decrypt round-trips a resource row", async () => { + const value = { + kind: "resource", + fqn: "stack/scope/resource-a", + instanceId: "inst-a", + props: { hello: "world", nested: { count: 42, flag: true } }, + attr: { id: "inst-a" }, + }; + const roundTripped = await Effect.runPromise( + Effect.gen(function* () { + const key = yield* importStateKey(KEY_HEX); + const framed = yield* encryptRow(key, value); + // Framed base64: nonce || ciphertext, never the plaintext. + expect(framed).not.toContain("hello"); + expect(Buffer.from(framed, "base64").byteLength).toBeGreaterThanOrEqual( + NONCE_BYTES, + ); + return yield* decryptRow(key, framed); + }), + ); + expect(roundTripped).toEqual(value); + }); + + test("two encryptions of the same value differ (random nonce)", async () => { + const [a, b] = await Effect.runPromise( + Effect.gen(function* () { + const key = yield* importStateKey(KEY_HEX); + return [ + yield* encryptRow(key, { v: 1 }), + yield* encryptRow(key, { v: 1 }), + ] as const; + }), + ); + expect(a).not.toEqual(b); + }); + + test("decrypting with the wrong key resolves to undefined (never throws)", async () => { + const result = await Effect.runPromise( + Effect.gen(function* () { + const key = yield* importStateKey(KEY_HEX); + const wrongKey = yield* importStateKey(OTHER_KEY_HEX); + const framed = yield* encryptRow(key, { secret: "value" }); + return yield* decryptRow(wrongKey, framed); + }), + ); + // AES-CTR with the wrong key yields garbage bytes — JSON.parse fails + // and the codec degrades to undefined so the engine reconciles. + expect(result).toBeUndefined(); + }); +}); + +describe("Vercel StateStore Keys", () => { + test("rowKey encodes separators so FQNs cannot collide with the scheme", () => { + const key = rowKey("my-stack", "dev", "stack/scope/resource-a"); + expect(key).toBe("r/my-stack/dev/stack%2Fscope%2Fresource-a"); + expect(key.startsWith(stagePrefix("my-stack", "dev"))).toBe(true); + expect(key.startsWith(stackRowsPrefix("my-stack"))).toBe(true); + }); + + test("parseRowKey round-trips awkward names", () => { + const stack = "we/ird stack"; + const stage = "st%age"; + const fqn = "a/b/c d%2F"; + expect(parseRowKey(rowKey(stack, stage, fqn))).toEqual({ + stack, + stage, + fqn, + }); + }); + + test("parseRowKey rejects foreign pathnames", () => { + expect(parseRowKey("o/stack/stage")).toBeUndefined(); + expect(parseRowKey("s/stack")).toBeUndefined(); + expect(parseRowKey("r/only-two/segments")).toBeUndefined(); + expect(parseRowKey("r/a/b/c/d")).toBeUndefined(); + }); + + test("stage prefixes do not collide across stages sharing a name prefix", () => { + // `dev` vs `dev2` — the trailing `/` separates them. + const key = rowKey("s", "dev2", "fqn"); + expect(key.startsWith(stagePrefix("s", "dev"))).toBe(false); + }); + + test("output keys live under the o/ prefix and parse nowhere else", () => { + const key = outputKey("my-stack", "dev"); + expect(key).toBe("o/my-stack/dev"); + expect(key.startsWith(stackOutputsPrefix("my-stack"))).toBe(true); + expect(parseRowKey(key)).toBeUndefined(); + }); + + test("stack index keys round-trip and reject nested paths", () => { + expect(parseStackIndexKey(stackIndexKey("my/stack"))).toBe("my/stack"); + expect(parseStackIndexKey("s/")).toBeUndefined(); + expect(parseStackIndexKey("s/a/b")).toBeUndefined(); + expect(parseStackIndexKey("r/a")).toBeUndefined(); + }); +}); + +describe("Vercel StateStore Revisions", () => { + const base = rowKey("stack", "stage", "my/fqn"); + + test("revision tokens sort by timestamp, revisioned keys parse to the base", () => { + const older = revisionToken(999, "zzzzzz"); + const newer = revisionToken(1_000, "aaaaaa"); + expect(older < newer).toBe(true); + const key = revisionedKey(base, newer); + expect(familyBaseOf(key)).toBe(base); + expect(parseRowKey(key)).toEqual(parseRowKey(base)); + }); + + test("the @ delimiter cannot appear in encoded segments", () => { + // `@` in stack/stage/fqn is URI-encoded, so a literal `@` always + // marks the revision suffix. + const tricky = rowKey("st@ck", "sta@ge", "fq@n"); + expect(tricky).not.toContain("@"); + expect(parseRowKey(revisionedKey(tricky, revisionToken(1, "x")))).toEqual({ + stack: "st@ck", + stage: "sta@ge", + fqn: "fq@n", + }); + }); + + test("latestOfFamily prefers the highest revision over the legacy base", () => { + const r1 = revisionedKey(base, revisionToken(1_000, "aa")); + const r2 = revisionedKey(base, revisionToken(2_000, "aa")); + // Sibling rows sharing the base as a name prefix are NOT family. + const sibling = `${base}x`; + const siblingRev = revisionedKey(`${base}x`, revisionToken(9_000, "zz")); + expect(latestOfFamily(base, [base, r1, r2, sibling, siblingRev])).toBe(r2); + expect(latestOfFamily(base, [r2, r1])).toBe(r2); + expect(latestOfFamily(base, [base])).toBe(base); + expect(latestOfFamily(base, [sibling, siblingRev])).toBeUndefined(); + expect(latestOfFamily(base, [])).toBeUndefined(); + }); + + test("isFamilyMember distinguishes revisions from prefix-sharing siblings", () => { + expect(isFamilyMember(base, base)).toBe(true); + expect(isFamilyMember(base, revisionedKey(base, "r"))).toBe(true); + expect(isFamilyMember(base, `${base}x`)).toBe(false); + expect(isFamilyMember(base, revisionedKey(`${base}x`, "r"))).toBe(false); + }); + + test("pickLatestPerFamily returns one pathname per row", () => { + const otherBase = rowKey("stack", "stage", "other"); + const picked = pickLatestPerFamily([ + base, + revisionedKey(base, revisionToken(1_000, "aa")), + revisionedKey(base, revisionToken(2_000, "aa")), + otherBase, + revisionedKey(otherBase, revisionToken(500, "bb")), + outputKey("stack", "stage"), + ]); + expect(picked.sort()).toEqual( + [ + revisionedKey(base, revisionToken(2_000, "aa")), + revisionedKey(otherBase, revisionToken(500, "bb")), + outputKey("stack", "stage"), + ].sort(), + ); + }); +}); diff --git a/packages/alchemy/test/Vercel/StateStore/State.test.ts b/packages/alchemy/test/Vercel/StateStore/State.test.ts new file mode 100644 index 0000000000..c6e95c3c3d --- /dev/null +++ b/packages/alchemy/test/Vercel/StateStore/State.test.ts @@ -0,0 +1,248 @@ +/** + * Live end-to-end test for the Vercel-hosted state store (DESIGN §13). + * + * This test OWNS the store's full lifecycle on the test team: it + * bootstraps the state function (deployed by alchemy's own Vercel deploy + * engine, with local state hoisted into the store), exercises the + * StateService surface, deploys a tiny stack USING the store as its + * state backend, verifies the rows out-of-band over the raw HTTP API, + * destroys the stack (rows gone), proves the credential-loss recovery + * path (re-bootstrap reads the token back from the `encrypted` project + * env), and finally tears the store down — the state function is never + * left deployed. + * + * Non-interactive by design: `bootstrap`/`teardownStateStore` are the + * programmatic (CI) path; the interactive prompts live only in + * `Vercel.state()`'s init and are not driven here. + */ +import * as Alchemy from "@/index.ts"; +import { deploy } from "@/Deploy"; +import { destroy } from "@/Destroy"; +import { StateApi } from "@/State/HttpStateApi"; +import { State } from "@/State/State"; +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import { + STATE_STORE_PROJECT_NAME, + STATE_STORE_VERSION, +} from "@/Vercel/StateStore/Api"; +import { + bootstrap, + loginWithVercel, + teardownStateStore, +} from "@/Vercel/StateStore/State"; +import { getProject } from "@distilled.cloud/vercel/projects"; +import { getStorageStores } from "@distilled.cloud/vercel/storage"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +/** + * Credentials-file isolation: bootstrap/teardown write the cached + * `{url, authToken}` under this dedicated profile so the test never + * touches a real profile's state-store credentials. + */ +const TEST_PROFILE = "vercel-state-test"; + +const E2E_STACK = "VercelStateE2E"; +const E2E_STAGE = "e2e"; + +const sampleState = (fqn: string, instanceId: string) => ({ + kind: "resource" as const, + resourceType: "Test.Resource", + namespace: undefined, + fqn, + logicalId: fqn.split("/").pop()!, + instanceId, + providerVersion: 1, + status: "created" as const, + downstream: [], + bindings: [], + props: { hello: "world" }, + attr: { id: instanceId }, +}); + +/** Raw HTTP API client against the deployed store (out-of-band checks). */ +const rawClient = (credentials: { url: string; authToken: string }) => + HttpApiClient.make(StateApi, { + baseUrl: credentials.url, + transformClient: HttpClient.mapRequest((req) => + HttpClientRequest.bearerToken(req, credentials.authToken), + ), + }); + +/** Poll (bounded) until the state project is gone. */ +const expectProjectGone = Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + const gone = yield* getProject({ + idOrName: STATE_STORE_PROJECT_NAME, + teamId, + }).pipe( + Effect.map(() => false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (g) => g, + times: 10, + }), + ); + expect(gone).toBe(true); +}); + +/** The state blob store must be gone from the team listing. */ +const expectStoreGone = Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + const gone = yield* getStorageStores({ teamId }).pipe( + Effect.map( + ({ stores }) => + !stores.some( + (row) => row.type === "blob" && row.name === STATE_STORE_PROJECT_NAME, + ), + ), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (g) => g, + times: 10, + }), + ); + expect(gone).toBe(true); +}); + +test.provider( + "bootstrap → StateService surface → stack on Vercel state → destroy → recovery → teardown", + () => + Effect.gen(function* () { + // Clean slate: reclaim anything a previously crashed run left + // behind (project, blob store, cached credentials, local stack). + yield* teardownStateStore({ profile: TEST_PROFILE }).pipe(Effect.ignore); + + // ── 1. Bootstrap: our own engine deploys the store function + + // private blob store, then hoists the local bootstrap state + // into the store itself. + const store = yield* bootstrap({ profile: TEST_PROFILE }); + expect(yield* store.getVersion()).toBe(STATE_STORE_VERSION); + + // ── 2. StateService surface against the live store. + const fqn = "stack/scope/resource-a"; + yield* store.deleteStack({ stack: "probe" }).pipe(Effect.ignore); + const echoed = yield* store.set({ + stack: "probe", + stage: "s1", + fqn, + value: sampleState(fqn, "inst-a"), + }); + expect(echoed.fqn).toBe(fqn); + const got = yield* store.get({ stack: "probe", stage: "s1", fqn }); + expect(got).toBeDefined(); + expect((got as any).props).toEqual({ hello: "world" }); + expect([...(yield* store.list({ stack: "probe", stage: "s1" }))]).toEqual( + [fqn], + ); + expect([...(yield* store.listStacks())]).toContain("probe"); + expect([...(yield* store.listStages("probe"))]).toEqual(["s1"]); + const out = yield* store.setOutput({ + stack: "probe", + stage: "s1", + value: { url: "https://example.com", count: 42 }, + }); + expect(out).toEqual({ url: "https://example.com", count: 42 }); + expect(yield* store.getOutput({ stack: "probe", stage: "s1" })).toEqual({ + url: "https://example.com", + count: 42, + }); + const missing = yield* store.get({ + stack: "probe", + stage: "s1", + fqn: "does/not/exist", + }); + expect(missing).toBeUndefined(); + yield* store.delete({ stack: "probe", stage: "s1", fqn }); + expect( + yield* store.get({ stack: "probe", stage: "s1", fqn }), + ).toBeUndefined(); + yield* store.deleteStack({ stack: "probe" }); + expect([...(yield* store.listStacks())]).not.toContain("probe"); + + // The bootstrap stack's own state was hoisted INTO the store. + expect([...(yield* store.listStacks())]).toContain("VercelStateStore"); + + // ── 3. A real stack deployed WITH the store as its state backend. + const stateLayer = Layer.succeed(State, Effect.succeed(store)); + const E2EStack = Alchemy.Stack( + E2E_STACK, + { providers: Vercel.providers(), state: stateLayer }, + Effect.gen(function* () { + const cfg = yield* Vercel.EdgeConfig("Cfg", { + items: { greeting: "hello-from-vercel-state" }, + }); + return { edgeConfigId: cfg.edgeConfigId }; + }), + ); + const output = yield* deploy({ stack: E2EStack, stage: E2E_STAGE }).pipe( + Effect.provide(stateLayer), + ); + expect(output.edgeConfigId).toBeDefined(); + + // Out-of-band: the rows are visible over the raw HTTP API with the + // bootstrap credentials. + const credentials = yield* loginWithVercel(TEST_PROFILE, false); + const client = yield* rawClient(credentials); + const fqns = yield* client.state.listResources({ + params: { stack: E2E_STACK, stage: E2E_STAGE }, + }); + expect(fqns.length).toBeGreaterThan(0); + expect(fqns.some((row) => row.includes("Cfg"))).toBe(true); + const stackOutput = (yield* client.state.getStackOutput({ + params: { stack: E2E_STACK, stage: E2E_STAGE }, + })) as { edgeConfigId?: string } | undefined; + expect(stackOutput?.edgeConfigId).toBe(output.edgeConfigId); + + // A bogus bearer is rejected. + const unauthorized = yield* rawClient({ + url: credentials.url, + authToken: "not-the-token", + }).pipe( + Effect.flatMap((bad) => bad.state.listStacks()), + Effect.map(() => false), + Effect.catch((e) => + Effect.succeed( + String((e as { _tag?: string })._tag).startsWith("Unauthorized"), + ), + ), + ); + expect(unauthorized).toBe(true); + + // ── 4. Destroy the stack — its rows disappear from the store. + yield* destroy({ stack: E2EStack, stage: E2E_STAGE }).pipe( + Effect.provide(stateLayer), + ); + const remaining = yield* client.state.listResources({ + params: { stack: E2E_STACK, stage: E2E_STAGE }, + }); + expect([...remaining]).toEqual([]); + + // ── 5. Credential-loss recovery: a second bootstrap re-derives the + // bearer token out-of-band from the `encrypted` project env + // (loginWithVercel force-refresh) and adopts the store. + const readopted = yield* bootstrap({ profile: TEST_PROFILE }); + expect(yield* readopted.getVersion()).toBe(STATE_STORE_VERSION); + + // ── 6. Teardown: project + blob store + credentials all gone. + yield* teardownStateStore({ profile: TEST_PROFILE }); + yield* expectProjectGone; + yield* expectStoreGone; + }).pipe(logLevel), + { timeout: 300_000 }, +); diff --git a/packages/alchemy/test/Vercel/Teams/Team.test.ts b/packages/alchemy/test/Vercel/Teams/Team.test.ts new file mode 100644 index 0000000000..6d249b0425 --- /dev/null +++ b/packages/alchemy/test/Vercel/Teams/Team.test.ts @@ -0,0 +1,187 @@ +import { adopt } from "@/AdoptPolicy"; +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as teams from "@distilled.cloud/vercel/teams"; +import * as user from "@distilled.cloud/vercel/user"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Result from "effect/Result"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +// HARD SAFETY RULE: creating a Vercel team has BILLING side effects. Tests +// never create a team — the ungated suites only ADOPT the standing testing +// team and manage settings that are safely revertible. The creation +// lifecycle is implemented but runs only with VERCEL_TEST_TEAM_CREATE=1 on +// an account where a throwaway team is acceptable. +const CREATE_ENTITLED = !!process.env.VERCEL_TEST_TEAM_CREATE; + +// Deterministic values — same on every run. The team's description ends +// every run on the standing value (the platform ignores empty-string +// updates and rejects null, so a description can never be cleared — manage +// it between two non-empty values instead). +const DESC_MANAGED = "alchemy testing team (managed by Vercel.Team test)"; +const DESC_STANDING = "alchemy testing team"; + +const resolveTeamId = Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + if (teamId !== undefined) return teamId; + const auth = yield* user.getAuthUser({}); + const defaultTeamId = auth.user.defaultTeamId; + if (defaultTeamId === null || defaultTeamId === undefined) { + return yield* Effect.die( + "requires a team-scoped VERCEL_TOKEN or a user with a default team", + ); + } + return defaultTeamId; +}); + +// Ungated probe: pins that team creation is guarded by server-side slug +// validation with a TYPED BadRequest — and, critically, that the probe +// itself can never create a team (the slug is unambiguously invalid). +test.provider( + "createTeam rejects an invalid slug with typed BadRequest (no team created)", + () => + Effect.gen(function* () { + const created = yield* Effect.result( + teams.createTeam({ slug: "ALCHEMY INVALID SLUG !!" }), + ); + expect(Result.isFailure(created)).toBe(true); + if (Result.isFailure(created)) { + // POST /v1/teams is capped at FIVE requests per 24h on this plan + // (`api-teams-post-free`, 429 with retry-after: 86400) — once the + // window is burned, the invalid-slug rejection is unobservable, so + // the typed throttle is an equally valid probe outcome. Either way: + // typed failure, no team created. + expect(["BadRequest", "TooManyRequests"]).toContain( + created.failure._tag, + ); + if (created.failure._tag === "BadRequest") { + expect(created.failure.message).toContain("slug"); + } + } + }).pipe(logLevel), +); + +// The primary ungated lifecycle: adopt the standing team, manage a safely +// revertible setting, and verify destroy RELEASES the adopted team without +// deleting it. +test.provider( + "adopts the standing team, manages settings, and releases without deleting", + (stack) => + Effect.gen(function* () { + const teamId = yield* resolveTeamId; + const before = yield* teams.getTeam({ teamId }); + + yield* stack.destroy(); + + // Adopt the existing team by slug and set the managed description. + const team = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.Team("Team", { + slug: before.slug, + description: DESC_MANAGED, + }).pipe(adopt(true)); + }), + ); + expect(team.teamId).toEqual(teamId); + expect(team.slug).toEqual(before.slug); + expect(team.created).toBe(false); + expect(team.description).toEqual(DESC_MANAGED); + + // Out-of-band verification via distilled. + const observed = yield* teams.getTeam({ teamId }); + expect(observed.description).toEqual(DESC_MANAGED); + + // Update in place — revert the description to the standing value. + const reverted = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.Team("Team", { + slug: before.slug, + description: DESC_STANDING, + }).pipe(adopt(true)); + }), + ); + expect(reverted.teamId).toEqual(teamId); + expect(reverted.description).toEqual(DESC_STANDING); + + // Destroy releases the adopted team — it must still exist untouched. + yield* stack.destroy(); + const after = yield* teams.getTeam({ teamId }); + expect(after.id).toEqual(teamId); + expect(after.slug).toEqual(before.slug); + expect(after.description).toEqual(DESC_STANDING); + }).pipe(logLevel), + { timeout: 120_000 }, +); + +// Idempotent redeploy: same props → no drift, same identity. +test.provider( + "redeploy with unchanged settings is a stable no-op", + (stack) => + Effect.gen(function* () { + const teamId = yield* resolveTeamId; + const before = yield* teams.getTeam({ teamId }); + + yield* stack.destroy(); + + const first = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.Team("Team", { + slug: before.slug, + description: DESC_STANDING, + }).pipe(adopt(true)); + }), + ); + const second = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.Team("Team", { + slug: before.slug, + description: DESC_STANDING, + }).pipe(adopt(true)); + }), + ); + expect(second.teamId).toEqual(first.teamId); + expect(second.updatedAt).toBeDefined(); + expect(second.description).toEqual(DESC_STANDING); + + yield* stack.destroy(); + const after = yield* teams.getTeam({ teamId }); + expect(after.id).toEqual(teamId); + }).pipe(logLevel), + { timeout: 120_000 }, +); + +// Gated: the full create→update→delete lifecycle. Creating a team has +// billing side effects, so this only runs when explicitly requested. +test.provider.skipIf(!CREATE_ENTITLED)( + "create, update, and destroy a team (VERCEL_TEST_TEAM_CREATE=1)", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const team = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.Team("CreatedTeam", { + name: "alchemy created team", + }); + }), + ); + expect(team.teamId).toMatch(/^team_/); + expect(team.created).toBe(true); + + const observed = yield* teams.getTeam({ teamId: team.teamId }); + expect(observed.slug).toEqual(team.slug); + + yield* stack.destroy(); + const gone = yield* Effect.result(teams.getTeam({ teamId: team.teamId })); + expect(Result.isFailure(gone)).toBe(true); + }).pipe(logLevel), + { timeout: 120_000 }, +); diff --git a/packages/alchemy/test/Vercel/Teams/TeamMember.test.ts b/packages/alchemy/test/Vercel/Teams/TeamMember.test.ts new file mode 100644 index 0000000000..6f24d12d06 --- /dev/null +++ b/packages/alchemy/test/Vercel/Teams/TeamMember.test.ts @@ -0,0 +1,139 @@ +import * as Provider from "@/Provider"; +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as teams from "@distilled.cloud/vercel/teams"; +import * as user from "@distilled.cloud/vercel/user"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Result from "effect/Result"; +import * as Schedule from "effect/Schedule"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +// HARD SAFETY RULE: inviting a member sends a REAL email invitation. Tests +// never invite external addresses — the ungated suites are read-only plus a +// probe whose email is unambiguously invalid (fails server-side validation +// before any invitation exists). The full lifecycle runs only with +// VERCEL_TEST_TEAM_INVITE=1 and an explicitly-provided address you control. +const INVITE_ENTITLED = !!process.env.VERCEL_TEST_TEAM_INVITE; +const INVITE_EMAIL = process.env.VERCEL_TEST_TEAM_INVITE_EMAIL; + +const resolveTeamId = Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + if (teamId !== undefined) return teamId; + const auth = yield* user.getAuthUser({}); + const defaultTeamId = auth.user.defaultTeamId; + if (defaultTeamId === null || defaultTeamId === undefined) { + return yield* Effect.die( + "requires a team-scoped VERCEL_TOKEN or a user with a default team", + ); + } + return defaultTeamId; +}); + +// Read-only: the provider's list enumerates the standing team's members. +test.provider("list enumerates the standing team's members", () => + Effect.gen(function* () { + const teamId = yield* resolveTeamId; + const provider = yield* Provider.findProvider(Vercel.TeamMember); + const members = yield* provider.list(); + expect(members.length).toBeGreaterThanOrEqual(1); + for (const member of members) { + expect(member.uid).toBeDefined(); + expect(member.email).toContain("@"); + expect(member.role).toBeDefined(); + expect(member.teamId).toEqual(teamId); + } + }).pipe(logLevel), +); + +// Ungated probe: pins that the (patched, object-body) invite endpoint is +// wired correctly — an invalid email reaches server-side validation and is +// rejected with a TYPED BadRequest, and no invitation is ever created. +test.provider( + "invite with an invalid email is rejected with typed BadRequest", + () => + Effect.gen(function* () { + const teamId = yield* resolveTeamId; + const invited = yield* Effect.result( + teams.inviteUserToTeam({ + teamId, + email: "not-an-email", + role: "MEMBER", + }), + ); + expect(Result.isFailure(invited)).toBe(true); + if (Result.isFailure(invited)) { + expect(invited.failure._tag).toBe("BadRequest"); + if (invited.failure._tag === "BadRequest") { + // `invalid_email` — proof the object body reached validation + // (the unpatched array body dies earlier with "Expected an + // object"). + expect(invited.failure.message).toContain("email"); + } + } + }).pipe(logLevel), +); + +// Gated: full invite→role-update→remove lifecycle against an address the +// operator controls. +test.provider.skipIf(!INVITE_ENTITLED || !INVITE_EMAIL)( + "invite, update role, and remove a member (VERCEL_TEST_TEAM_INVITE=1)", + (stack) => + Effect.gen(function* () { + const teamId = yield* resolveTeamId; + const email = INVITE_EMAIL!; + + yield* stack.destroy(); + + const member = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.TeamMember("Member", { + email, + role: "MEMBER", + }); + }), + ); + expect(member.email.toLowerCase()).toEqual(email.toLowerCase()); + expect(member.role).toEqual("MEMBER"); + expect(member.teamId).toEqual(teamId); + + // Role update in place — same uid. + const updated = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.TeamMember("Member", { + email, + role: "DEVELOPER", + }); + }), + ); + expect(updated.uid).toEqual(member.uid); + expect(updated.role).toEqual("DEVELOPER"); + + yield* stack.destroy(); + + // Typed wait-until-gone via the members feed. + const stillThere = yield* teams + .getTeamMembers({ teamId, limit: 100, search: email }) + .pipe( + Effect.map((page) => + page.members.some( + (m) => m.email.toLowerCase() === email.toLowerCase(), + ), + ), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (exists) => !exists, + times: 10, + }), + ); + expect(stillThere).toBe(false); + }).pipe(logLevel), + { timeout: 120_000 }, +); diff --git a/packages/alchemy/test/Vercel/Webhooks/Webhook.test.ts b/packages/alchemy/test/Vercel/Webhooks/Webhook.test.ts new file mode 100644 index 0000000000..1e509f1cf4 --- /dev/null +++ b/packages/alchemy/test/Vercel/Webhooks/Webhook.test.ts @@ -0,0 +1,172 @@ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as webhooks from "@distilled.cloud/vercel/webhooks"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Result from "effect/Result"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +const teamScope = Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + return teamId !== undefined ? { teamId } : {}; +}); + +/** Out-of-band read via distilled; `undefined` = webhook does not exist. */ +const getHook = (id: string) => + Effect.gen(function* () { + const team = yield* teamScope; + return yield* webhooks.getWebhook({ id, ...team }).pipe( + Effect.map((hook): webhooks.GetWebhookResponse | undefined => hook), + Effect.catchTag("NotFound", () => Effect.succeed(undefined)), + ); + }); + +test.provider("create webhook with url and events", (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const hook = yield* stack.deploy( + Vercel.Webhook("Hook", { + url: "https://example.com/alchemy/webhook-create", + events: ["deployment.created"], + }), + ); + + expect(hook.webhookId).toBeDefined(); + expect(hook.url).toEqual("https://example.com/alchemy/webhook-create"); + expect(hook.events).toEqual(["deployment.created"]); + expect(hook.ownerId).toBeDefined(); + // The signing secret is returned exactly once at create — the provider + // must have persisted it into the attributes. + expect(hook.secret.length).toBeGreaterThan(0); + + // Out-of-band verification via distilled: live and listed. + const fetched = yield* getHook(hook.webhookId); + expect(fetched).toBeDefined(); + expect(fetched!.url).toEqual("https://example.com/alchemy/webhook-create"); + expect([...fetched!.events]).toEqual(["deployment.created"]); + + const team = yield* teamScope; + // The list response is a union of two array shapes; both items carry `id`. + const listed: ReadonlyArray<{ id: string }> = yield* webhooks.getWebhooks({ + ...team, + }); + expect(listed.some((h) => h.id === hook.webhookId)).toBe(true); + + yield* stack.destroy(); + expect(yield* getHook(hook.webhookId)).toBeUndefined(); + }).pipe(logLevel), +); + +test.provider("changing the event list replaces the webhook", (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const first = yield* stack.deploy( + Vercel.Webhook("ReplaceHook", { + url: "https://example.com/alchemy/webhook-replace", + events: ["deployment.created"], + }), + ); + + // Same props (events reordered = same set) is a no-op: same id, same + // secret, no replacement. + const noop = yield* stack.deploy( + Vercel.Webhook("ReplaceHook", { + url: "https://example.com/alchemy/webhook-replace", + events: ["deployment.created"], + }), + ); + expect(noop.webhookId).toEqual(first.webhookId); + expect(noop.secret).toEqual(first.secret); + + // Changing the event list must replace (no PATCH on Vercel webhooks): + // the engine creates the successor first, then deletes the old one. + const second = yield* stack.deploy( + Vercel.Webhook("ReplaceHook", { + url: "https://example.com/alchemy/webhook-replace", + events: ["deployment.created", "deployment.succeeded"], + }), + ); + expect(second.webhookId).not.toEqual(first.webhookId); + // A replacement mints a fresh signing secret. + expect(second.secret).not.toEqual(first.secret); + expect([...second.events].sort()).toEqual([ + "deployment.created", + "deployment.succeeded", + ]); + + // Old id gone, new id live (out-of-band via distilled). + expect(yield* getHook(first.webhookId)).toBeUndefined(); + const live = yield* getHook(second.webhookId); + expect(live).toBeDefined(); + expect([...live!.events].sort()).toEqual([ + "deployment.created", + "deployment.succeeded", + ]); + + yield* stack.destroy(); + expect(yield* getHook(second.webhookId)).toBeUndefined(); + }).pipe(logLevel), +); + +test.provider( + "destroy is idempotent when the webhook is already gone", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const hook = yield* stack.deploy( + Vercel.Webhook("OrphanHook", { + url: "https://example.com/alchemy/webhook-orphan", + events: ["project.created"], + }), + ); + + // Delete out-of-band, then destroy: the provider must treat the typed + // NotFound as success (delete is idempotent by doctrine). + const team = yield* teamScope; + yield* webhooks.deleteWebhook({ id: hook.webhookId, ...team }); + expect(yield* getHook(hook.webhookId)).toBeUndefined(); + + yield* stack.destroy(); + }).pipe(logLevel), +); + +// Ungated probe: pins the distilled patch that types the 404 on +// getWebhook/deleteWebhook (code "not_found", "Webhook not found.") as the +// NotFound tag — proves the typed union at near-zero cost, forever. +test.provider("missing webhook surfaces the typed NotFound tag", () => + Effect.gen(function* () { + const team = yield* teamScope; + + const got = yield* Effect.result( + webhooks.getWebhook({ + id: "account_hook_alchemy_missing_probe", + ...team, + }), + ); + expect(Result.isFailure(got)).toBe(true); + if (Result.isFailure(got)) { + expect(got.failure._tag).toEqual("NotFound"); + } + + const deleted = yield* Effect.result( + webhooks.deleteWebhook({ + id: "account_hook_alchemy_missing_probe", + ...team, + }), + ); + expect(Result.isFailure(deleted)).toBe(true); + if (Result.isFailure(deleted)) { + expect(deleted.failure._tag).toEqual("NotFound"); + } + }).pipe(logLevel), +); diff --git a/packages/alchemy/test/Vercel/Website/Astro.test.ts b/packages/alchemy/test/Vercel/Website/Astro.test.ts new file mode 100644 index 0000000000..7a1ab99672 --- /dev/null +++ b/packages/alchemy/test/Vercel/Website/Astro.test.ts @@ -0,0 +1,145 @@ +/** + * Vercel.Website.Astro lifecycle test. + * + * LIVE-VERIFIED (2026-08-13: install + build + deploy + serve + destroy + * green) but GATED behind VERCEL_TEST_FRAMEWORKS=1: the `@astrojs/vercel` + * adapter is not vendored in this repository (only `astro` itself is + * hoisted), so the fixture installs its own dependencies at build time + * (`bun install`, network access required). Ungated CI would be slow and + * flaky on registry weather — run explicitly with: + * + * VERCEL_TEST_FRAMEWORKS=1 doppler run --project alchemy-v2 --config dev -- \ + * bun run test test/Vercel/Website/Astro.test.ts + */ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as projects from "@distilled.cloud/vercel/projects"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; +import * as HttpClient from "effect/unstable/http/HttpClient"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +const readiness = Schedule.max([ + Schedule.exponential("500 millis"), + Schedule.recurs(20), +]); + +const getText = (url: string) => + HttpClient.get(url).pipe( + Effect.flatMap((response) => + response.status === 200 + ? response.text + : Effect.fail(new Error(`status ${response.status}`)), + ), + Effect.retry({ schedule: readiness }), + ); + +const expectProjectGone = (projectId: string) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + const gone = yield* projects + .getProject({ idOrName: projectId, teamId }) + .pipe( + Effect.map(() => false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (g) => g, + times: 10, + }), + ); + expect(gone).toBe(true); + }); + +/** Minimal Astro project with the Vercel adapter (installed at build). */ +const writeFixture = Effect.fn(function* (dir: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(dir, "src", "pages"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(dir, "package.json"), + JSON.stringify( + { + name: "alchemy-vercel-astro-fixture", + version: "0.0.0", + private: true, + type: "module", + dependencies: { + "@astrojs/vercel": "^8", + astro: "^5", + }, + }, + null, + 2, + ), + ); + yield* fs.writeFileString( + path.join(dir, "astro.config.mjs"), + [ + `import vercel from "@astrojs/vercel";`, + `export default { output: "server", adapter: vercel() };`, + ``, + ].join("\n"), + ); + yield* fs.writeFileString( + path.join(dir, "src", "pages", "index.astro"), + "

astro-vercel-marker

\n", + ); + yield* fs.writeFileString( + path.join(dir, ".gitignore"), + "dist\n.astro\n.vercel\nnode_modules\n", + ); + // `&&` needs a shell — ship the install+build as an executable script. + const buildSh = path.join(dir, "build.sh"); + yield* fs.writeFileString( + buildSh, + "#!/bin/sh\nset -e\nbun install\nbunx astro build\n", + ); + yield* fs.chmod(buildSh, 0o755); +}); + +test.provider.skipIf(!process.env.VERCEL_TEST_FRAMEWORKS)( + "astro site: adapter build, deploy, drive, destroy", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectory({ + prefix: "alchemy-vercel-astro-", + }); + yield* Effect.addFinalizer(() => + Effect.ignore(fs.remove(dir, { recursive: true })), + ); + yield* writeFixture(dir); + + const site = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.Website.Astro("Site", { + rootDir: dir, + command: "./build.sh", + }); + }), + ); + expect(site.url).toBeDefined(); + + const page = yield* getText(`${site.url}/`); + expect(page).toContain("astro-vercel-marker"); + + yield* stack.destroy(); + yield* expectProjectGone(site.projectId); + }).pipe(logLevel), + { timeout: 300_000 }, +); diff --git a/packages/alchemy/test/Vercel/Website/Nuxt.test.ts b/packages/alchemy/test/Vercel/Website/Nuxt.test.ts new file mode 100644 index 0000000000..359245fbfb --- /dev/null +++ b/packages/alchemy/test/Vercel/Website/Nuxt.test.ts @@ -0,0 +1,150 @@ +/** + * Vercel.Website.Nuxt lifecycle test — live against the standing Vercel + * test team (run with the doppler alchemy-v2/dev env). + * + * The fixture is generated into `packages/alchemy/.tmp` so `nuxi`/`nuxt` + * resolve from the workspace's hoisted node_modules (the same convention + * as the Cloudflare Nuxt tests). Nitro's `vercel` preset is built in — no + * adapter package is needed. + */ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as projects from "@distilled.cloud/vercel/projects"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as pathe from "pathe"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +// Keep the temp fixture under the alchemy package so `nuxt`/`nitropack` +// resolve from the workspace's hoisted node_modules. +const tempRoot = pathe.resolve(import.meta.dirname, "../../../.tmp"); + +const readiness = Schedule.max([ + Schedule.exponential("500 millis"), + Schedule.recurs(20), +]); + +const getText = (url: string) => + HttpClient.get(url).pipe( + Effect.flatMap((response) => + response.status === 200 + ? response.text + : Effect.fail(new Error(`status ${response.status}`)), + ), + Effect.retry({ schedule: readiness }), + ); + +const expectProjectGone = (projectId: string) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + const gone = yield* projects + .getProject({ idOrName: projectId, teamId }) + .pipe( + Effect.map(() => false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (g) => g, + times: 10, + }), + ); + expect(gone).toBe(true); + }); + +/** Write the minimal Nuxt fixture (one page + one server route). */ +const writeFixture = Effect.fn(function* (dir: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(dir, "app"), { recursive: true }); + yield* fs.makeDirectory(path.join(dir, "server", "api"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(dir, "package.json"), + JSON.stringify( + { + name: "alchemy-vercel-nuxt-fixture", + version: "0.0.0", + private: true, + type: "module", + }, + null, + 2, + ), + ); + yield* fs.writeFileString( + path.join(dir, "nuxt.config.ts"), + [ + `import { defineNuxtConfig } from "nuxt/config";`, + `export default defineNuxtConfig({`, + ` compatibilityDate: "2026-07-01",`, + ` telemetry: { enabled: false },`, + `});`, + ``, + ].join("\n"), + ); + yield* fs.writeFileString( + path.join(dir, "app", "app.vue"), + "\n", + ); + yield* fs.writeFileString( + path.join(dir, "server", "api", "hello.ts"), + `export default defineEventHandler(() => ({ ok: true, from: "nitro" }));\n`, + ); + // Build output must stay out of the memo input hash. + yield* fs.writeFileString( + path.join(dir, ".gitignore"), + ".nuxt\n.output\n.vercel\nnode_modules\n", + ); +}); + +test.provider( + "nuxt site: build via nitro vercel preset, deploy, drive SSR + API, destroy", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(tempRoot, { recursive: true }); + const dir = yield* fs.makeTempDirectory({ + prefix: "alchemy-vercel-nuxt-", + directory: tempRoot, + }); + yield* Effect.addFinalizer(() => + Effect.ignore(fs.remove(dir, { recursive: true })), + ); + yield* writeFixture(dir); + + const site = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.Website.Nuxt("Site", { rootDir: dir }); + }), + ); + expect(site.projectId).toBeDefined(); + expect(site.deploymentId).not.toEqual(""); + expect(site.url).toBeDefined(); + + // SSR page renders the fixture marker over the production alias. + const page = yield* getText(`${site.url}/`); + expect(page).toContain("vercel-nuxt-marker"); + + // Nitro server route runs as a serverless function. + const api = yield* getText(`${site.url}/api/hello`); + expect(JSON.parse(api)).toEqual({ ok: true, from: "nitro" }); + + yield* stack.destroy(); + yield* expectProjectGone(site.projectId); + }).pipe(logLevel), + { timeout: 300_000 }, +); diff --git a/packages/alchemy/test/Vercel/Website/StaticSite.test.ts b/packages/alchemy/test/Vercel/Website/StaticSite.test.ts new file mode 100644 index 0000000000..5a648fbea6 --- /dev/null +++ b/packages/alchemy/test/Vercel/Website/StaticSite.test.ts @@ -0,0 +1,142 @@ +/** + * Vercel.Website.StaticSite lifecycle tests — live against the standing + * Vercel test team (run with the doppler alchemy-v2/dev env). + */ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as projects from "@distilled.cloud/vercel/projects"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; +import * as HttpClient from "effect/unstable/http/HttpClient"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +// Fresh .vercel.app URLs take a few seconds to start serving 200s — always +// retry the first request (bounded). +const readiness = Schedule.max([ + Schedule.exponential("500 millis"), + Schedule.recurs(20), +]); + +const getText = (url: string) => + HttpClient.get(url).pipe( + Effect.flatMap((response) => + response.status === 200 + ? response.text + : Effect.fail(new Error(`status ${response.status}`)), + ), + Effect.retry({ schedule: readiness }), + ); + +/** Poll (bounded) until the URL serves a body containing `marker`. */ +const expectUrlContains = (url: string, marker: string) => + Effect.gen(function* () { + const text = yield* getText(url).pipe( + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (t) => t.includes(marker), + times: 20, + }), + ); + expect(text).toContain(marker); + }); + +/** Poll (bounded) until the site's project is gone. */ +const expectProjectGone = (projectId: string) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + const gone = yield* projects + .getProject({ idOrName: projectId, teamId }) + .pipe( + Effect.map(() => false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (g) => g, + times: 10, + }), + ); + expect(gone).toBe(true); + }); + +const htmlPage = (marker: string) => + `\n

${marker}

\n`; + +test.provider( + "static site: deploy, serve, no-op redeploy, content change, destroy", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + // Fixture generated in a temp dir: index.html + a cp build script. + const dir = yield* fs.makeTempDirectory({ + prefix: "alchemy-vercel-static-", + }); + yield* Effect.addFinalizer(() => + Effect.ignore(fs.remove(dir, { recursive: true })), + ); + yield* fs.makeDirectory(path.join(dir, "src"), { recursive: true }); + // dist is build output — keep it out of the memo input hash. + yield* fs.writeFileString(path.join(dir, ".gitignore"), "dist\n"); + const buildSh = path.join(dir, "build.sh"); + yield* fs.writeFileString( + buildSh, + "#!/bin/sh\nmkdir -p dist\ncp src/index.html dist/index.html\n", + ); + yield* fs.chmod(buildSh, 0o755); + yield* fs.writeFileString( + path.join(dir, "src", "index.html"), + htmlPage("static-marker-v1"), + ); + + const makeSite = () => + Effect.gen(function* () { + return yield* Vercel.Website.StaticSite("Site", { + command: "./build.sh", + cwd: dir, + outdir: "dist", + }); + }); + + // 1. Greenfield deploy — served over the production alias. + const site1 = yield* stack.deploy(makeSite()); + expect(site1.projectId).toBeDefined(); + expect(site1.deploymentId).not.toEqual(""); + expect(site1.url).toBeDefined(); + yield* expectUrlContains(`${site1.url}/index.html`, "static-marker-v1"); + + // 2. Redeploy with unchanged content: build memo + skip-on-hash keep + // the deployment (no new immutable deployment is minted). + const site2 = yield* stack.deploy(makeSite()); + expect(site2.projectId).toEqual(site1.projectId); + expect(site2.deploymentId).toEqual(site1.deploymentId); + + // 3. Content change rebuilds and redeploys — the production alias + // serves the new content and the old marker is fully replaced. + yield* fs.writeFileString( + path.join(dir, "src", "index.html"), + htmlPage("static-marker-v2"), + ); + const site3 = yield* stack.deploy(makeSite()); + expect(site3.projectId).toEqual(site1.projectId); + expect(site3.deploymentId).not.toEqual(site1.deploymentId); + yield* expectUrlContains(`${site3.url}/index.html`, "static-marker-v2"); + + // 4. Destroy cascades the owned project; typed wait-until-gone. + yield* stack.destroy(); + yield* expectProjectGone(site1.projectId); + }).pipe(logLevel), + { timeout: 180_000 }, +); diff --git a/packages/alchemy/test/Vercel/Website/SvelteKit.test.ts b/packages/alchemy/test/Vercel/Website/SvelteKit.test.ts new file mode 100644 index 0000000000..95f33f5889 --- /dev/null +++ b/packages/alchemy/test/Vercel/Website/SvelteKit.test.ts @@ -0,0 +1,161 @@ +/** + * Vercel.Website.SvelteKit lifecycle test. + * + * LIVE-VERIFIED (2026-08-13: install + build + deploy + serve + destroy + * green) but GATED behind VERCEL_TEST_FRAMEWORKS=1: the + * `@sveltejs/adapter-vercel` adapter is not vendored in this repository, + * so the fixture installs its own dependencies at build time + * (`bun install`, network access required). Run explicitly with: + * + * VERCEL_TEST_FRAMEWORKS=1 doppler run --project alchemy-v2 --config dev -- \ + * bun run test test/Vercel/Website/SvelteKit.test.ts + */ +import * as Test from "@/Test/Alchemy"; +import * as Vercel from "@/Vercel"; +import * as projects from "@distilled.cloud/vercel/projects"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; +import * as HttpClient from "effect/unstable/http/HttpClient"; + +const { test } = Test.make({ providers: Vercel.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +const readiness = Schedule.max([ + Schedule.exponential("500 millis"), + Schedule.recurs(20), +]); + +const getText = (url: string) => + HttpClient.get(url).pipe( + Effect.flatMap((response) => + response.status === 200 + ? response.text + : Effect.fail(new Error(`status ${response.status}`)), + ), + Effect.retry({ schedule: readiness }), + ); + +const expectProjectGone = (projectId: string) => + Effect.gen(function* () { + const { teamId } = yield* Vercel.VercelEnvironment.current; + const gone = yield* projects + .getProject({ idOrName: projectId, teamId }) + .pipe( + Effect.map(() => false), + Effect.catchTag("NotFound", () => Effect.succeed(true)), + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (g) => g, + times: 10, + }), + ); + expect(gone).toBe(true); + }); + +/** Minimal SvelteKit project with the Vercel adapter (installed at build). */ +const writeFixture = Effect.fn(function* (dir: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(dir, "src", "routes"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(dir, "package.json"), + JSON.stringify( + { + name: "alchemy-vercel-sveltekit-fixture", + version: "0.0.0", + private: true, + type: "module", + devDependencies: { + "@sveltejs/adapter-vercel": "^5", + "@sveltejs/kit": "^2", + "@sveltejs/vite-plugin-svelte": "^5", + svelte: "^5", + vite: "^6", + }, + }, + null, + 2, + ), + ); + yield* fs.writeFileString( + path.join(dir, "svelte.config.js"), + [ + `import adapter from "@sveltejs/adapter-vercel";`, + // Explicit runtime: the adapter otherwise derives it from the local + // Node version and rejects versions it doesn't recognize. + `export default { kit: { adapter: adapter({ runtime: "nodejs22.x" }) } };`, + ``, + ].join("\n"), + ); + yield* fs.writeFileString( + path.join(dir, "vite.config.js"), + [ + `import { sveltekit } from "@sveltejs/kit/vite";`, + `export default { plugins: [sveltekit()] };`, + ``, + ].join("\n"), + ); + yield* fs.writeFileString( + path.join(dir, "src", "app.html"), + "\n%sveltekit.head%%sveltekit.body%\n", + ); + yield* fs.writeFileString( + path.join(dir, "src", "routes", "+page.svelte"), + "

sveltekit-vercel-marker

\n", + ); + yield* fs.writeFileString( + path.join(dir, ".gitignore"), + ".svelte-kit\n.vercel\nnode_modules\n", + ); + // `&&` needs a shell — ship the install+build as an executable script. + const buildSh = path.join(dir, "build.sh"); + yield* fs.writeFileString( + buildSh, + "#!/bin/sh\nset -e\nbun install\nbunx vite build\n", + ); + yield* fs.chmod(buildSh, 0o755); +}); + +test.provider.skipIf(!process.env.VERCEL_TEST_FRAMEWORKS)( + "sveltekit site: adapter build, deploy, drive, destroy", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectory({ + prefix: "alchemy-vercel-sveltekit-", + }); + yield* Effect.addFinalizer(() => + Effect.ignore(fs.remove(dir, { recursive: true })), + ); + yield* writeFixture(dir); + + const site = yield* stack.deploy( + Effect.gen(function* () { + return yield* Vercel.Website.SvelteKit("Site", { + rootDir: dir, + command: "./build.sh", + }); + }), + ); + expect(site.url).toBeDefined(); + + const page = yield* getText(`${site.url}/`); + expect(page).toContain("sveltekit-vercel-marker"); + + yield* stack.destroy(); + yield* expectProjectGone(site.projectId); + }).pipe(logLevel), + { timeout: 300_000 }, +); diff --git a/packages/alchemy/test/plan.test.ts b/packages/alchemy/test/plan.test.ts index e71a4357a2..87f48b9efc 100644 --- a/packages/alchemy/test/plan.test.ts +++ b/packages/alchemy/test/plan.test.ts @@ -3316,6 +3316,95 @@ describe("engine-level adoption", () => { }), ); + // Unresolved sibling Outputs in `news` (e.g. env referencing a resource + // created in the same plan) must NOT suppress the adoption probe: a + // resource with a deterministic physical identity can pre-exist even + // while carrying unresolved non-identity inputs. Skipping the probe here + // let reconcile silently converge onto an existing unowned resource — + // adoption without consent. + test( + "Unowned read result + adopt disabled -> OwnedBySomeoneElse even when props carry unresolved sibling Outputs", + Effect.gen(function* () { + const probed: string[] = []; + const exit = yield* makeAdoptPlan( + Effect.gen(function* () { + const upstream = yield* TestResource("Upstream", { string: "up" }); + // `upstream.string` is an unresolved Output at plan time. + yield* TestResource("Foreign", { string: upstream.string }); + }), + { + adopt: false, + readHook: (id) => + Effect.suspend(() => { + probed.push(id); + return id === "Foreign" + ? Effect.succeed(Unowned(ownedAttrs)) + : Effect.succeed(undefined); + }), + }, + ).pipe(Effect.exit); + + expect(probed).toContain("Foreign"); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const reason = exit.cause.reasons.find(Cause.isFailReason); + expect((reason?.error as any)?._tag).toBe("OwnedBySomeoneElse"); + expect((reason?.error as any)?.resourceType).toBe("Test.TestResource"); + } + }), + ); + + test( + "owned read result is adopted even when props carry unresolved sibling Outputs", + Effect.gen(function* () { + const plan = yield* makeAdoptPlan( + Effect.gen(function* () { + const upstream = yield* TestResource("Upstream", { string: "up" }); + yield* TestResource("Adopted", { string: upstream.string }); + }), + { + readHook: (id) => + id === "Adopted" + ? Effect.succeed(ownedAttrs) + : Effect.succeed(undefined), + }, + ); + + expect(plan.resources.Adopted!.action).toBe("update"); + expect(plan.resources.Adopted).toMatchObject({ + adopting: true, + state: { status: "created" }, + }); + // The sibling itself is a plain create. + expect(plan.resources.Upstream!.action).toBe("create"); + }), + ); + + // With unresolved inputs the probe hands `read` a stripped (sanitized) + // props shape; a read whose identity genuinely lived in the stripped + // value may fail. That probe is best-effort: failure degrades to "not + // pre-existing" (the pre-probe behavior) instead of failing the plan. + test( + "probe failure with unresolved inputs degrades to an ordinary create", + Effect.gen(function* () { + const plan = yield* makeAdoptPlan( + Effect.gen(function* () { + const upstream = yield* TestResource("Upstream", { string: "up" }); + yield* TestResource("Fresh", { string: upstream.string }); + }), + { + readHook: (id) => + id === "Fresh" + ? Effect.fail(new Error("identity not derivable")) + : Effect.succeed(undefined), + }, + ); + + expect(plan.resources.Fresh!.action).toBe("create"); + expect(plan.resources.Fresh!.state).toBeUndefined(); + }), + ); + test( "read returns undefined -> ordinary create", Effect.gen(function* () { diff --git a/packages/alchemy/tsconfig.json b/packages/alchemy/tsconfig.json index 344b38b0e8..4301b8938a 100644 --- a/packages/alchemy/tsconfig.json +++ b/packages/alchemy/tsconfig.json @@ -55,6 +55,9 @@ { "path": "../../distilled/packages/neon/tsconfig.json" }, + { + "path": "../../distilled/packages/vercel/tsconfig.json" + }, { "path": "../cloudflare-runtime/tsconfig.json" } diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 6fad3f9ec3..708757fa2b 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -25,6 +25,7 @@ function providersSidebarEntry() { items: [ { label: "AWS", link: "/aws" }, { label: "Cloudflare", link: "/cloudflare" }, + { label: "Vercel", link: "/vercel" }, { label: "PlanetScale", link: "/planetscale" }, { label: "Neon", link: "/neon" }, { label: "Prisma", link: "/prisma" }, @@ -949,6 +950,113 @@ export default defineConfig({ providerResourcesEntry("AWS"), ], }, + { + label: "Vercel", + items: [ + { label: "Overview", link: "/vercel" }, + { label: "Setup", link: "/vercel/setup" }, + { + label: "Tutorial", + items: [{ autogenerate: { directory: "vercel/tutorial" } }], + }, + { + label: "Compute", + items: [ + { label: "Functions", link: "/vercel/compute/functions" }, + { + label: "Fluid compute", + link: "/vercel/compute/fluid-compute", + }, + { + label: "Deployments & rollback", + link: "/vercel/compute/deployments-and-rollback", + }, + { label: "Cron", link: "/vercel/compute/cron" }, + ], + }, + { + label: "Frontend", + items: [ + { + label: "Overview", + link: "/vercel/frontend/websites", + }, + { label: "Astro", link: "/vercel/frontend/astro" }, + { label: "Nuxt", link: "/vercel/frontend/nuxt" }, + { + label: "Static sites", + link: "/vercel/frontend/static-site", + }, + { + label: "SvelteKit", + link: "/vercel/frontend/sveltekit", + }, + ], + }, + { + label: "APIs", + items: [ + { + label: "Effect HTTP", + link: "/vercel/apis/effect-http-api", + }, + { + label: "Invoke a Function", + link: "/vercel/apis/invoke-function", + }, + ], + }, + { + label: "Data", + items: [ + { label: "Blob", link: "/vercel/data/blob" }, + { label: "Edge Config", link: "/vercel/data/edge-config" }, + ], + }, + { + label: "Messaging & events", + items: [ + { label: "Queues", link: "/vercel/messaging/queues" }, + { label: "Webhooks", link: "/vercel/messaging/webhooks" }, + ], + }, + { + label: "Security & secrets", + items: [ + { + label: "Secrets & env", + link: "/vercel/security/secrets-env", + }, + { + label: "Deployment protection", + link: "/vercel/security/deployment-protection", + }, + { label: "Firewall", link: "/vercel/security/firewall" }, + ], + }, + { + label: "Observability", + items: [ + { label: "Logs", link: "/vercel/observability/logs" }, + { label: "Drains", link: "/vercel/observability/drains" }, + ], + }, + { + label: "Networking", + items: [ + { + label: "Domains & DNS", + link: "/vercel/networking/domains", + }, + { + label: "Custom domains", + link: "/vercel/networking/custom-domains", + }, + ], + }, + providerResourcesEntry("Vercel"), + ], + }, { label: "PlanetScale", items: [ diff --git a/website/src/components/starlight/DocsTabs.astro b/website/src/components/starlight/DocsTabs.astro index 9ea8de7b60..70ae3908eb 100644 --- a/website/src/components/starlight/DocsTabs.astro +++ b/website/src/components/starlight/DocsTabs.astro @@ -15,27 +15,36 @@ const categories = [...new Set(more.map((tab) => tab.category))];