Env Vars

Env Vars

Typed access via getServerEnv() in lib/env.ts. Production values live in Vercel (tryvio-shopify project). Local: apps/web/.env.local.

Env does not deploy with code (#343)

Vercel env is manual, per-project config — the pipeline ships code and nothing else. On 2026-08-12 release v2026.34 shipped the #326 affiliate portal with AFFILIATE_SESSION_SECRET set only on dev: tsc, 2890 vitest, proof-lint, schema-drift and post-deploy health 200 were all green, and /affiliate served “Portal unavailable” until the owner found it in his browser. Same class as #316 (migrations don’t deploy), one layer over.

Two rules follow:

  1. Every key the code reads is classified in apps/web/src/lib/env-registry.ts as required / optional / dev-only / waived / test-only, each with a reason. A key that is read by shipping code but not classified fails the gate — that is the point: a new env key cannot enter the codebase without someone declaring what it means. Note lib/env.ts is not a complete registry — a handful of keys (SHOPIFY_BILLING_TEST, KLAVIYO_OAUTH_ALLOW_SHOP_PARAM, TRYVIO_DEMO_SHOP_DOMAIN, ALERT_EMAIL, TRYVIO_CATALOG_PROVIDER, TRYVIO_SOURCE_ROOT) are read straight from process.env, so the gate scans all of apps/web/src. Test-only keys (#388 — RUN_AC6, PROOF_346, the RING_* harness switches that blocked the 2026.35 freeze) are verified from the reads by scripts/agents/_env-scan.mjs: a key whose every read sits in a test path (*.test.ts(x), __tests__/, test(s)/) passes with no registry entry and is reported as TEST-ONLY; an entry that claims test-only while shipping code reads it is RED (FALSE TEST-ONLY, naming the file:line). The offline half of this gate — classification — also runs on every push via landing-gate.mjs (#398), so an unclassified key surfaces the day it is written, not on the cut.
  2. Set new keys BEFORE the merge, then redeploy. Env only reaches a new deployment — setting a key does nothing for the deployment already running. Since #443 this step is a command, not a human opening the Vercel dashboard — see “The setter” below.

node scripts/agents/env-drift.mjs --target=prod|dev compares the keys the code reads against the target project’s env names (values never leave Vercel). Exit 1 on a missing required key, an unclassified key, or a dev-only key present on prod; exit 2 = inconclusive (fail-closed — an unreachable API never reads as “all present”). release-cut.mjs prints ENV IN SCOPE in the manifest beside MIGRATIONS, and production.yml re-runs the gate post-deploy (VERCEL_TOKEN).

Where a value comes from — ENV_ORIGIN (#443)

env-drift.mjs only ever DETECTED a missing key; the last mile stayed a human pasting it into the Vercel dashboard — the exact step that shipped the #326 affiliate portal dark on v2026.34. apps/web/src/lib/env-registry.ts exports a second map, ENV_ORIGIN, next to ENV_REGISTRY: not what a key means, but how its value is obtained, so the last mile can be automated. Four shapes:

originmeaningexample keys
generateWe invent it — crypto.randomBytes(32) base64url. The owner is never asked.CRON_SECRET, ADMIN_SESSION_SECRET, MERCHANT_SESSION_SECRET, AFFILIATE_SESSION_SECRET, OUTREACH_LINK_SECRET, TELEGRAM_WEBHOOK_SECRET
generate:hex64Same, but 64 hex chars — the one shape the consumer accepts.TOKEN_ENCRYPTION_KEY only: lib/server/token-crypto.ts throws “must be a 64-character hex string (32 bytes)”; a default base64url value would pass every gate and fail at runtime
provider:<name>Issued by someone else. The owner pastes it to a console ONCE, ever.provider:supabase, provider:shopify, provider:kie, provider:fal, provider:resend, provider:telegram, provider:klaviyo, provider:shopify-partners, provider:vercel
manual — <why>The escape hatch — must carry the reason.SHOPIFY_APP_URL, SHOPIFY_STORE_DOMAIN, ADMIN_AUTH_EMAIL, VERCEL_TEAM_ID (all: differs per environment, not issued by a provider), ADMIN_AUTH_PASSWORD (the owner must KNOW this value to sign in to /admin — generating it would lock him out)

An origin is declared for every key that must be obtained: required, waived, and any optional key holding a credential. A key with a code default or a dev-only/test-only switch needs none — there is nothing to fetch.

The gate. A required/waived key with no ENV_ORIGIN entry is RED in three places: a NO ORIGIN section of env-drift.mjs (exit 1, prints the exact registry line to paste), the env-classify gate in landing-gate.mjs on every push, and env-registry.test.ts in vitest. The rationale is the same as classification itself: a key nobody knows how to obtain still ends with a human in the dashboard.

The setter — env-set.mjs (#443)

node scripts/agents/env-set.mjs turns a red env-drift into a set key, without a human opening Vercel.

  • Storage. Values live at ~/.tryvio/env/<TARGET>/<KEY> — outside the repo, next to the Vercel/Supabase tokens. Never committed, never printed, never on argv, and per TARGET so a dev value can never reach prod. Generated secrets are stored too (not write-only), so they can be recovered and rotated.
  • Target + confirmation. Default --target=dev. --target=prod REFUSES without the typed --confirm=SET-PROD (golden rule #1). --origin=<origin> is a dev-only rehearsal switch, refused on prod.
  • Default scope. With no --key, every required key the code reads that the target does not have yet — exactly what env-drift would report red.
  • Manual values. --store --key=K reads the value from STDIN, never argv, and stores it. An empty/whitespace value is refused.
  • No silent clobber. A key already present on the target is never overwritten (ALREADY SET); --rotate is the deliberate override. A generate key is never silently regenerated — that would invalidate live sessions, links, or cron auth.
  • --dry-run describes every write (mint, store, Vercel POST/PATCH, redeploy) and performs none. A Vercel failure mid-batch exits 1 and reports which keys were set and which were not — a half-set batch is never deployed.
  • Redeploy. A key must be followed by a deployment that consumes it. During a ship, --no-redeploy — the chain order is env → merge → deploy, so the merge’s own deploy IS that deployment and an extra one would be waste. Outside a ship (fixing live prod), the tool triggers the redeploy itself, redeploying whatever is currently serving (same code, new env).
  • Regression harness: node scripts/agents/env-set-selftest.mjs (53 checks, hermetic — no network, no Vercel, no real secret; the Vercel API is replaced via --fake-vercel=<json>, the store via TRYVIO_ENV_DIR).

In the ship chain. _ship-steps.mjs’s stepEnv used to gate and stop for a human to run vercel env add …. It now gates → if red, obtains the missing keys from their declared origin via env-set.mjs → re-gates. It runs --target=prod --confirm=SET-PROD --no-redeploy, and only after confirming an unburned owner-approval nonce for the PR (read-only check — stepMerge still burns it): branch-guard is a hook on a typed command string and cannot see a spawned process, which is why the chain enforces the approval itself. No nonce → it gates exactly as before and prints the manual command. The chain now stops only when a human value genuinely does not exist yet, and stops with the one question to ask.

dev-only keys — checked in BOTH directions

VarWhy it must never be set on prodRequired on
TRYON_MOCK_MODEEvery merchant try-on silently returns a mockdev, staging
SHOPIFY_BILLING_TESTEvery subscription becomes a test charge — zero revenuedev, staging
KLAVIYO_OAUTH_ALLOW_SHOP_PARAMAccepts an unauthenticated ?shop param — an auth escape hatch—

/api/health/deep reports a feature_config component with a presence boolean per feature (booleans only, never values). It stays ok when a feature is unconfigured: a dark feature is a configuration fact, not an outage — blocking the ship is the pre-merge gate’s job.

#568 — TRYON_MOCK_MODE was absent from tryvio-dev. getServerEnv() parses an absent value as false, so every try-on generated on dev — widget QA, proof harnesses, E2E — was a real, paid kie/fal call. Mocking stopped 2026-07-23; measured from tryon_provider_jobs, 216 real provider generations ran between 2026-07-16 and 2026-09-09 12:01 UTC, recorded cost $5.19 with 14 of those rows carrying no cost_usd at all — a floor, not a total. env-drift could never have reported it: dev-only meant only “must not be present on prod”.

Fix: a declaration, not a special case. ENV_DEV_ONLY_REQUIRED_ON in lib/env-registry.ts states, per dev-only key, which non-prod targets must actually carry it — today TRYON_MOCK_MODE: ["dev", "staging"], SHOPIFY_BILLING_TEST: ["dev", "staging"]. Every dev-only key needs an entry ([] is legitimate, a missing entry is not — UNDECLARED, exit 1, caught offline by env-registry.test.ts too), so a new dev-only switch cannot enter the registry without someone stating where it belongs.

Verdicts env-drift now produces for a dev-only key:

SituationVerdictExit
Present on prodLEAKED1
Absent on a target it’s required on, no flipOFF1
Same, but an unexpired flip declares itFLIPPED (prints return date + days left)0
Flip past its untilOFF (names the expiry and the owner)1
Flip’s until is more than 14 days out— (an expiry that never arrives is not one)1
Malformed flip entry— (never silently ignored)1
Key is back while the flip still stands— (plus a delete-this-line note)0

The flip file, scripts/agents/env-flips.json, holds deliberate, time-boxed exceptions: { "key", "target", "until", "why", "by" }, until is YYYY-MM-DD and at most 14 days out. It lives in the repo rather than in an env var because env-drift reads env names only — values never leave Vercel — so the gate cannot see whether a switch is true or false, only whether it exists. Workflow: add the entry before removing the key from Vercel; restore with node scripts/agents/env-set.mjs --key=<KEY> --target=dev; then delete the entry.

.github/workflows/development.yml gained a post-deploy node scripts/agents/env-drift.mjs --target=dev step, the same shape as production.yml’s prod one — it warns loudly and exits 0 when the VERCEL_TOKEN secret is absent, never silently passing. Before this, nothing anywhere read the dev project’s configuration, which is why the gap survived eight weeks. Both TRYON_MOCK_MODE and SHOPIFY_BILLING_TEST have ENV_ORIGIN: manual (the literal true, dev and staging only), so restoring one is env-set.mjs, not the Vercel dashboard.

Regression harness: node scripts/agents/env-drift-selftest.mjs (offline, 20 checks, registered gate: true in scripts/agents/_selftests.mjs).

Shopify

VarNotes
SHOPIFY_CLIENT_ID / SHOPIFY_API_KEYApp client id (e0ee11a21417810ac0b21a7cf6898855)
SHOPIFY_CLIENT_SECRETApp secret
SHOPIFY_APP_URLhttps://app.tryvio.ai (used for callbacks, demo garment URLs)
SHOPIFY_SCOPESread_products,read_themes,read_customer_events,read_pixels,write_pixels,read_orders,write_discounts
SHOPIFY_API_VERSIONAdmin API version (2026-01)
SHOPIFY_STORE_DOMAINConfigured/default shop
SHOPIFY_*_CALLBACK_URL, SHOPIFY_WEBHOOK_*_URLCallback/webhook URLs
SHOPIFY_APP_AUTOMATION_TOKENAutomation token

Supabase

VarNotes
SUPABASE_URLProject URL
SUPABASE_ANON_KEYPublic anon key
SUPABASE_SERVICE_ROLE_KEYServer-only admin key (never expose)
TRYVIO_STORAGE_INPUT_BUCKET / OUTPUT / MERCHANT_BUCKETStorage buckets

AI (KIE)

KIE_API_KEY, KIE_API_BASE_URL, KIE_UPLOAD_BASE_URL, KIE_MODEL, KIE_TIMEOUT_MS, KIE_TASK_MAX_POLLS, KIE_TASK_POLL_INTERVAL_MS, KIE_ALLOW_MOCK_FALLBACK.

Cost & P&L

VarNotes
FAL_ADMIN_KEYAdmin-scope fal key (format id:secret) for the real fal usage/billing API (GET /v1/models/usage). Server-only, read-only use. Absent → the admin Costs & P&L tab falls back to the per-job cost estimate instead of the authoritative fal spend
VERCEL_BILLING_TOKENVercel REST API token (vercel.com/account/tokens), used for live infra spend on the Costs & P&L tab
VERCEL_TEAM_IDteam_... id that scopes the Vercel /v1/billing/charges query

See Cost & P&L for how these are used.

App / misc

VarNotes
APP_ENVEnvironment marker
TOKEN_ENCRYPTION_KEYEncrypts Shopify access tokens at rest
ADMIN_AUTH_EMAILAdmin/operator login
TRYVIO_TEMP_FILE_MAX_AGE_HOURSStorage cleanup window
TRYVIO_CATALOG_PROVIDER, TRYVIO_*_ROOTCatalog/storage roots
NEXT_PUBLIC_APP_NAMEPublic app name
NEXT_PUBLIC_GA4_MEASUREMENT_ID#727 — tryvio.ai’s GA4 web-stream ID (G-…). Public, build-time (redeploy after setting). required since 2026-09-30 (the owner created the property); also set on dev/staging only to keep env-drift green — those hosts never load it. Unset = no cookie banner, no tags. Only tryvio.ai/www.tryvio.ai ever load it (Site analytics)
NEXT_PUBLIC_META_PIXEL_ID#727 — the Tryvio Meta pixel ID. waived until the owner creates the pixel; same host rule as the GA4 ID
VERCEL_TOKENCI/deploy token
SHOPIFY_BILLING_TESTWhen true, creates test charges (off in prod)
TRYVIO_DEMO_SHOP_DOMAINDemo store domain (default teststoretryon.myshopify.com)
OUTREACH_LINK_SECRETHMAC signing secret shared by #270’s outreach link tokens (lib/outreach/link-token.ts) and the ttn_attr install-attribution cookie (see Install Attribution)
⚠️

SHOPIFY_BILLING_TEST must be unset/false in production, otherwise subscriptions are test charges. Production also relies on NODE_ENV=production.