import * as Alchemy from "alchemy"; import * as Cloudflare from "alchemy/Cloudflare"; import * as Config from "effect/Config"; import * as Effect from "effect/Effect"; import { Redacted } from "effect"; import { unstable_readConfig } from "wrangler"; import { z } from "zod"; import { HOSTED_PROD_STAGE, readWorkersSubdomain, workerName, } from "./alchemy.access.ts"; // Preview hostnames are `open-seo-.` — the naming // lives in alchemy.access.ts, shared with the Access wildcard the security // boundary depends on. The shell copy in .github/workflows/pr-preview.yml // must be kept in sync by hand. // Alchemy v2 stack for SaaS deployments — previews and prod. Stage semantics, // security model, and credentials are documented once in // docs/PREVIEW_DEPLOYMENTS.md. // // - Any stage except "hosted-prod": fresh stage-suffixed resources. Previews // deploy via `pnpm deploy:preview --stage `. // - Stage "hosted-prod": names the EXISTING openseo.so production resources // so `--adopt` imports them. Deploy via `pnpm deploy:postgres` (--adopt and // the stage baked in). // // Self-hosting still deploys through wrangler (wrangler.jsonc); an // alchemy-based self-host path is a planned fast-follow. Local dev and Docker // self-host do NOT use this stack (wrangler.jsonc + @cloudflare/vite-plugin). // This stack deploys the PREBUILT `vite build` output — Alchemy never runs Vite. // The worker's runtime contract — compatibility date/flags, crons, // observability, placement, DO/workflow classes — has one source of truth: // wrangler.jsonc (what local dev and Docker self-host already run). Only // stage-dependent values (names, domains, env) live in this file. // unstable_readConfig ships types too loose to lint; validate what we consume. const wrangler = z .object({ compatibility_date: z.string(), compatibility_flags: z.array(z.string()), triggers: z.object({ crons: z.array(z.string()) }), observability: z .object({ enabled: z.boolean().optional(), traces: z.object({ enabled: z.boolean().optional() }).optional(), }) .optional(), placement: z.object({ mode: z.enum(["off", "smart"]) }).optional(), durable_objects: z.object({ bindings: z.array(z.object({ name: z.string(), class_name: z.string() })), }), workflows: z.array( z.object({ binding: z.string(), name: z.string(), class_name: z.string(), }), ), }) .parse(unstable_readConfig({ config: "wrangler.jsonc" })); // Physical names of the wrangler-era production resources (see git history of // wrangler.jsonc). Adoption matches on these exact names/titles. const PROD_NAMES = { d1: "open-seo", r2: "open-seo", kv: "every-super-seo", oauthKv: "OAUTH_KV", hyperdrive: "openseo", } as const; const makeResources = (stage: string) => { const prod = stage === HOSTED_PROD_STAGE; // Prod adopts the LIVE resources; retain makes `alchemy destroy --stage // hosted-prod` (or an orphaning refactor) forget state instead of deleting // them. const keep = Alchemy.RemovalPolicy.retain(prod); return { DB: Cloudflare.D1.Database("DB", { name: prod ? PROD_NAMES.d1 : `open-seo-db-${stage}`, // drizzle-generated SQL migrations; tracked in the same // wrangler-compatible table prod already uses. migrationsDir: "drizzle", migrationsTable: "d1_migrations", }).pipe(keep), R2: Cloudflare.R2.Bucket("R2", { name: prod ? PROD_NAMES.r2 : `open-seo-r2-${stage}`, }).pipe(keep), KV: Cloudflare.KV.Namespace("KV", { title: prod ? PROD_NAMES.kv : `open-seo-kv-${stage}`, }).pipe(keep), OAUTH_KV: Cloudflare.KV.Namespace("OAUTH_KV", { title: prod ? PROD_NAMES.oauthKv : `open-seo-oauth-kv-${stage}`, }).pipe(keep), }; }; /** * Prod-only: the existing Hyperdrive config pooling connections to the * production Postgres. Origin credentials come from the env file — Cloudflare * never returns them, so alchemy must know them to manage the config. */ const makeHyperdrive = () => Cloudflare.Hyperdrive.Connection("HYPERDRIVE", { name: PROD_NAMES.hyperdrive, origin: Config.all([ Config.string("HYPERDRIVE_ORIGIN_HOST"), Config.string("HYPERDRIVE_ORIGIN_PORT").pipe(Config.withDefault("5432")), Config.string("HYPERDRIVE_ORIGIN_DATABASE"), Config.string("HYPERDRIVE_ORIGIN_USER"), Config.redacted("HYPERDRIVE_ORIGIN_PASSWORD"), ]).pipe( Config.map(([host, port, database, user, password]) => ({ scheme: "postgres" as const, host, port: Number(port), database, user, password, })), ), // Prod runs with Hyperdrive caching OFF (no write invalidation for a SaaS // with per-user reads-after-writes). caching: { disabled: true }, }).pipe(Alchemy.RemovalPolicy.retain()); const optionalVar = (name: string) => Config.string(name).pipe( Config.withDefault(""), Config.map((value) => value.trim()), ); const optionalSecret = (name: string) => Config.redacted(name).pipe(Config.withDefault(Redacted.make(""))); // Secrets/vars resolve from the env file passed to `alchemy deploy` // (`Config.redacted` → Cloudflare `secret_text`, `Config.string` → plaintext // var). NOTE: the alchemy CLI loads `--env-file` into the Config environment, // NOT into process.env — a process.env read here silently yields "". const dataEnv = { // AUTH_MODE, DATABASE_PROVIDER, BETTER_AUTH_URL, TEAM_DOMAIN, and // POLICY_AUD are stage-dependent and set in the stack body below. DATAFORSEO_API_KEY: Config.redacted("DATAFORSEO_API_KEY"), BYPASS_EMAIL_VERIFICATION: optionalVar("BYPASS_EMAIL_VERIFICATION"), BETTER_AUTH_SECRET: optionalSecret("BETTER_AUTH_SECRET"), GOOGLE_CLIENT_ID: optionalVar("GOOGLE_CLIENT_ID"), GOOGLE_CLIENT_SECRET: optionalSecret("GOOGLE_CLIENT_SECRET"), OPENROUTER_API_KEY: optionalSecret("OPENROUTER_API_KEY"), OPENROUTER_MODEL: optionalVar("OPENROUTER_MODEL"), AUTUMN_SECRET_KEY: optionalSecret("AUTUMN_SECRET_KEY"), AUTUMN_WEBHOOK_SECRET: optionalSecret("AUTUMN_WEBHOOK_SECRET"), LOOPS_API_KEY: optionalSecret("LOOPS_API_KEY"), LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID: optionalVar( "LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID", ), LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID: optionalVar( "LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID", ), POSTHOG_PUBLIC_KEY: optionalVar("POSTHOG_PUBLIC_KEY"), POSTHOG_HOST: optionalVar("POSTHOG_HOST"), REDDIT_PIXEL_ID: optionalSecret("REDDIT_PIXEL_ID"), REDDIT_CONVERSIONS_ACCESS_TOKEN: optionalSecret( "REDDIT_CONVERSIONS_ACCESS_TOKEN", ), TURNSTILE_SECRET_KEY: optionalSecret("TURNSTILE_SECRET_KEY"), TURNSTILE_SITE_KEY: optionalVar("TURNSTILE_SITE_KEY"), }; export default Alchemy.Stack( "open-seo", { providers: Cloudflare.providers(), // Durable state in the Cloudflare state store (an `alchemy-state-store` // Worker on this account; one-time `pnpm alchemy cloudflare bootstrap`). // CI fetches its auth token from the account Secrets Store each run. state: Cloudflare.state(), }, Effect.gen(function* () { const stage = yield* Alchemy.Stage; const prod = stage === HOSTED_PROD_STAGE; // Fail closed: an unset AUTH_MODE gets the Access-gated mode (matching the // app's own default in src/lib/auth-mode.ts), never public hosted signup. // hosted/local_noauth must be set explicitly. const authMode = yield* Config.string("AUTH_MODE").pipe( Config.withDefault("cloudflare_access"), ); const databaseProvider = yield* optionalVar("DATABASE_PROVIDER"); const workersSubdomain = yield* readWorkersSubdomain({ required: false }); // Auth needs an absolute BETTER_AUTH_URL. Prod sets it explicitly; // previews always derive it from the deterministic worker name — a wrong // WORKERS_SUBDOMAIN surfaces in CI's post-deploy Access verify step. let authUrl: string; if (prod) { authUrl = yield* optionalVar("BETTER_AUTH_URL"); if (!authUrl) { return yield* Effect.die( new Error( "Set BETTER_AUTH_URL (https://app.openseo.so) in .env.production.", ), ); } // Prod must say which database it runs on. A silently-defaulted "d1" // would deploy cleanly against the stale pre-Postgres data. if (databaseProvider !== "postgres" && databaseProvider !== "d1") { return yield* Effect.die( new Error( "Set DATABASE_PROVIDER explicitly in .env.production (prod runs postgres).", ), ); } } else if (workersSubdomain) { authUrl = `https://${workerName(stage)}.${workersSubdomain}`; } else if (authMode === "hosted") { return yield* Effect.die( new Error( "Hosted previews derive BETTER_AUTH_URL from WORKERS_SUBDOMAIN — set it to the account's full workers.dev subdomain (shown under Workers & Pages).", ), ); } else { // local_noauth / cloudflare_access never read BETTER_AUTH_URL — // src/lib/auth.ts uses a placeholder baseURL off the hosted path. authUrl = ""; } // cloudflare_access self-host reads these; hosted/local_noauth leave them // empty. (Deriving/provisioning the Access application is a follow-up PR.) const teamDomain = yield* optionalVar("TEAM_DOMAIN"); const policyAud = yield* optionalVar("POLICY_AUD"); const app = yield* Cloudflare.Worker("open-seo", { name: workerName(stage), // Prod serves the real domains; the zone is inferred from the hostname. domain: prod ? ["app.openseo.so", "www.app.openseo.so"] : undefined, // Prebuilt worker from `vite build` (@cloudflare/vite-plugin). The entry // exports the DO + WorkflowEntrypoint classes (re-exported by // src/server.ts), which `bundle: false` requires. Sibling chunks under // assets/ are uploaded as-is by the default module rules. main: "./dist/server/index.js", bundle: false, assets: { directory: "./dist/client", }, compatibility: { date: wrangler.compatibility_date, flags: wrangler.compatibility_flags, }, observability: { enabled: wrangler.observability?.enabled ?? true, traces: { enabled: wrangler.observability?.traces?.enabled ?? false }, }, placement: wrangler.placement?.mode === "smart" ? { mode: "smart" } : undefined, // Scheduled rank checks — src/server.ts `scheduled` handler. crons: wrangler.triggers.crons, env: { ...makeResources(stage), ...dataEnv, AUTH_MODE: authMode, DATABASE_PROVIDER: databaseProvider || "d1", BETTER_AUTH_URL: authUrl, TEAM_DOMAIN: teamDomain, POLICY_AUD: policyAud, // Prod-only: pooled Postgres via the existing Hyperdrive config. ...(prod ? { HYPERDRIVE: makeHyperdrive() } : {}), // Durable Objects (Agents SDK chat agents). Alchemy backs new DO // classes with SQLite storage, which both require. ...Object.fromEntries( wrangler.durable_objects.bindings.map((binding) => [ binding.name, Cloudflare.DurableObject(binding.name, { className: binding.class_name, }), ]), ), // Cloudflare Workflows (upstream props-only form for prebuilt // workers). Workflow names are ACCOUNT-scoped: prod owns the // unsuffixed names; previews carry the stage suffix so concurrent // stages can't repoint each other's workflows (registration is a // PUT-as-upsert on the name). ...Object.fromEntries( wrangler.workflows.map((workflow) => [ workflow.binding, Cloudflare.Workflow( prod ? workflow.name : `${workflow.name}-${stage}`, { className: workflow.class_name }, ), ]), ), }, }).pipe( // Prod adopts the live worker serving app.openseo.so; never delete it // on destroy. (Workflow registrations aren't individually retainable — // they're created inside the worker provider — but re-registering them // is a lossless upsert, unlike deleting the data-bearing resources.) Alchemy.RemovalPolicy.retain(prod), ); return { url: app.url.as() }; }), );