Alchemy v2 for SaaS deployments (preview envs); account-agnostic wrangler.jsonc (#324)
This commit is contained in:
parent
d6d0f210ba
commit
bd22268ad2
@ -11,3 +11,4 @@ Dockerfile
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
.alchemy/
|
||||||
|
|||||||
33
.env.preview.example
Normal file
33
.env.preview.example
Normal 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
40
.env.production.example
Normal 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
145
.github/workflows/pr-preview.yml
vendored
Normal 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
5
.gitignore
vendored
@ -16,6 +16,8 @@ dist-sourcemaps/
|
|||||||
.env.
|
.env.
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
!.env.preview.example
|
||||||
|
!.env.production.example
|
||||||
.vercel
|
.vercel
|
||||||
.output
|
.output
|
||||||
.nitro
|
.nitro
|
||||||
@ -38,3 +40,6 @@ dist-sourcemaps/
|
|||||||
# Local Claude config (skills are shared)
|
# Local Claude config (skills are shared)
|
||||||
.claude/*
|
.claude/*
|
||||||
!.claude/skills/
|
!.claude/skills/
|
||||||
|
|
||||||
|
# Alchemy local state + bundle artifacts (SaaS deploys)
|
||||||
|
.alchemy/
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
**/build
|
**/build
|
||||||
**/public
|
**/public
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
|
# no parser for dotenv files; explicit `prettier <file>` aborts without this
|
||||||
|
.env*
|
||||||
routeTree.gen.ts
|
routeTree.gen.ts
|
||||||
.opencode/package.json
|
.opencode/package.json
|
||||||
|
|
||||||
|
|||||||
81
alchemy.access.ts
Normal file
81
alchemy.access.ts
Normal 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],
|
||||||
|
});
|
||||||
|
});
|
||||||
49
alchemy.preview-access.run.ts
Normal file
49
alchemy.preview-access.run.ts
Normal 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
306
alchemy.run.ts
Normal 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
203
docs/PREVIEW_DEPLOYMENTS.md
Normal 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.
|
||||||
@ -1,9 +1,11 @@
|
|||||||
{
|
{
|
||||||
"entry": [
|
"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
|
// Detect Tanstack Start Routes
|
||||||
"cli-auth.ts",
|
"cli-auth.ts",
|
||||||
"src/start.ts",
|
"src/start.ts",
|
||||||
"src/server.ts",
|
|
||||||
"src/router.tsx",
|
"src/router.tsx",
|
||||||
"src/routes/**/*.ts",
|
"src/routes/**/*.ts",
|
||||||
"src/routes/**/*.tsx",
|
"src/routes/**/*.tsx",
|
||||||
@ -29,6 +31,8 @@
|
|||||||
"**/*.{js,mjs,ts,tsx}",
|
"**/*.{js,mjs,ts,tsx}",
|
||||||
"!src/routeTree.gen.ts",
|
"!src/routeTree.gen.ts",
|
||||||
"!web/**",
|
"!web/**",
|
||||||
|
// Alchemy state/bundle artifacts (gitignored)
|
||||||
|
"!.alchemy/**",
|
||||||
"!badseo/**",
|
"!badseo/**",
|
||||||
],
|
],
|
||||||
"ignore": ["drizzle-prod.config.ts"],
|
"ignore": ["drizzle-prod.config.ts"],
|
||||||
|
|||||||
@ -15,6 +15,11 @@
|
|||||||
"lint:fix": "oxlint . --type-aware --fix",
|
"lint:fix": "oxlint . --type-aware --fix",
|
||||||
"preview": "npm run build && vite preview --port 3001",
|
"preview": "npm run build && vite preview --port 3001",
|
||||||
"deploy": "npm run db:migrate:prod && npm run build && wrangler deploy",
|
"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",
|
"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",
|
"cf-typegen": "wrangler types",
|
||||||
"types:check": "tsc --noEmit",
|
"types:check": "tsc --noEmit",
|
||||||
@ -29,7 +34,6 @@
|
|||||||
"db:migrate:local": "wrangler d1 migrations apply DB --local",
|
"db:migrate:local": "wrangler d1 migrations apply DB --local",
|
||||||
"db:migrate:prod": "wrangler d1 migrations apply DB --remote",
|
"db:migrate:prod": "wrangler d1 migrations apply DB --remote",
|
||||||
"db:migrate:pg": "drizzle-kit migrate --config drizzle-pg.config.ts",
|
"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",
|
"knip": "knip",
|
||||||
"release:notes": "node scripts/release-notes.mjs",
|
"release:notes": "node scripts/release-notes.mjs",
|
||||||
"release:publish": "node scripts/publish-release.mjs",
|
"release:publish": "node scripts/publish-release.mjs",
|
||||||
@ -111,6 +115,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.42.3",
|
"@cloudflare/vite-plugin": "^1.42.3",
|
||||||
"@cloudflare/workers-types": "^4.20260611.1",
|
"@cloudflare/workers-types": "^4.20260611.1",
|
||||||
|
"@effect/platform-node": "4.0.0-beta.93",
|
||||||
"@libsql/client": "^0.15.15",
|
"@libsql/client": "^0.15.15",
|
||||||
"@playwright/test": "^1.59.1",
|
"@playwright/test": "^1.59.1",
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
@ -121,7 +126,9 @@
|
|||||||
"@types/react": "^19.0.8",
|
"@types/react": "^19.0.8",
|
||||||
"@types/react-dom": "^19.0.3",
|
"@types/react-dom": "^19.0.3",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
|
"alchemy": "2.0.0-beta.61",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
|
"effect": "4.0.0-beta.93",
|
||||||
"knip": "^5.88.1",
|
"knip": "^5.88.1",
|
||||||
"oxlint": "^1.50.0",
|
"oxlint": "^1.50.0",
|
||||||
"oxlint-tsgolint": "^0.15.0",
|
"oxlint-tsgolint": "^0.15.0",
|
||||||
|
|||||||
1965
pnpm-lock.yaml
generated
1965
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,11 @@
|
|||||||
minimumReleaseAge: 11520
|
minimumReleaseAge: 11520
|
||||||
minimumReleaseAgeExclude:
|
minimumReleaseAgeExclude:
|
||||||
- "@every-app/*"
|
- "@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.
|
# 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
|
# - GHSA-67mh-4wv8-2f99: esbuild <=0.24.2 dev-server CORS. Only reachable via
|
||||||
|
|||||||
@ -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
|
`scripts/migrate-d1-to-postgres.ts`. For the condensed happy path see
|
||||||
[d1-to-postgres-simple.md](./d1-to-postgres-simple.md).
|
[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
|
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
|
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
|
dump-based approach was dropped after a `wrangler d1 export` download silently
|
||||||
|
|||||||
@ -7,6 +7,11 @@ detail — what the script converts, the low-downtime delta sync, cutover and
|
|||||||
rollback — see
|
rollback — see
|
||||||
[d1-to-postgres-detailed.md](./d1-to-postgres-detailed.md).
|
[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
|
> 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
|
> 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`.
|
> written to, so rollback is just flipping the provider back to `d1`.
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Bundler",
|
||||||
"lib": ["DOM", "DOM.Iterable", "ES2023"],
|
"lib": ["DOM", "DOM.Iterable", "ES2023"],
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
|
|||||||
@ -19,13 +19,6 @@
|
|||||||
"placement": {
|
"placement": {
|
||||||
"mode": "smart",
|
"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": [
|
"workflows": [
|
||||||
{
|
{
|
||||||
"name": "site-audit-workflow",
|
"name": "site-audit-workflow",
|
||||||
@ -68,6 +61,15 @@
|
|||||||
"triggers": {
|
"triggers": {
|
||||||
"crons": ["*/15 * * * *"],
|
"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": [
|
"kv_namespaces": [
|
||||||
{
|
{
|
||||||
"binding": "KV",
|
"binding": "KV",
|
||||||
@ -86,21 +88,15 @@
|
|||||||
"migrations_dir": "drizzle",
|
"migrations_dir": "drizzle",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
// Postgres scale path. Production runs on Postgres: DATABASE_PROVIDER is set as
|
// Postgres scale path (opt-in; D1 is the default). Hyperdrive is the ONLY way
|
||||||
// a worker *secret* (`wrangler secret put DATABASE_PROVIDER` -> "postgres") so it
|
// the app connects to Postgres — there is no direct-connection fallback. For
|
||||||
// survives `wrangler deploy` — which resets plain vars/bindings and is what
|
// local dev, `localConnectionString` is the throwaway Docker Postgres from
|
||||||
// reverted prod to the D1 default before. This Hyperdrive binding points the
|
// docs/LOCAL_POSTGRES.md, and nothing connects to it unless
|
||||||
// pooled connection at that Postgres. Hyperdrive is the ONLY way the app
|
// DATABASE_PROVIDER=postgres is set in .env.local.
|
||||||
// connects to Postgres — there is no direct-connection fallback.
|
|
||||||
//
|
//
|
||||||
// `localConnectionString` is local-dev only (ignored by `wrangler deploy`):
|
// Running raw `wrangler deploy` against another account? DELETE this block —
|
||||||
// it's the throwaway Docker Postgres from docs/LOCAL_POSTGRES.md, and nothing
|
// the id lives in OpenSEO's account and the deploy fails without access to
|
||||||
// connects to it unless DATABASE_PROVIDER=postgres is set in .env.local.
|
// it. (Alchemy deploys and the Docker image never read it.)
|
||||||
//
|
|
||||||
// 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.
|
|
||||||
"hyperdrive": [
|
"hyperdrive": [
|
||||||
{
|
{
|
||||||
"binding": "HYPERDRIVE",
|
"binding": "HYPERDRIVE",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user