fix(env): wire shared secrets through to per-app runtime payloads#24
fix(env): wire shared secrets through to per-app runtime payloads#24cooper (czxtm) wants to merge 4 commits intomainfrom
Conversation
Joining the waitlist (and every other tRPC call) on production returned HTTP 500 with `You are using the default secret. Please set BETTER_AUTH_SECRET ...`. Root cause: `.stack/config.apps.nix:envs.shared` declared `BETTER_AUTH_SECRET` and `POLAR_ACCESS_TOKEN` without a `sops:` source — just `required = false` and a description. The codegen rendered `"BETTER_AUTH_SECRET": ""` into `packages/gen/env/data/<env>/<app>.sops.json` and the embedded runtime payload, so at request time `process.env.BETTER_AUTH_SECRET === ""`. Better-auth fell back to its hard- coded sentinel `"better-auth-secret-12345678901234567890"` and `validateSecret()` threw on every request, because `createTRPCContext` calls `auth.api.getSession()` for every procedure (waitlist included). `POLAR_WEBHOOK_SECRET`, `POLAR_PRO_PRODUCT_ID_PRODUCTION`, and `POLAR_FREE_PRODUCT_ID_PRODUCTION` were missing from `envs.shared` entirely — only present in the deploy scope — so any `process.env.*` reader saw `undefined` despite the SOPS source existing. Fix wires every shared env that has a corresponding SOPS source: BETTER_AUTH_SECRET → /shared/better-auth-secret (required) POLAR_ACCESS_TOKEN → /shared/polar-access-token POLAR_WEBHOOK_SECRET → /shared/polar-webhook-secret POLAR_PRO_PRODUCT_ID_PRODUCTION → /shared/polar-pro-product-id-production POLAR_FREE_PRODUCT_ID_PRODUCTION → /shared/polar-free-product-id-production `BETTER_AUTH_URL`, `CORS_ORIGIN`, `POLAR_SUCCESS_URL` stay `required = false` without a SOPS source — they are per-env URL config and the consumer code already handles missing values gracefully (better-auth derives the URL from the request host; CORS_ORIGIN and POLAR_SUCCESS_URL fall back to upstream defaults). Documented in the comment block. Re-ran `stackpanel codegen build` — every per-app per-env runtime payload now embeds real SOPS ciphertext for these keys (verified via `sops -d packages/gen/env/data/prod/web.sops.json`). codegen-drift gate should pass. Refs stackpanel-3tj.
PR SummaryMedium Risk Overview Regenerates Adds Reviewed by Cursor Bugbot for commit 8a7897c. Configure here. |
|
Preview deployed to |
|
Docs preview deployed to |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Optional secrets typed as required in Effect schemas
- Updated the Effect schemas for api/docs/web so the four optional Polar secrets are now
Schema.optional(Schema.RedactedFromValue(Schema.String))and no longer fail decode when absent.
- Updated the Effect schemas for api/docs/web so the four optional Polar secrets are now
Or push these changes by commenting:
@cursor push 926aaaaa88
Preview (926aaaaa88)
diff --git a/packages/gen/env/src/effect/api.ts b/packages/gen/env/src/effect/api.ts
--- a/packages/gen/env/src/effect/api.ts
+++ b/packages/gen/env/src/effect/api.ts
@@ -21,11 +21,17 @@
BETTER_AUTH_SECRET: Schema.RedactedFromValue(Schema.String),
BETTER_AUTH_URL: Schema.optional(Schema.String),
CORS_ORIGIN: Schema.optional(Schema.String),
- POLAR_ACCESS_TOKEN: Schema.RedactedFromValue(Schema.String),
- POLAR_FREE_PRODUCT_ID_PRODUCTION: Schema.RedactedFromValue(Schema.String),
- POLAR_PRO_PRODUCT_ID_PRODUCTION: Schema.RedactedFromValue(Schema.String),
+ POLAR_ACCESS_TOKEN: Schema.optional(Schema.RedactedFromValue(Schema.String)),
+ POLAR_FREE_PRODUCT_ID_PRODUCTION: Schema.optional(
+ Schema.RedactedFromValue(Schema.String),
+ ),
+ POLAR_PRO_PRODUCT_ID_PRODUCTION: Schema.optional(
+ Schema.RedactedFromValue(Schema.String),
+ ),
POLAR_SUCCESS_URL: Schema.optional(Schema.String),
- POLAR_WEBHOOK_SECRET: Schema.RedactedFromValue(Schema.String),
+ POLAR_WEBHOOK_SECRET: Schema.optional(
+ Schema.RedactedFromValue(Schema.String),
+ ),
PORT: Schema.String,
POSTGRES_URL: Schema.RedactedFromValue(Schema.String),
}) {}
diff --git a/packages/gen/env/src/effect/docs.ts b/packages/gen/env/src/effect/docs.ts
--- a/packages/gen/env/src/effect/docs.ts
+++ b/packages/gen/env/src/effect/docs.ts
@@ -21,11 +21,17 @@
BETTER_AUTH_SECRET: Schema.RedactedFromValue(Schema.String),
BETTER_AUTH_URL: Schema.optional(Schema.String),
CORS_ORIGIN: Schema.optional(Schema.String),
- POLAR_ACCESS_TOKEN: Schema.RedactedFromValue(Schema.String),
- POLAR_FREE_PRODUCT_ID_PRODUCTION: Schema.RedactedFromValue(Schema.String),
- POLAR_PRO_PRODUCT_ID_PRODUCTION: Schema.RedactedFromValue(Schema.String),
+ POLAR_ACCESS_TOKEN: Schema.optional(Schema.RedactedFromValue(Schema.String)),
+ POLAR_FREE_PRODUCT_ID_PRODUCTION: Schema.optional(
+ Schema.RedactedFromValue(Schema.String),
+ ),
+ POLAR_PRO_PRODUCT_ID_PRODUCTION: Schema.optional(
+ Schema.RedactedFromValue(Schema.String),
+ ),
POLAR_SUCCESS_URL: Schema.optional(Schema.String),
- POLAR_WEBHOOK_SECRET: Schema.RedactedFromValue(Schema.String),
+ POLAR_WEBHOOK_SECRET: Schema.optional(
+ Schema.RedactedFromValue(Schema.String),
+ ),
PORT: Schema.String,
POSTGRES_URL: Schema.RedactedFromValue(Schema.String),
}) {}
diff --git a/packages/gen/env/src/effect/web.ts b/packages/gen/env/src/effect/web.ts
--- a/packages/gen/env/src/effect/web.ts
+++ b/packages/gen/env/src/effect/web.ts
@@ -22,11 +22,17 @@
BETTER_AUTH_URL: Schema.optional(Schema.String),
CORS_ORIGIN: Schema.optional(Schema.String),
HOSTNAME: Schema.String,
- POLAR_ACCESS_TOKEN: Schema.RedactedFromValue(Schema.String),
- POLAR_FREE_PRODUCT_ID_PRODUCTION: Schema.RedactedFromValue(Schema.String),
- POLAR_PRO_PRODUCT_ID_PRODUCTION: Schema.RedactedFromValue(Schema.String),
+ POLAR_ACCESS_TOKEN: Schema.optional(Schema.RedactedFromValue(Schema.String)),
+ POLAR_FREE_PRODUCT_ID_PRODUCTION: Schema.optional(
+ Schema.RedactedFromValue(Schema.String),
+ ),
+ POLAR_PRO_PRODUCT_ID_PRODUCTION: Schema.optional(
+ Schema.RedactedFromValue(Schema.String),
+ ),
POLAR_SUCCESS_URL: Schema.optional(Schema.String),
- POLAR_WEBHOOK_SECRET: Schema.RedactedFromValue(Schema.String),
+ POLAR_WEBHOOK_SECRET: Schema.optional(
+ Schema.RedactedFromValue(Schema.String),
+ ),
PORT: Schema.String,
POSTGRES_URL: Schema.RedactedFromValue(Schema.String),
}) {}You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit 8a7897c. Configure here.
| POLAR_FREE_PRODUCT_ID_PRODUCTION: Schema.RedactedFromValue(Schema.String), | ||
| POLAR_PRO_PRODUCT_ID_PRODUCTION: Schema.RedactedFromValue(Schema.String), | ||
| POLAR_SUCCESS_URL: Schema.optional(Schema.String), | ||
| POLAR_WEBHOOK_SECRET: Schema.RedactedFromValue(Schema.String), |
There was a problem hiding this comment.
Optional secrets typed as required in Effect schemas
Medium Severity
POLAR_ACCESS_TOKEN, POLAR_FREE_PRODUCT_ID_PRODUCTION, POLAR_PRO_PRODUCT_ID_PRODUCTION, and POLAR_WEBHOOK_SECRET changed from Schema.optional(Schema.String) to Schema.RedactedFromValue(Schema.String), making them required for schema decoding. But the nix config declares all four with required = false and their descriptions explicitly state they handle missing values ("When unset, polarClient is null", "Falls back to the sandbox product when unset"). If the Effect-based env loader is used in an environment where SOPS decryption fails or values are empty, schema decode will reject the payload instead of allowing graceful degradation.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 8a7897c. Configure here.
The web Worker doesn't call loadAppEnv() at request time; it relies on Cloudflare to inject env vars set on the deployed script. Previously only DATABASE_URL was forwarded — BETTER_AUTH_SECRET and the four Polar secrets we just SOPS-wired were not, so process.env.BETTER_AUTH_SECRET was empty in production, better-auth fell back to its sentinel, and every tRPC call (including waitlist.join) returned 500. This forwards the five secrets from process.env (populated by loadDeployEnv at the top of alchemy.run.ts) into the Cloudflare.Vite env: map. Polar values default to '' so a missing-secret deploy still boots; consumer code treats empty as feature-disabled. Refs stackpanel-3tj.
…time forwarding This reverses the env-shovel approach from 21c0084 in favour of decrypting the embedded `@gen/env` SOPS payload at Worker boot. The Worker now needs only `SOPS_AGE_KEY` (the AGE key material) and `APP_ENV` (the SOPS namespace discriminator) at deploy time; every other secret is unsealed on first request from the encrypted payload already shipped in packages/gen/env/src/runtime/generated-payloads/web/{dev,staging,prod}.ts. Why: - Single source of truth. Adding a new app secret used to require two edits (.stack/config.apps.nix AND apps/web/alchemy.run.ts); now only the Nix scope edit + a codegen rebuild is needed. - No dual-write into Cloudflare's secret store. The encrypted payload is the only place secret material lives. - Mirrors the Fly-deployed apps/api boot pattern. Changes: - apps/web/src/server.ts: top-level `await loadAppEnv("web", APP_ENV, { inject: true })` against the new edge-safe `@gen/env/runtime/edge` loader. Guarded by `process.env.SOPS_AGE_KEY` so vite dev / vitest keep working with whatever process.env they already have. - apps/web/alchemy.run.ts: drop BETTER_AUTH_SECRET and the four POLAR_* forwards added in 21c0084. Add SOPS_AGE_KEY (read from process.env after `loadDeployEnv`) and APP_ENV (the resolved appEnv literal). - packages/auth/src/index.ts: lazify `betterAuth({...})` behind a Proxy-backed `auth` export. Defers `validateSecret` to first property access (per-request) so the import chain routeTree.gen.ts → routes/api/trpc.$.ts → @stackpanel/auth no longer crashes when env injection hasn't happened yet. Adds `getAuth()` for callers that want to surface init errors eagerly. - nix/stackpanel/lib/codegen/env-package.nix: add `./runtime/edge` export pointing at `loader.ts` (no FileSystem/ChildProcess deps). Required because the existing `./runtime` export resolves to `node-loader.ts`, which pulls in @effect/platform-node — fine for alchemy.run.ts on Node, broken in a Cloudflare Worker. - packages/gen/env/package.json: regenerated from the Nix change. - docs/adr/0001-runtime-secrets-via-gen-env-loader.md (+ README): ADR documenting the decision, consequences, and rejected alternatives. Refs: bd stackpanel-3tj
Move the SOPS payload decrypt from `apps/web/src/server.ts` into
`packages/auth/src/index.ts` so it happens BEFORE `betterAuth({...})`
constructs the auth instance — and before `polarClient` is built.
The previous PR-24 attempt put the load in `server.ts` with a Proxy in
`@stackpanel/auth`, but ESM hoisting meant `payments.ts` (and the rest of
`index.ts`'s static imports) evaluated before the `server.ts` TLA ran:
`polarClient` was always `null` in the Worker, and `betterAuth({...})`
was being initialised before `process.env.BETTER_AUTH_SECRET` had been
written by `loadAppEnv`. Result: HTTP 500 "you are using the default
secret" on every tRPC call.
Now `@stackpanel/auth/index.ts`:
- Awaits `loadAppEnv("web", APP_ENV, { inject: true })` at the top of
the module (gated on `process.env.SOPS_AGE_KEY`).
- Reads `BETTER_AUTH_SECRET` and `POLAR_*` via a local `envOf()` helper
that prefers the decrypted payload over `process.env` (so we don't
rely on `process.env` being writable at the edge — Cloudflare's
unenv shim is mutable today, but it shouldn't be load-bearing).
- Constructs `polarClient` inline in `index.ts` AFTER the TLA, instead
of importing it from `./lib/payments`. The static import was the
reason `polarClient` was always null in the Worker.
- Drops the lazy Proxy: now that the env load happens inside this
module, eager construction is safe again.
`apps/web/src/server.ts` keeps its own (defense-in-depth) TLA load —
both calls are idempotent because the loader caches the decrypted
payload.
Refs ADR docs/adr/0001-runtime-secrets-via-gen-env-loader.md.
|
Superseded by #26. #26 keeps your SOPS-source wiring (so the codegen still embeds real ciphertext for The new ADR 0003 there documents the trade-off; ADR 0001's body is preserved with a 'Superseded by 0003' header for the historical record. Suggest closing this PR once you're happy with #26. |



Problem
Joining the waitlist (and every other tRPC call) on production returned HTTP 500:
Root cause
.stack/config.apps.nix:envs.shareddeclaredBETTER_AUTH_SECRETandPOLAR_ACCESS_TOKENwithout asops:source — onlyrequired = falseanda description. The codegen rendered them as
""inpackages/gen/env/data/<env>/<app>.sops.jsonand the embedded runtimepayload, so at request time
process.env.BETTER_AUTH_SECRET === "". Better-auth's
createAuthContextfalls back to its hard-coded sentinel"better-auth-secret-12345678901234567890"when its env is empty, thenvalidateSecret()throws on every request —createTRPCContextcallsauth.api.getSession()for every procedure (including the publicwaitlist.joinmutation).POLAR_WEBHOOK_SECRET,POLAR_PRO_PRODUCT_ID_PRODUCTION, andPOLAR_FREE_PRODUCT_ID_PRODUCTIONwere missing fromenvs.sharedentirely— only present in the deploy scope — so any
process.env.*reader sawundefineddespite the SOPS source existing.The actual encrypted secret has lived at
/shared/better-auth-secretin.stack/secrets/vars/shared.sops.yamlthe whole time, and the deploy scopealready wired it. The runtime
envs.sharedblock just never connectedthe dots.
Change
Wire every shared env that has a corresponding SOPS source:
BETTER_AUTH_SECRET/shared/better-auth-secret(required)POLAR_ACCESS_TOKEN/shared/polar-access-tokenPOLAR_WEBHOOK_SECRET/shared/polar-webhook-secretPOLAR_PRO_PRODUCT_ID_PRODUCTION/shared/polar-pro-product-id-productionPOLAR_FREE_PRODUCT_ID_PRODUCTION/shared/polar-free-product-id-productionBETTER_AUTH_URL,CORS_ORIGIN,POLAR_SUCCESS_URLstayrequired = falsewithout a SOPS source — they are per-env URL config and the consumer code
already handles missing values gracefully (better-auth derives the URL from
the request host; CORS_ORIGIN/POLAR_SUCCESS_URL fall back to upstream
defaults). Documented in the comment block.
Re-ran
stackpanel codegen build; every per-app per-env runtime payloadnow embeds real SOPS ciphertext for these keys (verified via
sops -d packages/gen/env/data/prod/web.sops.json).Verification
Local:
Codegen idempotent —
stackpanel codegen buildre-runs cleanly with nofollow-up writes (codegen-drift gate should pass).
Test plan
Deploy Webworkflow succeeds on the PR preview.secrets-codegen-check(drift gate) passes.POST https://web.<pr>.stackpanel.com/api/trpc/waitlist.joinreturns{"result": {"data": {"json": {"ok": true, ...}}}}instead of thedefault-secret error.
https://stackpanel.com/api/trpc/waitlist.joinreturns success.
Refs
stackpanel-3tj.