import * as Alchemy from "alchemy"; import * as Cloudflare from "alchemy/Cloudflare"; import * as CfWorkers from "@distilled.cloud/cloudflare/workers"; import * as ZeroTrust from "@distilled.cloud/cloudflare/zero-trust"; import * as Config from "effect/Config"; import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import { Redacted } from "effect"; import { unstable_readConfig } from "wrangler"; import { z } from "zod"; import { emailAccessGate, HOSTED_PROD_STAGE, readWorkersSubdomain, requireAllowedEmails, 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, prod, and Cloudflare // self-hosting. 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 `; self-hosters via // `pnpm deploy:selfhost` (stage "selfhost", no flag to pass). // - 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). // // 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}`, // Expire cached DataForSEO responses. Prod's lifecycle rules are // dashboard-managed; its props stay omitted so alchemy leaves them be. ...(prod ? {} : { lifecycleRules: [ { id: "dataforseo-cache-expiry", prefix: "dataforseo-cache/", deleteObjectsTransition: { condition: { type: "Age", maxAge: 7 * 24 * 60 * 60 }, }, }, ], }), }).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(""))); const accessScopeHint = " (if this is a permissions error, re-run `pnpm alchemy login --configure`, answer yes to “Customize OAuth scopes?”, and select access:write alongside the defaults)"; /** * Self-host auth (AUTH_MODE=cloudflare_access): derive the Access values * instead of making the user copy them out of the dashboard. TEAM_DOMAIN is * the account's Zero Trust team domain (one API read; the team is created — * named after the workers.dev subdomain — if the account has none); * POLICY_AUD is the audience tag of an alchemy-provisioned Access * application whose allow-policy comes from ACCESS_ALLOWED_EMAILS. Explicit * env values always win, so a hand-managed Access application keeps * working — set both TEAM_DOMAIN and POLICY_AUD and nothing here provisions. */ const resolveSelfHostAccess = ( stage: string, provision: boolean, workersSubdomain: string, ) => Effect.gen(function* () { let teamDomain = yield* optionalVar("TEAM_DOMAIN"); let policyAud: Alchemy.Input = yield* optionalVar("POLICY_AUD"); if (!provision || (teamDomain && policyAud)) { return { teamDomain, policyAud }; } const { accountId } = yield* yield* Cloudflare.CloudflareEnvironment; // The workers.dev subdomain names both the Access application's hostname // (which must exist before the Worker resource does) and an auto-created // Zero Trust team; it is deterministic from the account. let subdomain = workersSubdomain; if (!subdomain) { const observed = yield* CfWorkers.getSubdomain({ accountId }).pipe( Effect.catch((error) => Effect.die( new Error( `Could not read the workers.dev subdomain: ${String(error)}${accessScopeHint}`, ), ), ), ); subdomain = `${observed.subdomain}.workers.dev`; } if (!teamDomain) { const organization = yield* ZeroTrust.listOrganizationsForAccount({ accountId, }).pipe( Effect.catchTag("OrganizationNotFound", () => Effect.succeed(null)), Effect.catch((error) => Effect.die( new Error( `Could not read the Zero Trust organization: ${String(error)}${accessScopeHint}`, ), ), ), ); if (organization?.authDomain) { teamDomain = `https://${organization.authDomain}`; } else { // Fresh account with no Zero Trust team: create one, named after the // workers.dev subdomain — both are globally unique account handles. const teamName = subdomain.replace(/\.workers\.dev$/, ""); yield* ZeroTrust.createOrganizationForAccount({ accountId, name: teamName, authDomain: `${teamName}.cloudflareaccess.com`, }).pipe( Effect.catch((error) => Effect.die( new Error( `Could not create the Zero Trust team "${teamName}": ${String(error)}${accessScopeHint}. You can also create one by hand — open https://one.dash.cloudflare.com once to pick a team name (free plan is fine), then redeploy.`, ), ), ), ); yield* Console.log( `Created the Zero Trust team "${teamName}" (${teamName}.cloudflareaccess.com) — its login page is where Cloudflare Access sends users to sign in.`, ); teamDomain = `https://${teamName}.cloudflareaccess.com`; } } if (!policyAud) { const allowedEmails = yield* requireAllowedEmails( "Set ACCESS_ALLOWED_EMAILS to the comma-separated emails allowed through Cloudflare Access — or set TEAM_DOMAIN and POLICY_AUD to manage the Access application yourself.", ); const application = yield* emailAccessGate({ policyId: "SelfHostAllowUsers", applicationId: "SelfHostAccess", policyName: `open-seo ${stage} self-host users`, applicationName: `open-seo ${stage}`, domain: `${workerName(stage)}.${subdomain}`, emails: allowedEmails, }); policyAud = application.aud; } return { teamDomain, policyAud }; }); // 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"), DUB_API_KEY: optionalSecret("DUB_API_KEY"), GDPR_ERASURE_SECRET: optionalSecret("GDPR_ERASURE_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"), TURNSTILE_SECRET_KEY: optionalSecret("TURNSTILE_SECRET_KEY"), TURNSTILE_SITE_KEY: optionalVar("TURNSTILE_SITE_KEY"), // Alchemy reconciles worker vars on every deploy, so the telemetry opt-out // must live in the env file — a dashboard-set var would be wiped. OPENSEO_TELEMETRY_DISABLED: optionalVar("OPENSEO_TELEMETRY_DISABLED"), }; 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 = ""; } const access = yield* resolveSelfHostAccess( stage, authMode === "cloudflare_access" && !prod, workersSubdomain, ); 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, }, // Site audits parse and persist batches of HTML inside Workflow steps. // Paid Workers permit up to five minutes; keep headroom for unusually // link-heavy sites after bounding page bodies and bulk-writing links. // Configurable CPU limits are a paid-plan feature, and self-host // deploys (cloudflare_access) may run on the free plan — which rejects // them — so those get the plan default instead. ...(authMode === "cloudflare_access" ? {} : { limits: { cpuMs: 300_000 } }), 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: access.teamDomain, POLICY_AUD: access.policyAud, // Prod-only: pooled Postgres via the existing Hyperdrive config. ...(prod ? { HYPERDRIVE: makeHyperdrive() } : {}), // Durable Objects (chat agents + the per-audit crawl scratchpad). // Alchemy backs new DO classes with SQLite storage, which all of // them require; the `migrations` array in wrangler.jsonc only // applies to the wrangler/workerd surfaces (local dev, Docker). ...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() }; }), );