feat(env): build-time secret injection + effect/Config consumer reads#26
Conversation
Reverses ADR 0001's runtime SOPS-decrypt-on-every-isolate-boot design in
favour of forwarding already-decrypted secrets into the Cloudflare Worker
env at deploy time. Per-isolate cold-start cost is now zero — every
Worker boots with `process.env.BETTER_AUTH_SECRET` already populated by
Cloudflare's secret store, instead of paying an AGE+SOPS decrypt on
every cold path.
What changed:
- `.stack/config.apps.nix:envs.shared` — wire `sops:` sources for
BETTER_AUTH_SECRET (required), POLAR_ACCESS_TOKEN, POLAR_WEBHOOK_SECRET,
POLAR_PRO_PRODUCT_ID_PRODUCTION, POLAR_FREE_PRODUCT_ID_PRODUCTION so
the codegen embeds real ciphertext in each per-stage runtime payload.
- `apps/web/alchemy.run.ts` — forward those five values plus
`DATABASE_URL` into `Cloudflare.Vite({ env: { ... } })`. The values
are read from `process.env` after `loadDeployEnv("web", appEnv)`
populates them at deploy time. Polar values default to `""` so a
missing-secret deploy still boots; consumer code treats `""` as
"feature disabled".
- `packages/auth/src/config.ts` (new) — `effect/Config`-based typed,
redacted access to BETTER_AUTH_SECRET, POLAR_*, CORS_ORIGIN,
POLAR_SUCCESS_URL, STACKPANEL_DEPLOY_ENV. Secrets are
`Redacted<string>` so accidental log/JSON.stringify can't leak them;
`presentString` / `presentRedacted` helpers collapse the forwarded
`""` sentinel back to `undefined`.
- `packages/auth/src/index.ts` — replace direct `process.env.X` reads
with imports from `./config`. The migration TLA from ADR 0002 stays.
- `packages/auth/src/lib/payments.ts` — read `POLAR_ACCESS_TOKEN` via
`effect/Config` instead of `@gen/env/web` static import.
- `packages/auth/package.json` + `bun.lock` — add `effect` (catalog).
- `docs/adr/README.md` (new) — ADR conventions + index.
- `docs/adr/0001-runtime-secrets-via-gen-env-loader.md` (new, marked
**Superseded by 0003**) — preserves the rejected design body for the
historical record.
- `docs/adr/0003-build-time-env-injection-with-effect-config.md` (new)
— full rationale: cold-start cost dominates the architectural-purity
argument we made in ADR 0001, `effect/Config` is the consumer-side
pattern (process.env backend by default, Redacted<T> first-class,
swappable provider for tests).
- `packages/gen/env/**` — codegen output (BETTER_AUTH_SECRET +
POLAR_* now embedded as real ciphertext; effect/Schema renders the
redacted secrets as `RedactedFromValue`; web exports list
BETTER_AUTH_SECRET as required).
Closes the regression tracked in `bd stackpanel-3tj` and the
`BETTER_AUTH_SECRET=""` follow-up in `bd stackpanel-ayo`. PR #24
(`fix/wire-shared-runtime-env`) is superseded by this branch and can
be closed.
Refs: stackpanel-3tj, stackpanel-ayo.
PR SummaryHigh Risk Overview Refactors Extends generated env metadata to include Polar webhook/product IDs and marks secrets as redacted in Reviewed by Cursor Bugbot for commit 1a8adfd. Configure here. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Required secret silently falls back to empty string
- Replaced the empty-string fallback with an explicit required check that throws when
BETTER_AUTH_SECRETis missing before Cloudflare env forwarding.
- Replaced the empty-string fallback with an explicit required check that throws when
- ✅ Fixed: Exported
betterAuthSecretandrevealare never consumed- Removed the unused
betterAuthSecretconfig read/export and the unusedrevealexport frompackages/auth/src/config.ts.
- Removed the unused
Or push these changes by commenting:
@cursor push dd68259948
Preview (dd68259948)
diff --git a/apps/web/alchemy.run.ts b/apps/web/alchemy.run.ts
--- a/apps/web/alchemy.run.ts
+++ b/apps/web/alchemy.run.ts
@@ -53,13 +53,18 @@
//
// See `docs/adr/0003-build-time-env-injection-with-effect-config.md`
// (which supersedes the runtime-decrypt approach in ADR 0001).
+ const betterAuthSecret = process.env.BETTER_AUTH_SECRET;
+ if (!betterAuthSecret) {
+ throw new Error("BETTER_AUTH_SECRET environment variable is required");
+ }
+
const website = yield* Cloudflare.Vite("TanstackStart", {
compatibility: {
flags: ["nodejs_compat"],
},
env: {
DATABASE_URL: db.connectionUri,
- BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET ?? "",
+ BETTER_AUTH_SECRET: betterAuthSecret,
POLAR_ACCESS_TOKEN: process.env.POLAR_ACCESS_TOKEN ?? "",
POLAR_WEBHOOK_SECRET: process.env.POLAR_WEBHOOK_SECRET ?? "",
POLAR_PRO_PRODUCT_ID_PRODUCTION:
diff --git a/packages/auth/src/config.ts b/packages/auth/src/config.ts
--- a/packages/auth/src/config.ts
+++ b/packages/auth/src/config.ts
@@ -26,9 +26,6 @@
import * as Redacted from "effect/Redacted";
const program = Effect.gen(function* () {
- const betterAuthSecret = yield* Config.option(
- Config.redacted("BETTER_AUTH_SECRET"),
- );
const polarAccessToken = yield* Config.option(
Config.redacted("POLAR_ACCESS_TOKEN"),
);
@@ -43,7 +40,6 @@
Config.string("STACKPANEL_DEPLOY_ENV"),
);
return {
- betterAuthSecret,
polarAccessToken,
polarWebhookSecret,
polarSuccessUrl,
@@ -58,10 +54,6 @@
// throws is a true validation failure (none of these schemas have one).
const resolved = Effect.runSync(program);
-/** Better-Auth signing secret — `Redacted` so it doesn't accidentally leak. */
-export const betterAuthSecret: Option.Option<Redacted.Redacted<string>> =
- resolved.betterAuthSecret;
-
/** Polar API access token. When `None`, the polar plugin is not mounted. */
export const polarAccessToken: Option.Option<Redacted.Redacted<string>> =
resolved.polarAccessToken;
@@ -85,13 +77,6 @@
resolved.stackpanelDeployEnv;
/**
- * Unwrap a `Redacted<string>` only at the boundary where an SDK requires
- * a raw string. Centralized so callers don't sprinkle `Redacted.value`
- * around the codebase.
- */
-export const reveal = Redacted.value;
-
-/**
* Treats an empty string the same as a missing value. Used because
* `Cloudflare.Vite({ env: { POLAR_ACCESS_TOKEN: process.env.POLAR_ACCESS_TOKEN ?? "" } })`
* forwards literal `""` for an unset secret rather than dropping the key,You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit 1a8adfd. Configure here.
| }, | ||
| env: { | ||
| DATABASE_URL: db.connectionUri, | ||
| BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET ?? "", |
There was a problem hiding this comment.
Required secret silently falls back to empty string
High Severity
BETTER_AUTH_SECRET is marked required = true in .stack/config.apps.nix, yet the forwarder uses process.env.BETTER_AUTH_SECRET ?? "". If the deploy-time validation is ever bypassed or misconfigured, this silently forwards an empty string — reproducing the exact stackpanel-ayo bug this PR is meant to fix. The Polar vars correctly default to "" because they're optional, but the required auth secret deserves a loud failure (e.g., throwing or omitting the ?? "" fallback).
Reviewed by Cursor Bugbot for commit 1a8adfd. Configure here.
| * a raw string. Centralized so callers don't sprinkle `Redacted.value` | ||
| * around the codebase. | ||
| */ | ||
| export const reveal = Redacted.value; |
There was a problem hiding this comment.
Exported betterAuthSecret and reveal are never consumed
Low Severity
betterAuthSecret is exported from config.ts but never imported by any consumer — neither index.ts nor payments.ts reference it. The better-auth library reads BETTER_AUTH_SECRET directly from process.env internally, making this export dead code. Similarly, the reveal alias for Redacted.value is exported but not imported anywhere in the codebase.
Reviewed by Cursor Bugbot for commit 1a8adfd. Configure here.
|
Preview |
|
Docs preview |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1a8adfd82c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "Codex (@codex) review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".
| POLAR_PRO_PRODUCT_ID_PRODUCTION: | ||
| process.env.POLAR_PRO_PRODUCT_ID_PRODUCTION ?? "", | ||
| POLAR_FREE_PRODUCT_ID_PRODUCTION: | ||
| process.env.POLAR_FREE_PRODUCT_ID_PRODUCTION ?? "", |
There was a problem hiding this comment.
Keep optional Polar product IDs undefined when unset
Defaulting POLAR_PRO_PRODUCT_ID_PRODUCTION and POLAR_FREE_PRODUCT_ID_PRODUCTION to "" changes the meaning of “unset” and breaks the fallback logic in packages/auth/src/lib/polar-products.ts, which uses nullish coalescing (??) to fall back to sandbox IDs only when values are undefined/null. In deployments where these secrets are intentionally omitted, production will now get empty product IDs instead of sandbox IDs, causing checkout/webhook product mapping to fail at runtime.
Useful? React with 👍 / 👎.



Summary
Reverses the runtime-SOPS-decrypt approach proposed in the unreleased
ADR
0001(carried on PR #24's branch but never merged) in favour ofbuild-time env injection. Workers now boot with
process.env.BETTER_AUTH_SECRETalready populated by Cloudflare'ssecret store — zero per-isolate decrypt cost on the cold path. Consumer
code in
@stackpanel/authreads viaeffect/Configfor typed,redacted access.
Also resolves
bd stackpanel-ayo(BETTER_AUTH_SECRET="" in payloads) and the original waitlist 500
tracked in
bd stackpanel-3tj.What's in the box
.stack/config.apps.nix— wiresops:sources forBETTER_AUTH_SECRET(required),POLAR_ACCESS_TOKEN,POLAR_WEBHOOK_SECRET,POLAR_PRO_PRODUCT_ID_PRODUCTION,POLAR_FREE_PRODUCT_ID_PRODUCTION. The codegen embeds realciphertext into each per-stage runtime payload (verified via
sops -d packages/gen/env/data/prod/web.sops.json).apps/web/alchemy.run.ts— forward those five values plusDATABASE_URLintoCloudflare.Vite({ env: { ... } }). Polarvalues default to
""so a missing-secret deploy still boots.SOPS_AGE_KEYis not forwarded — the Worker doesn't decryptanything at runtime.
packages/auth/src/config.ts(new) —effect/Configschema:Config.option(Config.redacted("BETTER_AUTH_SECRET"))and friends.Materialized once at module load via
Effect.runSync; defaultConfigProvider.fromEnv()readsprocess.env. Secrets areRedacted<string>so accidental log/JSON.stringify can't leakthem.
presentString/presentRedactedhelpers collapse theforwarded
""sentinel back toundefined.packages/auth/src/index.ts— replace directprocess.env.Xreads with imports from
./config. Theawait runMigrations(db)TLA from ADR 0002 stays (its only env dep is
DATABASE_URL,which is in the forwarded env map).
packages/auth/src/lib/payments.ts— readPOLAR_ACCESS_TOKENviaeffect/Configinstead of@gen/env/webstatic import.packages/auth/package.json+bun.lock— addeffect(catalog).
docs/adr/README.md(new) — ADR conventions + index.docs/adr/0001-runtime-secrets-via-gen-env-loader.md(new,marked Superseded by 0003) — preserves the rejected design body
for the historical record.
docs/adr/0003-build-time-env-injection-with-effect-config.md(new) — full rationale: the per-isolate AGE+SOPS decrypt cost
dominates the architectural-purity argument we made in ADR 0001;
effect/Configis the consumer-side pattern (process.envbackend by default,
Redacted<T>first-class, swappable providerfor tests).
Why we're un-pivoting from ADR 0001
ADR 0001 chose to ship SOPS ciphertext into the Worker bundle and
decrypt it on every isolate boot. The appeal was a single-source-of-
truth pipeline: a secret declared in
.stack/config.apps.nixplusstackpanel codegen buildwould land in the embedded payload andbecome available on
process.envafter the loader runs.The per-isolate decrypt cost we waved away in 0001 turned out to be
the deciding factor. Cloudflare spawns isolates aggressively (per
region, per cold path, on memory eviction), and each new isolate paid
one AGE X25519 derivation + N ChaCha20-Poly1305 decrypts. The payload
itself changes far less often than a Worker isolate spins up; paying
on every boot is wrong-shaped, and the cost grows linearly with every
new secret.
Test plan
Deploy Webworkflow succeeds on the PR preview.secrets-codegen-check(drift gate) passes.POST https://local.<stage>.stackpanel.com/api/trpc/waitlist.join?batch=1returns
{"result": {...}}withok: true(oralreadyOnList: trueon a repeat) — not thedefault-secret 500 and not the missing-table error.
GET https://local.<stage>.stackpanel.com/returns 200.https://stackpanel.com/api/trpc/waitlist.joinreturnssuccess.
Coordination
fix/wire-shared-runtime-env). PR fix(env): wire shared secrets through to per-app runtime payloads #24carried both the SOPS-source wiring (kept here) and the runtime-
loader pivot (rejected here). Leaving a comment on fix(env): wire shared secrets through to per-app runtime payloads #24 to flag
the supersession.
feat/runtime-migrations, merged) — theawait runMigrations(db)TLA inpackages/auth/src/index.tsis preserved exactly. Its only envdep is
DATABASE_URL, which is in the forwardedCloudflare.Vite({ env })map already.Bd
stackpanel-3tj(waitlist auth-secret regression) — to be closedafter this PR ships and the production curl confirms.
stackpanel-ayo(BETTER_AUTH_SECRET="" in payloads) — resolved bythis PR's wiring; close after merge with the curl evidence.