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:
- Every key the code reads is classified in
apps/web/src/lib/env-registry.tsasrequired/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. Notelib/env.tsis 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 fromprocess.env, so the gate scans all ofapps/web/src. Test-only keys (#388 —RUN_AC6,PROOF_346, theRING_*harness switches that blocked the 2026.35 freeze) are verified from the reads byscripts/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 claimstest-onlywhile 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 vialanding-gate.mjs(#398), so an unclassified key surfaces the day it is written, not on the cut. - 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:
| origin | meaning | example keys |
|---|---|---|
generate | We 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:hex64 | Same, 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 perTARGETso 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=prodREFUSES 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, everyrequiredkey the code reads that the target does not have yet — exactly whatenv-driftwould report red. - Manual values.
--store --key=Kreads 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);--rotateis the deliberate override. Ageneratekey is never silently regenerated — that would invalidate live sessions, links, or cron auth. --dry-rundescribes 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 viaTRYVIO_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
| Var | Why it must never be set on prod | Required on |
|---|---|---|
TRYON_MOCK_MODE | Every merchant try-on silently returns a mock | dev, staging |
SHOPIFY_BILLING_TEST | Every subscription becomes a test charge — zero revenue | dev, staging |
KLAVIYO_OAUTH_ALLOW_SHOP_PARAM | Accepts 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:
| Situation | Verdict | Exit |
|---|---|---|
| Present on prod | LEAKED | 1 |
| Absent on a target it’s required on, no flip | OFF | 1 |
| Same, but an unexpired flip declares it | FLIPPED (prints return date + days left) | 0 |
Flip past its until | OFF (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
| Var | Notes |
|---|---|
SHOPIFY_CLIENT_ID / SHOPIFY_API_KEY | App client id (e0ee11a21417810ac0b21a7cf6898855) |
SHOPIFY_CLIENT_SECRET | App secret |
SHOPIFY_APP_URL | https://app.tryvio.ai (used for callbacks, demo garment URLs) |
SHOPIFY_SCOPES | read_products,read_themes,read_customer_events,read_pixels,write_pixels,read_orders,write_discounts |
SHOPIFY_API_VERSION | Admin API version (2026-01) |
SHOPIFY_STORE_DOMAIN | Configured/default shop |
SHOPIFY_*_CALLBACK_URL, SHOPIFY_WEBHOOK_*_URL | Callback/webhook URLs |
SHOPIFY_APP_AUTOMATION_TOKEN | Automation token |
Supabase
| Var | Notes |
|---|---|
SUPABASE_URL | Project URL |
SUPABASE_ANON_KEY | Public anon key |
SUPABASE_SERVICE_ROLE_KEY | Server-only admin key (never expose) |
TRYVIO_STORAGE_INPUT_BUCKET / OUTPUT / MERCHANT_BUCKET | Storage 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
| Var | Notes |
|---|---|
FAL_ADMIN_KEY | Admin-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_TOKEN | Vercel REST API token (vercel.com/account/tokens), used for live infra spend on the Costs & P&L tab |
VERCEL_TEAM_ID | team_... id that scopes the Vercel /v1/billing/charges query |
See Cost & P&L for how these are used.
App / misc
| Var | Notes |
|---|---|
APP_ENV | Environment marker |
TOKEN_ENCRYPTION_KEY | Encrypts Shopify access tokens at rest |
ADMIN_AUTH_EMAIL | Admin/operator login |
TRYVIO_TEMP_FILE_MAX_AGE_HOURS | Storage cleanup window |
TRYVIO_CATALOG_PROVIDER, TRYVIO_*_ROOT | Catalog/storage roots |
NEXT_PUBLIC_APP_NAME | Public 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_TOKEN | CI/deploy token |
SHOPIFY_BILLING_TEST | When true, creates test charges (off in prod) |
TRYVIO_DEMO_SHOP_DOMAIN | Demo store domain (default teststoretryon.myshopify.com) |
OUTREACH_LINK_SECRET | HMAC 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.