Alchemy v2 for SaaS deployments (preview envs); account-agnostic wrangler.jsonc (#324)

This commit is contained in:
Ben Senescu 2026-07-14 18:23:43 -04:00 committed by Ben Senescu
parent d6d0f210ba
commit bd22268ad2
18 changed files with 2759 additions and 140 deletions

View File

@ -11,3 +11,4 @@ Dockerfile
.env
.env.*
!.env.example
.alchemy/

33
.env.preview.example Normal file
View File

@ -0,0 +1,33 @@
# Alchemy preview deploys — see docs/PREVIEW_DEPLOYMENTS.md.
# Cloudflare credentials come from `pnpm alchemy login`, not this file.
#
# cp .env.preview.example .env.preview
# pnpm preview:access # once — the shared Access gate
# pnpm deploy:preview --stage <name> --yes
# Required.
# DATAFORSEO_API_KEY=
# Previews skip in-app auth — the Access gate is the only auth boundary, and
# everyone allowed through it shares one auto-created admin account.
AUTH_MODE=local_noauth
# The account's workers.dev subdomain (shown under Workers & Pages). Used by
# `pnpm preview:access` and CI's verify step.
# WORKERS_SUBDOMAIN=your-subdomain.workers.dev
# Comma-separated emails allowed through the Access gate.
# ACCESS_ALLOWED_EMAILS=you@yourdomain.com
# Optional.
# OPENROUTER_API_KEY=
# OPENROUTER_MODEL=
# POSTHOG_PUBLIC_KEY=
# POSTHOG_HOST=
# To exercise the real sign-up flow instead, replace AUTH_MODE with:
# AUTH_MODE=hosted
# BETTER_AUTH_SECRET= # any random 32+ char string
# BYPASS_EMAIL_VERIFICATION=true
# GOOGLE_CLIENT_ID=
# GOOGLE_CLIENT_SECRET=

40
.env.production.example Normal file
View File

@ -0,0 +1,40 @@
# Alchemy PROD deploy (stage "prod") — adoption semantics and the
# first-cutover checklist are in docs/PREVIEW_DEPLOYMENTS.md.
# Cloudflare credentials come from `pnpm alchemy login`, not this file.
#
# cp .env.production.example .env.production
# pnpm deploy:postgres
AUTH_MODE=hosted
BETTER_AUTH_URL=https://app.openseo.so
# BETTER_AUTH_SECRET=
# DATAFORSEO_API_KEY=
# Postgres through the existing Hyperdrive config. Cloudflare never returns
# origin credentials, so alchemy needs them here (PlanetScale connection
# settings).
DATABASE_PROVIDER=postgres
# HYPERDRIVE_ORIGIN_HOST=us-east-3.pg.psdb.cloud
# HYPERDRIVE_ORIGIN_PORT=5432
# HYPERDRIVE_ORIGIN_DATABASE=postgres
# HYPERDRIVE_ORIGIN_USER=
# HYPERDRIVE_ORIGIN_PASSWORD=
# Hosted auth.
# GOOGLE_CLIENT_ID=
# GOOGLE_CLIENT_SECRET=
# LOOPS_API_KEY=
# LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID=
# LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID=
# TURNSTILE_SECRET_KEY=
# TURNSTILE_SITE_KEY=
# Billing, analytics, agents.
# AUTUMN_SECRET_KEY=
# AUTUMN_WEBHOOK_SECRET=
# POSTHOG_PUBLIC_KEY=
# POSTHOG_HOST=
# REDDIT_PIXEL_ID=
# REDDIT_CONVERSIONS_ACCESS_TOKEN=
# OPENROUTER_API_KEY=
# OPENROUTER_MODEL=

145
.github/workflows/pr-preview.yml vendored Normal file
View File

@ -0,0 +1,145 @@
name: PR Preview
# Alchemy preview stage per private-repo PR: deploy on open/update, destroy on
# close (stage pr-<n>). Full model in docs/PREVIEW_DEPLOYMENTS.md.
#
# Public-mirror (every-app/open-seo) PRs NEVER deploy from CI — fork code must
# not run with deploy secrets. Preview those locally from a worktree instead
# (docs/PREVIEW_DEPLOYMENTS.md, "Public-mirror PRs"). The repository gate
# below keeps the synced copy of this file inert on the mirror.
#
# Required repo secrets: CLOUDFLARE_API_TOKEN (Workers Scripts/KV/D1/R2/
# Workflows write + Secrets Store read + Account Settings read — the last two
# are how alchemy fetches its state-store token via an edge-preview worker),
# CLOUDFLARE_ACCOUNT_ID, ENV_PREVIEW (.env.preview contents).
on:
pull_request:
types: [opened, synchronize, reopened, closed]
# Skip PRs that can't change the deployed worker. Filters see the PR's
# full file list, so deploy and close-time destroy stay consistent.
paths-ignore:
- "**/*.md"
- docs/**
- runbooks/**
- .agents/**
- web/**
- badseo/**
# A newer push supersedes any in-flight deploy for the same PR (alchemy's
# per-resource state reconciles cleanly on the next run); a close-triggered
# destroy never cancels — it queues behind whatever is running.
concurrency:
group: pr-preview-${{ github.event.pull_request.number }}
cancel-in-progress: ${{ github.event.action != 'closed' }}
permissions:
contents: read
pull-requests: write
env:
STAGE: pr-${{ github.event.pull_request.number }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
jobs:
preview:
if: >-
github.repository == 'bensenescu/open-seo' &&
github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Write .env.preview
run: printf '%s' "$ENV_PREVIEW" > .env.preview
env:
ENV_PREVIEW: ${{ secrets.ENV_PREVIEW }}
# Derive the preview URL once, before spending a deploy on a broken
# secret. Destroy runs don't need it.
- name: Derive preview URL
if: github.event.action != 'closed'
run: |
subdomain=$(grep '^WORKERS_SUBDOMAIN=' .env.preview | cut -d= -f2-)
case "$subdomain" in
*.workers.dev) ;;
*) echo "::error::ENV_PREVIEW secret is missing a valid WORKERS_SUBDOMAIN (needed to derive and verify the preview URL)"; exit 1 ;;
esac
echo "PREVIEW_URL=https://open-seo-${STAGE}.${subdomain}" >> "$GITHUB_ENV"
# Same command as a local preview deploy: vite build, alchemy deploy.
# State lives in the account's Cloudflare state store; CI resolves its
# auth token from the Secrets Store each run. The Access gate protecting
# previews is one-time local setup (pnpm preview:access) — the verify
# step below fails the job if it's ever missing.
- name: Deploy preview stage
if: github.event.action != 'closed'
run: pnpm deploy:preview --stage "$STAGE" --yes
env:
NODE_OPTIONS: --max-old-space-size=4096
# Fail the job (and skip the URL comment) if the deployed preview
# answers without a Cloudflare Access login redirect. A definitive app
# response (2xx/3xx, no Access redirect) fails immediately — the
# preview is public; retries are only for propagation-era errors.
# (Cloudflare version preview URLs sit outside this wildcard, but alchemy
# uploads versions with no preview provisioned, so none are served — see
# docs/PREVIEW_DEPLOYMENTS.md.)
- name: Verify Access protection
if: github.event.action != 'closed'
run: |
for attempt in 1 2 3 4 5 6 7 8; do
response=$(curl -sS -o /dev/null -m 10 -w '%{http_code} %{redirect_url}' "$PREVIEW_URL") || response=""
code="${response%% *}"
location="${response#* }"
case "$location" in
https://*.cloudflareaccess.com/cdn-cgi/access/login*)
exit 0 ;;
esac
if [ -n "$code" ] && [ "$code" -ge 200 ] && [ "$code" -lt 500 ]; then
echo "::error::$PREVIEW_URL responded (HTTP $code) WITHOUT a Cloudflare Access challenge — the preview is public; destroy the stage (pnpm destroy:preview --stage $STAGE --yes)"
exit 1
fi
echo "attempt $attempt: not up yet (${response:-no response})"
sleep 5
done
echo "::error::$PREVIEW_URL is unreachable (likely workers.dev propagation) — it still sits behind the wildcard Access app; re-run this job or verify manually"
exit 1
- name: Destroy preview stage
if: github.event.action == 'closed'
run: pnpm destroy:preview --stage "$STAGE" --yes
# PREVIEW_URL comes from the derive step's $GITHUB_ENV write; this step
# only runs after verify succeeds (no `if:`, so default success() gating).
- name: Comment on PR
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.number }}
run: |
if [ "${{ github.event.action }}" = "closed" ]; then
body=$(printf '**Preview destroyed** (stage `%s`).' "$STAGE")
else
body=$(printf '**Preview deployed** (stage `%s`): %s\n_Updated for %s. Torn down automatically when this PR closes._' \
"$STAGE" "$PREVIEW_URL" "${{ github.event.pull_request.head.sha }}")
fi
gh pr comment "$PR" --repo "${{ github.repository }}" \
--body "$body" --edit-last --create-if-none

5
.gitignore vendored
View File

@ -16,6 +16,8 @@ dist-sourcemaps/
.env.
.env.*
!.env.example
!.env.preview.example
!.env.production.example
.vercel
.output
.nitro
@ -38,3 +40,6 @@ dist-sourcemaps/
# Local Claude config (skills are shared)
.claude/*
!.claude/skills/
# Alchemy local state + bundle artifacts (SaaS deploys)
.alchemy/

View File

@ -1,6 +1,8 @@
**/build
**/public
pnpm-lock.yaml
# no parser for dotenv files; explicit `prettier <file>` aborts without this
.env*
routeTree.gen.ts
.opencode/package.json

81
alchemy.access.ts Normal file
View File

@ -0,0 +1,81 @@
// The contract shared by the two email-gated Cloudflare Access boundaries —
// the persistent preview wildcard (alchemy.preview-access.run.ts) and the
// per-stage self-host gate (alchemy.run.ts). Worker naming, the
// WORKERS_SUBDOMAIN shape, the allowed-emails parsing, and the
// policy/application shape define who gets through which hostnames; keep them
// in one place so the two gates cannot drift. The one copy that can't import
// this module is the shell in .github/workflows/pr-preview.yml — its
// `open-seo-<stage>` naming stays comment-synced (and is backstopped by the
// workflow's Access verify step).
import * as Cloudflare from "alchemy/Cloudflare";
import * as Config from "effect/Config";
import * as Effect from "effect/Effect";
const WORKER_PREFIX = "open-seo";
// The one stage that adopts openseo.so's live hosted resources (unsuffixed
// names, app.openseo.so domain, Postgres). Deliberately not "prod" so a
// self-hoster's stage name can't collide with the adoption path.
export const HOSTED_PROD_STAGE = "hosted-prod";
export const workerName = (stage: string) =>
stage === HOSTED_PROD_STAGE ? WORKER_PREFIX : `${WORKER_PREFIX}-${stage}`;
// Matches every preview worker hostname; production's unsuffixed worker does
// not match (Access allows one wildcard per dot-label).
export const previewWildcard = (subdomain: string) =>
`${WORKER_PREFIX}-*.${subdomain}`;
export const readWorkersSubdomain = ({ required }: { required: boolean }) =>
Effect.gen(function* () {
const subdomain = (yield* Config.string("WORKERS_SUBDOMAIN").pipe(
Config.withDefault(""),
)).trim();
if (subdomain.endsWith(".workers.dev") || (!subdomain && !required)) {
return subdomain;
}
return yield* Effect.die(
new Error(
`Set WORKERS_SUBDOMAIN to the account's full workers.dev subdomain (shown under Workers & Pages)${required ? "." : ", or leave it unset."}`,
),
);
});
/** Reads ACCESS_ALLOWED_EMAILS; dies with `remedy` when none are set. */
export const requireAllowedEmails = (remedy: string) =>
Effect.gen(function* () {
const emails = (yield* Config.string("ACCESS_ALLOWED_EMAILS").pipe(
Config.withDefault(""),
))
.split(",")
.map((email) => email.trim())
.filter(Boolean);
if (emails.length === 0) {
return yield* Effect.die(new Error(remedy));
}
return emails;
});
/** The gate itself: an email allow-policy on a self-hosted Access application. */
export const emailAccessGate = (options: {
policyId: string;
applicationId: string;
policyName: string;
applicationName: string;
domain: string;
emails: string[];
}) =>
Effect.gen(function* () {
const allow = yield* Cloudflare.Access.Policy(options.policyId, {
name: options.policyName,
decision: "allow",
include: options.emails.map((email) => ({ email: { email } })),
});
return yield* Cloudflare.Access.Application(options.applicationId, {
type: "self_hosted",
name: options.applicationName,
domain: options.domain,
policies: [allow.policyId],
});
});

View File

@ -0,0 +1,49 @@
import * as Alchemy from "alchemy";
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import {
emailAccessGate,
previewWildcard,
readWorkersSubdomain,
requireAllowedEmails,
} from "./alchemy.access.ts";
// Persistent account-level security boundary for every ephemeral preview.
// This stack is intentionally separate from alchemy.run.ts: destroying one
// preview must never remove the wildcard Access application protecting all of
// the others. The wildcard hostname derives from the same worker naming the
// deploy stack uses (alchemy.access.ts).
//
// This gates the stable stage hostname (`open-seo-<stage>.<sub>`). Cloudflare
// version preview URLs (`<version>-open-seo-<stage>.<sub>`) sit outside this
// wildcard, but alchemy uploads each version with no preview provisioned
// (`has_preview: false`), so none are served — see docs/PREVIEW_DEPLOYMENTS.md.
export default Alchemy.Stack(
"open-seo-preview-access",
{
providers: Cloudflare.providers(),
state: Cloudflare.state(),
},
Effect.gen(function* () {
const subdomain = yield* readWorkersSubdomain({ required: true });
const allowedEmails = yield* requireAllowedEmails(
"Set ACCESS_ALLOWED_EMAILS to the comma-separated preview testers.",
);
const hostname = previewWildcard(subdomain);
const application = yield* emailAccessGate({
policyId: "PreviewAllowTeam",
applicationId: "PreviewAccess",
policyName: "open-seo preview team",
applicationName: "open-seo preview environments",
domain: hostname,
emails: allowedEmails,
});
return {
hostname,
applicationId: application.applicationId,
aud: application.aud,
};
}),
);

306
alchemy.run.ts Normal file
View File

@ -0,0 +1,306 @@
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-<stage>.<WORKERS_SUBDOMAIN>` — 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 <name>`.
// - 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<string>() };
}),
);

203
docs/PREVIEW_DEPLOYMENTS.md Normal file
View File

@ -0,0 +1,203 @@
# Alchemy preview deployments
OpenSEO preview stages use isolated Cloudflare resources and a shared,
Alchemy-managed Cloudflare Access boundary.
## Security model
- Preview Workers are named `open-seo-<stage>` and served from
`open-seo-<stage>.<WORKERS_SUBDOMAIN>`.
- One persistent Access application protects
`open-seo-*.<WORKERS_SUBDOMAIN>` before any preview Worker exists.
- Production uses the unsuffixed `open-seo` Worker on `app.openseo.so` and
`www.app.openseo.so`. It does not match the preview wildcard and is not
placed behind preview Access.
- A separate persistent Alchemy stack manages the shared Access boundary. A
failed preview deploy or teardown therefore cannot remove the gate protecting
other previews.
- `pnpm preview:access` deploys/reconciles the persistent Access stack —
one-time setup, safe to re-run. Every CI deploy then verifies the real HTTP
challenge (a curl check in `pr-preview.yml`, with retries for propagation
delays) before commenting a URL, so a missing gate fails the job. A preview
that answers without the challenge is public — destroy the stage
(`pnpm destroy:preview`); a merely unreachable one can stay, as it still
sits behind the wildcard application.
## Credentials
Alchemy manages Cloudflare credentials itself — nothing credential-shaped goes
in the env files.
- **Locally**, run `pnpm alchemy login` once. Answer yes to
**Customize OAuth scopes?** and enable `access:write` on top of the defaults
(the preview Access gate needs it; add `query_cache:write` too if you will
deploy production — Hyperdrive). The credential is stored globally, and
later runs — including non-interactive ones — reuse it silently.
- **State** lives in the account's Cloudflare state store (an
`alchemy-state-store` Worker with embedded SQLite), shared by every machine
and CI — provision it once with `pnpm alchemy cloudflare bootstrap`. It
mints an auth token and encryption key into the account Secrets Store;
local runs cache credentials under `~/.alchemy/`.
- **In GitHub Actions**, the runner's `CI` env makes alchemy read
`CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` from the environment
(repo secrets), and resolve the state-store token from the Secrets Store on
every run. The token needs write access to Workers Scripts, KV, D1, R2, and
Workflows, plus **Secrets Store read** and **Account Settings read** (both
used by alchemy's state-store login, which fetches its token through a
temporary edge-preview worker). CI never touches Access — the gate is
one-time local setup.
## Cloudflare Access configuration
The persistent Access stack creates a self-hosted application with this public
hostname:
```text
open-seo-*.your-subdomain.workers.dev
```
Use the account's Workers subdomain shown under **Workers & Pages**, not the
Zero Trust team domain. Set that full value in `.env.preview` as
`WORKERS_SUBDOMAIN`. Preview URLs derive from it as
`https://open-seo-<stage>.<WORKERS_SUBDOMAIN>` — hosted previews use that as
`BETTER_AUTH_URL`, and CI's verify step probes it; a wrong value fails the
check. Previews in `local_noauth` or `cloudflare_access` mode deploy without
it, since nothing reads `BETTER_AUTH_URL` there.
Set `ACCESS_ALLOWED_EMAILS` to the exact comma-separated emails that may open
previews:
```env
ACCESS_ALLOWED_EMAILS=you@example.com
```
`pnpm preview:access` deploys the persistent Access stack. It always uses
`--adopt`, so it can recover matching infrastructure if local Alchemy state is
lost — safe to re-run any time. Normal preview teardown only destroys the
requested application stage and cannot touch the Access stack.
## Local preview
```sh
pnpm alchemy login # once — see Credentials above
cp .env.preview.example .env.preview
pnpm preview:access # once — the shared Access gate (safe to re-run)
pnpm deploy:preview --stage manual-preview --yes
```
`deploy:preview` builds with Vite's preview mode (`--mode preview` loads
`.env.preview` into the client bundle) and runs `alchemy deploy` against
`.env.preview`; extra flags land on the deploy. CI runs this same command.
Two alchemy conventions to know: omitting `--stage` targets alchemy's default
per-user stage (`dev_$USER`), and stage `hosted-prod` with `.env.preview` fails
in the stack (no `BETTER_AUTH_URL`) — use `pnpm deploy:postgres` for production.
Each preview starts with an empty database: open the URL, pass the Access
challenge, and you're in. Previews default to `AUTH_MODE=local_noauth`, so
everyone allowed through the gate shares one auto-created admin account — set
`AUTH_MODE=hosted` in `.env.preview` (see the example file) to exercise the
real sign-up flow instead.
Destroy the stage (state is shared via the Cloudflare state store, so any
machine with credentials can do this):
```sh
pnpm destroy:preview --stage manual-preview --yes
```
## CI
`.github/workflows/pr-preview.yml` deploys stage `pr-<n>` for every
private-repo PR (then verifies the Access challenge before commenting the
URL) and destroys it when the PR closes, running the same commands as local
deploys. State is shared through the Cloudflare state store, so CI runs and
local machines see the same stages — a straggler can always be destroyed
locally with `pnpm destroy:preview --stage pr-<n> --yes`.
## Public-mirror PRs
External (every-app/open-seo) PRs never deploy from CI — fork code must not
run with deploy secrets. Preview one locally instead: the fork's code only
BUILDS, in a detached sibling worktree, and the deploy runs from this trusted
checkout's alchemy stack against the fork's `dist/`. The fork's own deploy
scripts never execute (fork PRs may also predate the alchemy setup entirely).
Read the PR's diff for the build-executable surface first — `package.json`,
the lockfile, `vite.config*`, `scripts/`, `patches/`, `.npmrc` — because
building executes the fork's config code on your machine with `.env.preview`
available.
```sh
git fetch https://github.com/every-app/open-seo.git pull/<pr>/head
git worktree add --detach ../open-seo-pub-<pr> FETCH_HEAD
cp .env.preview ../open-seo-pub-<pr>/
(cd ../open-seo-pub-<pr> && pnpm install --frozen-lockfile && pnpm exec vite build --mode preview)
rm -rf dist && cp -R ../open-seo-pub-<pr>/dist dist
git worktree remove --force ../open-seo-pub-<pr>
pnpm alchemy deploy --env-file .env.preview --stage pub-<pr> --yes
```
Verify the preview redirects to the Access login before sharing its URL.
Destroy the stage whenever the PR is done:
```sh
pnpm destroy:preview --stage pub-<pr> --yes
```
## Production
Production deploys through the same Alchemy stack, stage `hosted-prod`, which
names the existing production resources so `--adopt` imports them instead of
creating fresh ones. Its domains do not match the preview Access wildcard.
(The stage is `hosted-prod`, not `prod`, so a self-hoster's stage name can
never collide with the adoption path.)
```sh
pnpm deploy:postgres
```
The script runs the Postgres migrations (`db:migrate:pg`), builds, and then
`alchemy deploy --env-file .env.production --stage hosted-prod --adopt`
`--adopt` and the stage are baked in so they cannot be forgotten, and alchemy
shows the plan for approval before applying. It uses the same
`pnpm alchemy login` credential as previews (make sure `query_cache:write`
was enabled for Hyperdrive).
### First-cutover checklist (one time)
The first Alchemy prod deploy adopts live resources. Before running it:
1. Append `--dry-run` to the alchemy command and read the plan — every prod
resource (D1 `open-seo`, KV `every-super-seo`/`OAUTH_KV`, R2 `open-seo`,
Hyperdrive `openseo`, Worker `open-seo`) should be adopted, none created.
2. Diff `.env.production` against the live worker's secrets
(`GET /accounts/:id/workers/scripts/open-seo/secrets`): alchemy's deploy
replaces the COMPLETE binding set, so any live secret missing from the env
file deploys as `""` — most vars are optional-with-empty-default, so the
deploy succeeds while silently disabling that integration.
3. Rehearse adoption on a scratch stage that mirrors prod's shape — deploy
the scratch worker **with wrangler first** (including the `migrations`
block) so it carries a wrangler-era migration tag and live DO namespaces
like prod; a fresh alchemy stage skips the exact adoption path prod will
take.
4. Compare `SELECT name FROM d1_migrations` on the prod D1 against
`ls drizzle/*.sql` — alchemy applies any missing D1 migrations on the
first deploy (wrangler-compatible ledger, verified), and the dormant prod
D1 hasn't been migrated since the Postgres cutover.
5. Confirm the `HYPERDRIVE_ORIGIN_*` values in `.env.production` match the
live Hyperdrive config — Cloudflare never returns origin credentials, so a
mismatch would rewrite the origin.
6. Note: prod already serves on `open-seo.<subdomain>.workers.dev` (alchemy
keeps it enabled; the workers.dev toggle never appears in `--dry-run`).
The prod resources (worker, D1, R2, KV, Hyperdrive) carry alchemy's
`RemovalPolicy.retain`, stamped into state on the first prod deploy: a
destroy of stage `hosted-prod` forgets state but leaves the live resources
untouched. (Workflow registrations are the exception — they're created
inside the worker provider and aren't individually retainable — but
re-registering a workflow is a lossless upsert.)
## Self-hosting on Cloudflare
Self-hosters deploy through wrangler (`wrangler.jsonc`) — see
docs/SELF_HOSTING_CLOUDFLARE.md. An alchemy-based self-host path (fresh
stage-suffixed resources plus a derived Cloudflare Access application) is a
planned fast-follow on top of this stack.

View File

@ -1,9 +1,11 @@
{
"entry": [
// Alchemy deploy stack (run by the alchemy CLI, not imported by the app)
"alchemy.run.ts",
"alchemy.preview-access.run.ts",
// Detect Tanstack Start Routes
"cli-auth.ts",
"src/start.ts",
"src/server.ts",
"src/router.tsx",
"src/routes/**/*.ts",
"src/routes/**/*.tsx",
@ -29,6 +31,8 @@
"**/*.{js,mjs,ts,tsx}",
"!src/routeTree.gen.ts",
"!web/**",
// Alchemy state/bundle artifacts (gitignored)
"!.alchemy/**",
"!badseo/**",
],
"ignore": ["drizzle-prod.config.ts"],

View File

@ -15,6 +15,11 @@
"lint:fix": "oxlint . --type-aware --fix",
"preview": "npm run build && vite preview --port 3001",
"deploy": "npm run db:migrate:prod && npm run build && wrangler deploy",
"deploy:postgres": "npm run db:migrate:pg && npm run build && pnpm alchemy deploy --env-file .env.production --stage hosted-prod --adopt",
"deploy:preview": "vite build --mode preview && pnpm alchemy deploy --env-file .env.preview",
"preview:access": "pnpm alchemy deploy alchemy.preview-access.run.ts --env-file .env.preview --stage preview-access --adopt",
"alchemy": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-strip-types\" alchemy",
"destroy:preview": "pnpm alchemy destroy --env-file .env.preview",
"sourcemaps:upload": "POSTHOG_SOURCEMAPS=true NODE_OPTIONS=--max-old-space-size=8192 npm run build && pnpm dlx @posthog/cli sourcemap inject --directory ./dist-sourcemaps && pnpm dlx @posthog/cli sourcemap upload --directory ./dist-sourcemaps",
"cf-typegen": "wrangler types",
"types:check": "tsc --noEmit",
@ -29,7 +34,6 @@
"db:migrate:local": "wrangler d1 migrations apply DB --local",
"db:migrate:prod": "wrangler d1 migrations apply DB --remote",
"db:migrate:pg": "drizzle-kit migrate --config drizzle-pg.config.ts",
"deploy:postgres": "npm run db:migrate:pg && npm run build && wrangler deploy",
"knip": "knip",
"release:notes": "node scripts/release-notes.mjs",
"release:publish": "node scripts/publish-release.mjs",
@ -111,6 +115,7 @@
"devDependencies": {
"@cloudflare/vite-plugin": "^1.42.3",
"@cloudflare/workers-types": "^4.20260611.1",
"@effect/platform-node": "4.0.0-beta.93",
"@libsql/client": "^0.15.15",
"@playwright/test": "^1.59.1",
"@tailwindcss/vite": "^4.1.11",
@ -121,7 +126,9 @@
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.6.0",
"alchemy": "2.0.0-beta.61",
"drizzle-kit": "^0.31.10",
"effect": "4.0.0-beta.93",
"knip": "^5.88.1",
"oxlint": "^1.50.0",
"oxlint-tsgolint": "^0.15.0",

1965
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,11 @@
minimumReleaseAge: 11520
minimumReleaseAgeExclude:
- "@every-app/*"
# TEMPORARY (remove after 2026-07-16): alchemy 2.0.0-beta.61 and its
# matching @distilled.cloud/*@0.28.2 published 2026-07-08 — 2 days shy of
# the window. Exact-pinned in package.json.
- "alchemy"
- "@distilled.cloud/*"
# Advisories triaged as not applicable. Re-review when the parent updates.
# - GHSA-67mh-4wv8-2f99: esbuild <=0.24.2 dev-server CORS. Only reachable via

View File

@ -9,6 +9,11 @@ copies the data into a freshly-migrated Postgres database using
`scripts/migrate-d1-to-postgres.ts`. For the condensed happy path see
[d1-to-postgres-simple.md](./d1-to-postgres-simple.md).
> **Scope:** this runbook is for the OpenSEO production deployment —
> `pnpm deploy:postgres` is hardwired to alchemy stage `hosted-prod`, its domains, and
> `.env.production`. The alchemy self-host path (non-`prod` stages) has no
> Hyperdrive wiring, so Postgres is not currently available to self-hosters.
The script reads **each table directly from D1 over the Cloudflare REST API** and
writes to Postgres — there is no SQL dump to download or reimport. (An earlier
dump-based approach was dropped after a `wrangler d1 export` download silently

View File

@ -7,6 +7,11 @@ detail — what the script converts, the low-downtime delta sync, cutover and
rollback — see
[d1-to-postgres-detailed.md](./d1-to-postgres-detailed.md).
> **Scope:** this runbook is for the OpenSEO production deployment —
> `pnpm deploy:postgres` is hardwired to alchemy stage `hosted-prod`, its domains, and
> `.env.production`. The alchemy self-host path (non-`prod` stages) has no
> Hyperdrive wiring, so Postgres is not currently available to self-hosters.
> Switching `DATABASE_PROVIDER` to `postgres` changes where the app reads and
> writes — it does **not** move existing data. This copies the data. D1 is never
> written to, so rollback is just flipping the provider back to `d1`.

View File

@ -9,6 +9,7 @@
"moduleResolution": "Bundler",
"lib": ["DOM", "DOM.Iterable", "ES2023"],
"isolatedModules": true,
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"target": "ES2022",

View File

@ -19,13 +19,6 @@
"placement": {
"mode": "smart",
},
// Uncomment when deploying to the openseo.so Cloudflare account. Self-hosters
// should leave this commented (or replace with their own zone) otherwise
// `wrangler deploy` fails with "Could not find zone for app.openseo.so".
// "routes": [
// { "pattern": "app.openseo.so", "custom_domain": true },
// { "pattern": "www.app.openseo.so", "custom_domain": true },
// ],
"workflows": [
{
"name": "site-audit-workflow",
@ -68,6 +61,15 @@
"triggers": {
"crons": ["*/15 * * * *"],
},
// This config serves local dev and Docker self-host only. All Cloudflare
// deployments previews, prod, self-host go through Alchemy
// (alchemy.run.ts), which provisions real resources per stage and never
// reads these ids.
//
// The ids below are NOT dead config: miniflare derives its on-disk storage
// filenames (.wrangler/state, a persistent volume in Docker self-hosts)
// from an HMAC of `id`/`database_id`. Changing them orphans every existing
// local and self-hosted database. Leave them as-is.
"kv_namespaces": [
{
"binding": "KV",
@ -86,21 +88,15 @@
"migrations_dir": "drizzle",
},
],
// Postgres scale path. Production runs on Postgres: DATABASE_PROVIDER is set as
// a worker *secret* (`wrangler secret put DATABASE_PROVIDER` -> "postgres") so it
// survives `wrangler deploy` which resets plain vars/bindings and is what
// reverted prod to the D1 default before. This Hyperdrive binding points the
// pooled connection at that Postgres. Hyperdrive is the ONLY way the app
// connects to Postgres there is no direct-connection fallback.
// Postgres scale path (opt-in; D1 is the default). Hyperdrive is the ONLY way
// the app connects to Postgres there is no direct-connection fallback. For
// local dev, `localConnectionString` is the throwaway Docker Postgres from
// docs/LOCAL_POSTGRES.md, and nothing connects to it unless
// DATABASE_PROVIDER=postgres is set in .env.local.
//
// `localConnectionString` is local-dev only (ignored by `wrangler deploy`):
// it's the throwaway Docker Postgres from docs/LOCAL_POSTGRES.md, and nothing
// connects to it unless DATABASE_PROVIDER=postgres is set in .env.local.
//
// SELF-HOSTERS on the free D1 default: DELETE this hyperdrive block before
// deploying. The id below lives in OpenSEO's Cloudflare account, so `wrangler
// deploy` will fail without access to it. D1 stays the default when
// DATABASE_PROVIDER is unset.
// Running raw `wrangler deploy` against another account? DELETE this block
// the id lives in OpenSEO's account and the deploy fails without access to
// it. (Alchemy deploys and the Docker image never read it.)
"hyperdrive": [
{
"binding": "HYPERDRIVE",