Skip to content

feat(env): build-time secret injection + effect/Config consumer reads#26

Merged
cooper (czxtm) merged 1 commit intomainfrom
feat/build-time-env-effect-config
May 1, 2026
Merged

feat(env): build-time secret injection + effect/Config consumer reads#26
cooper (czxtm) merged 1 commit intomainfrom
feat/build-time-env-effect-config

Conversation

@czxtm
Copy link
Copy Markdown
Contributor

Summary

Reverses the runtime-SOPS-decrypt approach proposed in the unreleased
ADR 0001 (carried on PR #24's branch but never merged) in favour of
build-time env injection. Workers now boot with
process.env.BETTER_AUTH_SECRET already populated by Cloudflare's
secret store — zero per-isolate decrypt cost on the cold path. Consumer
code in @stackpanel/auth reads via effect/Config for 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 — wire sops: sources for
    BETTER_AUTH_SECRET (required), POLAR_ACCESS_TOKEN,
    POLAR_WEBHOOK_SECRET, POLAR_PRO_PRODUCT_ID_PRODUCTION,
    POLAR_FREE_PRODUCT_ID_PRODUCTION. The codegen embeds real
    ciphertext 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 plus
    DATABASE_URL into Cloudflare.Vite({ env: { ... } }). Polar
    values default to "" so a missing-secret deploy still boots.
    SOPS_AGE_KEY is not forwarded — the Worker doesn't decrypt
    anything at runtime.
  • packages/auth/src/config.ts (new) — effect/Config schema:
    Config.option(Config.redacted("BETTER_AUTH_SECRET")) and friends.
    Materialized once at module load via Effect.runSync; default
    ConfigProvider.fromEnv() reads process.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 await 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 — 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: the per-isolate AGE+SOPS decrypt 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).

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.nix plus
stackpanel codegen build would land in the embedded payload and
become available on process.env after 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 Web workflow succeeds on the PR preview.
  • secrets-codegen-check (drift gate) passes.
  • POST https://local.<stage>.stackpanel.com/api/trpc/waitlist.join?batch=1
    returns {"result": {...}} with ok: true (or
    alreadyOnList: true on a repeat) — not the
    default-secret 500 and not the missing-table error.
  • GET https://local.<stage>.stackpanel.com/ returns 200.
  • After merge, same waitlist call against
    https://stackpanel.com/api/trpc/waitlist.join returns
    success.

Coordination

Bd

  • stackpanel-3tj (waitlist auth-secret regression) — to be closed
    after this PR ships and the production curl confirms.
  • stackpanel-ayo (BETTER_AUTH_SECRET="" in payloads) — resolved by
    this PR's wiring; close after merge with the curl evidence.

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.
@cursor
Copy link
Copy Markdown

cursor Bot commented May 1, 2026

PR Summary

High Risk
Changes how authentication and billing secrets are sourced and injected into the Cloudflare Worker runtime, plus refactors @stackpanel/auth to use those values; misconfiguration could break logins or Polar integrations across environments.

Overview
Switches the web deploy flow to build-time secret injection: shared secrets are now sourced from SOPS in .stack/config.apps.nix (with BETTER_AUTH_SECRET marked required) and forwarded into Cloudflare.Vite({ env }) in apps/web/alchemy.run.ts so Cloudflare stores them as Worker secrets.

Refactors @stackpanel/auth to stop reading secrets directly from process.env/@gen/env and instead resolve them via new packages/auth/src/config.ts using effect/Config with Redacted wrappers and helpers to treat forwarded empty strings as “unset.” Updates Polar wiring (checkout success URL, webhook mounting, polarClient creation) to use this config.

Extends generated env metadata to include Polar webhook/product IDs and marks secrets as redacted in @gen/env’s Effect schemas, updates generated SOPS payloads accordingly, adds ADR documentation (0001 superseded, 0003 accepted, ADR README), and adds the effect dependency to packages/auth/lockfile.

Reviewed by Cursor Bugbot for commit 1a8adfd. Configure here.

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

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_SECRET is missing before Cloudflare env forwarding.
  • ✅ Fixed: Exported betterAuthSecret and reveal are never consumed
    • Removed the unused betterAuthSecret config read/export and the unused reveal export from packages/auth/src/config.ts.

Create PR

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.

Comment thread apps/web/alchemy.run.ts
},
env: {
DATABASE_URL: db.connectionUri,
BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET ?? "",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Fix in Cursor Fix in Web

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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1a8adfd. Configure here.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 1, 2026

Preview pr-26 has been destroyed.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 1, 2026

Docs preview pr-26 has been destroyed.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread apps/web/alchemy.run.ts
Comment on lines +65 to +68
POLAR_PRO_PRODUCT_ID_PRODUCTION:
process.env.POLAR_PRO_PRODUCT_ID_PRODUCTION ?? "",
POLAR_FREE_PRODUCT_ID_PRODUCTION:
process.env.POLAR_FREE_PRODUCT_ID_PRODUCTION ?? "",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

@czxtm cooper (czxtm) merged commit d509e93 into main May 1, 2026
12 of 13 checks passed
@czxtm cooper (czxtm) deleted the feat/build-time-env-effect-config branch May 1, 2026 17:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant