-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathalchemy.run.ts
More file actions
447 lines (422 loc) · 18.9 KB
/
Copy pathalchemy.run.ts
File metadata and controls
447 lines (422 loc) · 18.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
import * as Alchemy from "alchemy";
import * as Cloudflare from "alchemy/Cloudflare";
import * as CfWorkers from "@distilled.cloud/cloudflare/workers";
import * as ZeroTrust from "@distilled.cloud/cloudflare/zero-trust";
import * as Config from "effect/Config";
import * as Console from "effect/Console";
import * as Effect from "effect/Effect";
import { Redacted } from "effect";
import { unstable_readConfig } from "wrangler";
import { z } from "zod";
import {
emailAccessGate,
HOSTED_PROD_STAGE,
readWorkersSubdomain,
requireAllowedEmails,
workerName,
} from "./alchemy.access.ts";
// Preview hostnames are `upgradeseo-<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, prod, and Cloudflare
// self-hosting. Stage semantics, security model, and credentials are
// documented once in docs/PREVIEW_DEPLOYMENTS.md.
//
// - Any stage except "hosted-prod": fresh stage-suffixed resources. Previews
// deploy via `pnpm deploy:preview --stage <name>`; self-hosters via
// `pnpm deploy:selfhost` (stage "selfhost", no flag to pass).
// - Stage "hosted-prod": names the EXISTING upgradeseo.so production resources
// so `--adopt` imports them. Deploy via `pnpm deploy:postgres` (--adopt and
// the stage baked in).
//
// Local dev and Docker self-host do NOT use this stack (wrangler.jsonc +
// @cloudflare/vite-plugin). This stack deploys the PREBUILT `vite build`
// output — Alchemy never runs Vite.
// The worker's runtime contract — compatibility date/flags, crons,
// observability, placement, DO/workflow classes — has one source of truth:
// wrangler.jsonc (what local dev and Docker self-host already run). Only
// stage-dependent values (names, domains, env) live in this file.
// unstable_readConfig ships types too loose to lint; validate what we consume.
const wrangler = z
.object({
compatibility_date: z.string(),
compatibility_flags: z.array(z.string()),
triggers: z.object({ crons: z.array(z.string()) }),
observability: z
.object({
enabled: z.boolean().optional(),
traces: z.object({ enabled: z.boolean().optional() }).optional(),
})
.optional(),
placement: z.object({ mode: z.enum(["off", "smart"]) }).optional(),
durable_objects: z.object({
bindings: z.array(z.object({ name: z.string(), class_name: z.string() })),
}),
workflows: z.array(
z.object({
binding: z.string(),
name: z.string(),
class_name: z.string(),
}),
),
})
.parse(unstable_readConfig({ config: "wrangler.jsonc" }));
// Physical names of the wrangler-era production resources (see git history of
// wrangler.jsonc). Adoption matches on these exact names/titles.
const PROD_NAMES = {
d1: "upgradeseo",
r2: "upgradeseo",
kv: "every-super-seo",
oauthKv: "OAUTH_KV",
hyperdrive: "upgradeseo",
} 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 : `upgradeseo-db-${stage}`,
// drizzle-generated SQL migrations; tracked in the same
// wrangler-compatible table prod already uses.
migrationsDir: "drizzle",
migrationsTable: "d1_migrations",
}).pipe(keep),
// R2 REMOVED (Upgrade Ventures, 15 Aug 2026). Cloudflare requires a payment
// method on file to enable R2 even inside its free tier, and the owner's
// ruling is that this deployment stays card-free. Safe to drop here because
// the bucket only ever backed (a) the DataForSEO response cache, which is
// off since we run without a DataForSEO key, and (b) the operator-only GDPR
// storage-erasure endpoint. Nothing in Search Console, the site audit or the
// GA4 tooling reads or writes R2 — verified by grep before removing.
// TRADE-OFF, stated plainly: /gdpr storage erasure would fail if invoked.
// Restore by reverting this hunk (original kept at alchemy.run.ts.orig).
KV: Cloudflare.KV.Namespace("KV", {
title: prod ? PROD_NAMES.kv : `upgradeseo-kv-${stage}`,
}).pipe(keep),
OAUTH_KV: Cloudflare.KV.Namespace("OAUTH_KV", {
title: prod ? PROD_NAMES.oauthKv : `upgradeseo-oauth-kv-${stage}`,
}).pipe(keep),
};
};
/**
* Prod-only: the existing Hyperdrive config pooling connections to the
* production Postgres. Origin credentials come from the env file — Cloudflare
* never returns them, so alchemy must know them to manage the config.
*/
const makeHyperdrive = () =>
Cloudflare.Hyperdrive.Connection("HYPERDRIVE", {
name: PROD_NAMES.hyperdrive,
origin: Config.all([
Config.string("HYPERDRIVE_ORIGIN_HOST"),
Config.string("HYPERDRIVE_ORIGIN_PORT").pipe(Config.withDefault("5432")),
Config.string("HYPERDRIVE_ORIGIN_DATABASE"),
Config.string("HYPERDRIVE_ORIGIN_USER"),
Config.redacted("HYPERDRIVE_ORIGIN_PASSWORD"),
]).pipe(
Config.map(([host, port, database, user, password]) => ({
scheme: "postgres" as const,
host,
port: Number(port),
database,
user,
password,
})),
),
// Prod runs with Hyperdrive caching OFF (no write invalidation for a SaaS
// with per-user reads-after-writes).
caching: { disabled: true },
}).pipe(Alchemy.RemovalPolicy.retain());
const optionalVar = (name: string) =>
Config.string(name).pipe(
Config.withDefault(""),
Config.map((value) => value.trim()),
);
const optionalSecret = (name: string) =>
Config.redacted(name).pipe(Config.withDefault(Redacted.make("")));
const accessScopeHint =
" (if this is a permissions error, re-run `pnpm alchemy login --configure`, answer yes to “Customize OAuth scopes?”, and select access:write alongside the defaults)";
/**
* Self-host auth (AUTH_MODE=cloudflare_access): derive the Access values
* instead of making the user copy them out of the dashboard. TEAM_DOMAIN is
* the account's Zero Trust team domain (one API read; the team is created —
* named after the workers.dev subdomain — if the account has none);
* POLICY_AUD is the audience tag of an alchemy-provisioned Access
* application whose allow-policy comes from ACCESS_ALLOWED_EMAILS. Explicit
* env values always win, so a hand-managed Access application keeps
* working — set both TEAM_DOMAIN and POLICY_AUD and nothing here provisions.
*/
const resolveSelfHostAccess = (
stage: string,
provision: boolean,
workersSubdomain: string,
) =>
Effect.gen(function* () {
let teamDomain = yield* optionalVar("TEAM_DOMAIN");
let policyAud: Alchemy.Input<string> = yield* optionalVar("POLICY_AUD");
if (!provision || (teamDomain && policyAud)) {
return { teamDomain, policyAud };
}
const { accountId } = yield* yield* Cloudflare.CloudflareEnvironment;
// The workers.dev subdomain names both the Access application's hostname
// (which must exist before the Worker resource does) and an auto-created
// Zero Trust team; it is deterministic from the account.
let subdomain = workersSubdomain;
if (!subdomain) {
const observed = yield* CfWorkers.getSubdomain({ accountId }).pipe(
Effect.catch((error) =>
Effect.die(
new Error(
`Could not read the workers.dev subdomain: ${String(error)}${accessScopeHint}`,
),
),
),
);
subdomain = `${observed.subdomain}.workers.dev`;
}
if (!teamDomain) {
const organization = yield* ZeroTrust.listOrganizationsForAccount({
accountId,
}).pipe(
Effect.catchTag("OrganizationNotFound", () => Effect.succeed(null)),
Effect.catch((error) =>
Effect.die(
new Error(
`Could not read the Zero Trust organization: ${String(error)}${accessScopeHint}`,
),
),
),
);
if (organization?.authDomain) {
teamDomain = `https://${organization.authDomain}`;
} else {
// Fresh account with no Zero Trust team: create one, named after the
// workers.dev subdomain — both are globally unique account handles.
const teamName = subdomain.replace(/\.workers\.dev$/, "");
yield* ZeroTrust.createOrganizationForAccount({
accountId,
name: teamName,
authDomain: `${teamName}.cloudflareaccess.com`,
}).pipe(
Effect.catch((error) =>
Effect.die(
new Error(
`Could not create the Zero Trust team "${teamName}": ${String(error)}${accessScopeHint}. You can also create one by hand — open https://one.dash.cloudflare.com once to pick a team name (free plan is fine), then redeploy.`,
),
),
),
);
yield* Console.log(
`Created the Zero Trust team "${teamName}" (${teamName}.cloudflareaccess.com) — its login page is where Cloudflare Access sends users to sign in.`,
);
teamDomain = `https://${teamName}.cloudflareaccess.com`;
}
}
if (!policyAud) {
const allowedEmails = yield* requireAllowedEmails(
"Set ACCESS_ALLOWED_EMAILS to the comma-separated emails allowed through Cloudflare Access — or set TEAM_DOMAIN and POLICY_AUD to manage the Access application yourself.",
);
const application = yield* emailAccessGate({
policyId: "SelfHostAllowUsers",
applicationId: "SelfHostAccess",
policyName: `upgradeseo ${stage} self-host users`,
applicationName: `upgradeseo ${stage}`,
domain: `${workerName(stage)}.${subdomain}`,
emails: allowedEmails,
});
policyAud = application.aud;
}
return { teamDomain, policyAud };
});
// Secrets/vars resolve from the env file passed to `alchemy deploy`
// (`Config.redacted` → Cloudflare `secret_text`, `Config.string` → plaintext
// var). NOTE: the alchemy CLI loads `--env-file` into the Config environment,
// NOT into process.env — a process.env read here silently yields "".
const dataEnv = {
// AUTH_MODE, DATABASE_PROVIDER, BETTER_AUTH_URL, TEAM_DOMAIN, and
// POLICY_AUD are stage-dependent and set in the stack body below.
// OPTIONAL, not required: this deployment runs on free data sources, and a
// required secret here made `deploy:selfhost` hard-fail before it started.
DATAFORSEO_API_KEY: optionalSecret("DATAFORSEO_API_KEY"),
// FREE PROVIDER STACK. Without these the Cloudflare deploy has no keyword
// data source at all, which is what shipped before.
BING_WEBMASTER_API_KEY: optionalSecret("BING_WEBMASTER_API_KEY"),
OPENPAGERANK_API_KEY: optionalSecret("OPENPAGERANK_API_KEY"),
// Google Ads: four secrets as one JSON blob, plus the manager customer id.
GOOGLE_ADS_CREDENTIALS: optionalSecret("GOOGLE_ADS_CREDENTIALS"),
GOOGLE_ADS_CUSTOMER_ID: optionalVar("GOOGLE_ADS_CUSTOMER_ID"),
// Azure AI Foundry, the sanctioned AI provider.
FOUNDERY_API_KEY: optionalSecret("FOUNDERY_API_KEY"),
FOUNDERY_ENDPOINT: optionalVar("FOUNDERY_ENDPOINT"),
// Encrypts provider keys entered through Settings. Without it (and without
// BETTER_AUTH_SECRET) the Settings page cannot store anything.
SECRETS_ENCRYPTION_KEY: optionalSecret("SECRETS_ENCRYPTION_KEY"),
// PageSpeed Insights. Keyless calls now 429 on a shared quota, so the free
// key is effectively required for Lighthouse data.
PAGESPEED_API_KEY: optionalSecret("PAGESPEED_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"),
GDPR_ERASURE_SECRET: optionalSecret("GDPR_ERASURE_SECRET"),
LOOPS_API_KEY: optionalSecret("LOOPS_API_KEY"),
LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID: optionalVar(
"LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID",
),
LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID: optionalVar(
"LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID",
),
POSTHOG_PUBLIC_KEY: optionalVar("POSTHOG_PUBLIC_KEY"),
POSTHOG_HOST: optionalVar("POSTHOG_HOST"),
TURNSTILE_SECRET_KEY: optionalSecret("TURNSTILE_SECRET_KEY"),
TURNSTILE_SITE_KEY: optionalVar("TURNSTILE_SITE_KEY"),
// Alchemy reconciles worker vars on every deploy, so the telemetry opt-out
// must live in the env file — a dashboard-set var would be wiped.
UPGRADESEO_TELEMETRY_DISABLED: optionalVar("UPGRADESEO_TELEMETRY_DISABLED"),
};
export default Alchemy.Stack(
"upgradeseo",
{
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 () in .env.production."),
);
}
// Prod must say which database it runs on. A silently-defaulted "d1"
// would deploy cleanly against the stale pre-Postgres data.
if (databaseProvider !== "postgres" && databaseProvider !== "d1") {
return yield* Effect.die(
new Error(
"Set DATABASE_PROVIDER explicitly in .env.production (prod runs postgres).",
),
);
}
} else if (workersSubdomain) {
authUrl = `https://${workerName(stage)}.${workersSubdomain}`;
} else if (authMode === "hosted") {
return yield* Effect.die(
new Error(
"Hosted previews derive BETTER_AUTH_URL from WORKERS_SUBDOMAIN — set it to the account's full workers.dev subdomain (shown under Workers & Pages).",
),
);
} else {
// local_noauth / cloudflare_access never read BETTER_AUTH_URL —
// src/lib/auth.ts uses a placeholder baseURL off the hosted path.
authUrl = "";
}
const access = yield* resolveSelfHostAccess(
stage,
authMode === "cloudflare_access" && !prod,
workersSubdomain,
);
const app = yield* Cloudflare.Worker("upgradeseo", {
name: workerName(stage),
// Prod serves the real domains; the zone is inferred from the hostname.
domain: prod ? ["app.upgradeseo.so", "www.app.upgradeseo.so"] : undefined,
// Prebuilt worker from `vite build` (@cloudflare/vite-plugin). The entry
// exports the DO + WorkflowEntrypoint classes (re-exported by
// src/server.ts), which `bundle: false` requires. Sibling chunks under
// assets/ are uploaded as-is by the default module rules.
main: "./dist/server/index.js",
bundle: false,
assets: {
directory: "./dist/client",
},
compatibility: {
date: wrangler.compatibility_date,
flags: wrangler.compatibility_flags,
},
// Site audits parse and persist batches of HTML inside Workflow steps.
// Paid Workers permit up to five minutes; keep headroom for unusually
// link-heavy sites after bounding page bodies and bulk-writing links.
// Configurable CPU limits are a paid-plan feature, and self-host
// deploys (cloudflare_access) may run on the free plan — which rejects
// them — so those get the plan default instead.
...(authMode === "cloudflare_access"
? {}
: { limits: { cpuMs: 300_000 } }),
observability: {
enabled: wrangler.observability?.enabled ?? true,
traces: { enabled: wrangler.observability?.traces?.enabled ?? false },
},
placement:
wrangler.placement?.mode === "smart" ? { mode: "smart" } : undefined,
// Scheduled rank checks — src/server.ts `scheduled` handler.
crons: wrangler.triggers.crons,
env: {
...makeResources(stage),
...dataEnv,
AUTH_MODE: authMode,
DATABASE_PROVIDER: databaseProvider || "d1",
BETTER_AUTH_URL: authUrl,
TEAM_DOMAIN: access.teamDomain,
POLICY_AUD: access.policyAud,
// Prod-only: pooled Postgres via the existing Hyperdrive config.
...(prod ? { HYPERDRIVE: makeHyperdrive() } : {}),
// Durable Objects (chat agents + the per-audit crawl scratchpad).
// Alchemy backs new DO classes with SQLite storage, which all of
// them require; the `migrations` array in wrangler.jsonc only
// applies to the wrangler/workerd surfaces (local dev, Docker).
...Object.fromEntries(
wrangler.durable_objects.bindings.map((binding) => [
binding.name,
Cloudflare.DurableObject(binding.name, {
className: binding.class_name,
}),
]),
),
// Cloudflare Workflows (upstream props-only form for prebuilt
// workers). Workflow names are ACCOUNT-scoped: prod owns the
// unsuffixed names; previews carry the stage suffix so concurrent
// stages can't repoint each other's workflows (registration is a
// PUT-as-upsert on the name).
...Object.fromEntries(
wrangler.workflows.map((workflow) => [
workflow.binding,
Cloudflare.Workflow(
prod ? workflow.name : `${workflow.name}-${stage}`,
{ className: workflow.class_name },
),
]),
),
},
}).pipe(
// Prod adopts the live worker serving app.upgradeseo.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>() };
}),
);