Environment Variables — Inventory & Classification
Status: Frozen audit. Source of truth for issue #168 Phase A.
Last verified: 2026-05-22 against main (commits up to 656f8b5).
Companion doc: NAMING.md — target naming convention (Phase B).
This document inventories every environment variable read at runtime, classifies each as public or secret, and notes the consumer. It does not propose renames — those belong to Phase F (per-domain shimmed renames).
1. Classification legend
Section titled “1. Classification legend”| Class | Meaning | Storage rule |
|---|---|---|
| 🔒 secret | Credential, bearer, token, or password. Leaking it grants attacker access to a third-party service or our own users. | Never in /api/config, never in client bundles, never in logs, never committed. |
| 🪪 identifier | Non-secret ID (database UUID, OAuth client ID, email address). Knowing it does not authorize anything on its own. | Acceptable in /api/config if a client genuinely needs it. Default to not exposing. |
| 🌐 public config | Hostnames, ports, URLs, feature flags. Safe in client bundles. | VITE_* prefix required if the dashboard needs it at build time. |
| ⚙️ infra | Set by the runtime (Bun, OS, container orchestrator). Not authored by us. | n/a |
2. Server-side inventory (apps/api)
Section titled “2. Server-side inventory (apps/api)”2.1 Required at boot
Section titled “2.1 Required at boot”Defined as required in apps/api/core/env/parseEnvSchema.ts. Boot fails fast with a one-line error listing missing keys.
| Variable | Class | Consumer | Notes |
|---|---|---|---|
DATABASE_URL | 🔒 | apps/api/lib/drizzle/db.ts:4 | The only DB var actually read at runtime. |
DATABASE_HOST | 🔒 | (schema-required, unused) | Dead requirement. Drop in Phase F #3. |
DATABASE_USER | 🔒 | (schema-required, unused) | Dead requirement. Drop in Phase F #3. |
DATABASE_PASSWORD | 🔒 | (schema-required, unused) | Dead requirement. Drop in Phase F #3. |
DATABASE_NAME | 🔒 | (schema-required, unused) | Dead requirement. Drop in Phase F #3. |
DATABASE_PORT | 🔒 | (schema-required, unused) | Dead requirement. Drop in Phase F #3. |
BETTER_AUTH_SECRET | 🔒 | better-auth | Session signing key. |
AUTH_SECRET | 🔒 | (schema-required, unused) | Dead requirement; documented as “optional duplicate” in the schema but is in fact z.string() with no default. Collapse in Phase F #2. |
⚠️ Hostile boot: the schema currently requires five
DATABASE_*fields andAUTH_SECRETthat no code reads. A fresh clone following.env.example(which only documentsDATABASE_URLandBETTER_AUTH_SECRET) fails at boot. Phase C will tighten the schema; Phase E will regenerate.env.examplefrom it so they cannot drift again.
2.2 Optional with defaults
Section titled “2.2 Optional with defaults”| Variable | Class | Consumer | Default |
|---|---|---|---|
NODE_ENV | ⚙️ | parseEnvSchema.ts:14 | development |
APP_ENV | 🌐 | apps/api/lib/drizzle/db.ts:6-8, schema | development |
API_PORT | 🌐 | apps/api/index.ts | 3000 |
API_URL | 🌐 | schema; apps/api/mcp/buildMcp.ts (share + asset URLs), apps/api/work/buildWork.ts (tracked link in a drafted first comment — unset ⇒ the raw URL) | http://localhost:3000 |
CLIENT_URLS | 🌐 | CORS allowlist | http://localhost:5000,http://localhost:4321 |
BETTER_AUTH_URL | 🌐 | better-auth | http://localhost:3000 |
DASHBOARD_URL | 🌐 | apps/api/userSettings/routes/settings.routes.ts, apps/cli/lib/env.ts (viite record) | http://localhost:5000 |
DASHBOARD_PORT | 🌐 | schema | 5000 |
DATABASE_MIGRATING | 🌐 | drizzle | false |
DATABASE_SEEDING | 🌐 | drizzle | false |
CHROME_CDP_ENDPOINT | 🌐 | platform-ingestion CDP scraper | http://localhost:9222 |
2.3 Optional secrets / integrations
Section titled “2.3 Optional secrets / integrations”Empty string is the schema default; consuming subsystems disable themselves when unset and log a [subsystem] X not set — Y unavailable warning at boot. Behavior intentionally fail-soft.
| Variable | Class | Consumer | Disabled-when-empty behavior |
|---|---|---|---|
CLINE_API_KEY | 🔒 | apps/api/index.ts:122, ClineAdapter | Falls through to next LLM provider. |
ANTHROPIC_API_KEY | 🔒 | apps/api/index.ts:123, AnthropicAdapter | Falls through to next LLM provider. |
MANAGED_LLM_PROVIDER | 🌐 | apps/api/llm/managedLlm.ts | No managed LLM — a user with no llm:// channel gets no model, as before. |
MANAGED_LLM_MODEL | 🌐 | apps/api/llm/managedLlm.ts | Same as above; all three are required together. |
MANAGED_LLM_API_KEY | 🔒 | apps/api/llm/managedLlm.ts → ManagedLlmConnectorRepository | Same as above. The platform’s own key, lent to users who have connected none and charged to their credit balance — see the managed LLM. Also inert unless CREDITS_METERING_ENABLED=true. |
LLM_EXTRACT_URLS_MODEL | 🌐 | apps/api/index.ts:125 | Provider default used. |
LLM_ANALYZE_CONTENT_MODEL | 🌐 | apps/api/index.ts:126 | Provider default used. |
NOTION_INTEGRATION_TOKEN | 🔒 | declared in the env schema only | Unused at runtime — Notion credentials now come from a connected notion:// channel (/channels/new), not this env var. |
NOTION_DATABASE_ID | 🪪 | declared in the env schema only | Unused at runtime — was the veille preset’s input default (both are gone); a Notion database id is now supplied per-channel or typed directly into the dashboard’s “Build custom flow” dispatch step. |
YOUTUBE_DATA_API_KEY | 🔒 | nothing | Not read at runtime — setting it has no effect. The youtube_to_tasks step resolves its Data API key per run from the owner’s connected youtube:// channel, the same way notion:// works. Paste the key into /channels, not here. See YouTube channel videos. |
GMAIL_CLIENT_ID | — | — | Retired (Wave C). Gmail/Drive/Calendar credentials moved onto the owner’s gmail:// connector row. Still declared in docker-compose.coolify.yml, but nothing reads it. |
GMAIL_CLIENT_SECRET | — | — | Retired (Wave C). See above. |
GMAIL_REFRESH_TOKEN | — | — | Retired (Wave C). The token now lives on the gmail:// connector and is minted by the self-serve “Reconnect Gmail” flow (GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET / GMAIL_OAUTH_REDIRECT_URI below), not by get-gmail-token.ts. It still needs both gmail.modify and calendar.readonly — the same connector backs read_gmail, tag, and calendar_to_tasks. |
GMAIL_SERVICE_ACCOUNT_PATH | — | — | Retired (Wave C). Service-account auth is gone; the connector row is the only Gmail credential path. |
GMAIL_USER_EMAIL | 🪪 | read-filter / CLI identity (/api/config) | Some Gmail filters degrade. Not a credential — this is why it survived Wave C. |
GOOGLE_CLIENT_ID | 🪪 | /api/connect/gmail/* — the self-serve Gmail OAuth app. Gmail connector only — not sign-in (see the caution below). | Reconnect-Gmail flow dormant; a gmail:// connector can’t be minted through the UI. Public. |
GOOGLE_CLIENT_SECRET | 🔒 | /api/connect/gmail/* | Reconnect-Gmail flow dormant. |
GOOGLE_AUTH_CLIENT_ID | 🪪 | “Continue with Google” sign-in / sign-up on the dashboard (better-auth socialProviders.google). A separate OAuth client from GOOGLE_CLIENT_ID. | The Google sign-in button is absent (GET /api/auth-providers reports google: false). Public. |
GOOGLE_AUTH_CLIENT_SECRET | 🔒 | Same sign-in client as above. | Sign-in dormant. Setting only one of the pair is treated as unset, with a warning at boot. |
GMAIL_OAUTH_REDIRECT_URI | 🌐 | Gmail OAuth callback | Defaults to empty; must byte-for-byte match a redirect URI registered on the Google OAuth client, e.g. https://platform-api.viite.ai/api/connect/gmail/callback (prod) or http://localhost:3000/api/connect/gmail/callback (local). |
AUTH_PASSKEY_RP_ID | 🌐 | WebAuthn Relying Party ID for the passkey plugin (auth/adapters/better-auth/passkeyConfig.ts). | Defaults to DASHBOARD_URL’s hostname. Set it to viite.ai in production — see the caution below. |
HEARTBEAT_PING_URL | 🔒 | The liveness beat’s dead-man’s-switch target (apps/api/alerts/livenessBeat.ts, wired in apps/api/index.ts). A bearer URL: whoever holds it can fake a heartbeat, or append /fail to page the operator — so never /api/config, never a client bundle. See uptime monitoring. | No ping; the beat is otherwise unchanged. Example value: https://hc-ping.com/<uuid>. |
MCP_ALLOWED_REDIRECT_HOSTS | 🌐 | Hosts an MCP OAuth client may use as its redirect_uri (auth/mcpClientPolicy.ts). Comma-separated hostnames; a leading dot matches subdomains (.claude.ai). | Defaults to claude.ai,claude.com. Loopback is additionally accepted outside production. See the caution below. |
| Variable | Class | Consumer | Disabled-when-empty behavior |
|---|---|---|---|
MY_EMAIL_ADDRESS | 🪪 | legacy alias for GMAIL_USER_EMAIL (older CLI installs) | Some flows degrade. Safe to drop once every CLI reads GMAIL_USER_EMAIL. |
TWITTER_BEARER_TOKEN | 🔒 | Twitter integration (unwired today) | Unused at runtime. |
SOCIAL_TOKEN_ENCRYPTION_KEY | 🔒 | apps/api/social/buildSocialOAuth.ts (AES key, sha256-derived) | Gates ALL social token storage. Unset → social connect/publish dormant for every platform. Any strong random string (openssl rand -base64 32). |
YOUTUBE_CLIENT_ID | 🪪 | YouTube upload OAuth (YouTubeOAuthClient) | YouTube connect + video upload unavailable. Public. Lives in its own Google Cloud project, viite-youtube-upload — see the caution below. |
YOUTUBE_CLIENT_SECRET | 🔒 | YouTube upload OAuth | YouTube connect unavailable. |
YOUTUBE_OAUTH_REDIRECT_URI | 🌐 | YouTube OAuth callback | Defaults to https://platform-api.viite.ai/api/connect/youtube/callback; must byte-match a registered redirect URI. |
YOUTUBE_UPLOAD_TRANSPORT | 🪪 | buildYouTubeUploads.ts | direct (default, empty = direct) sends video bytes browser → Google; relay routes them one bounded chunk at a time through the API. Only flip to relay if Google stops exposing the Range header to cross-origin JS — the browser reads the value from the mint response, so no dashboard rebuild is needed. |
| Variable | Class | Consumer | Disabled-when-empty behavior |
|---|---|---|---|
X_CLIENT_ID | 🪪 | X OAuth (XOAuthClient) | X connect unavailable. Public; compose default is the prod app id. |
X_CLIENT_SECRET | 🔒 | X OAuth | X connect unavailable. |
X_OAUTH_REDIRECT_URI | 🌐 | X OAuth callback | Defaults to the prod callback; must byte-match a registered redirect URL. |
LINKEDIN_CLIENT_ID | 🪪 | LinkedIn OAuth (LinkedInOAuthClient) | LinkedIn connect unavailable. Public. LinkedIn publishing is LIVE (2026-06-27). |
LINKEDIN_CLIENT_SECRET | 🔒 | LinkedIn OAuth | LinkedIn connect unavailable. |
LINKEDIN_OAUTH_REDIRECT_URI | 🌐 | LinkedIn OAuth callback | Defaults to https://platform-api.viite.ai/api/connect/linkedin/callback; must byte-match a registered redirect URL. |
REDDIT_CLIENT_ID | 🪪 | Reddit OAuth (RedditOAuthClient) | Reddit connect + publishing unavailable. Public. ONE viite-owned app, registered at https://www.reddit.com/prefs/apps as type web app — OAuth2 is Reddit’s only write path, and every signed-in user authorizes against this one app to post to their own account (owner principal, like YouTube). |
REDDIT_CLIENT_SECRET | 🔒 | Reddit OAuth | Reddit connect unavailable. The token endpoint authenticates with HTTP Basic, so this is required even with PKCE. |
REDDIT_OAUTH_REDIRECT_URI | 🌐 | Reddit OAuth callback | Defaults to https://platform-api.viite.ai/api/connect/reddit/callback; must byte-match a redirect URI registered on the Reddit app. |
INSTAGRAM_CLIENT_ID | 🪪 | Instagram OAuth (InstagramOAuthClient) | Instagram connect + publishing unavailable. Public. The Meta App ID — may be the same Meta app as FACEBOOK_CLIENT_ID, kept as its own key so Instagram can be dormant while Facebook is live (the scopes differ, so one shared app needs both sets approved). |
INSTAGRAM_CLIENT_SECRET | 🔒 | Instagram OAuth | Instagram connect unavailable. |
INSTAGRAM_OAUTH_REDIRECT_URI | 🌐 | Instagram OAuth callback | Defaults to https://platform-api.viite.ai/api/connect/instagram/callback; must byte-match a redirect URL registered on the Meta app. |
INSTAGRAM_LOGIN_CONFIG_ID | 🌐 | Instagram OAuth authorize | Optional. Facebook Login for Business configuration id; when set the authorize call sends config_id and no scope. The configuration then owns the permission list and Meta exposes its contents through no Graph endpoint — GET /api/connect/instagram/introspect is the only way to see what a token actually got. |
RESEND_API_KEY | 🔒 | apps/api/messaging/adapters/resend/ResendEmailSenderAdapter.ts:7,11 | Email sending disabled. |
KIE_API_KEY | 🔒 | buildWork.ts → createKieTtsAdapter (the tts step behind TTS cards) | Speech generation unavailable. The step stays registered and fails one card at a time with “KIE_API_KEY is not configured” — an unregistered step would fail the whole job with the far less useful “No executor registered”. Get a key at https://kie.ai/api-key. |
KIE_USD_PER_CREDIT | 🌐 | buildWork.ts → TtsNode.priceRun (via usdPerKieCredit) | USD value of one kie.ai credit — what a speech run’s creditsConsumed is priced from before the ×1.2 markup. Default 0.005 (200 credits/$1). A var rather than a constant because the published rate is not authoritative: compare a real llm_usage debit against the kie.ai billing dashboard and correct it here, no deploy needed. Declared in docker-compose.coolify.yml, since a Coolify value never reaches the container without an environment: entry. |
PRODUCTHUNT_TOKEN | 🔒 | buildWork.ts → ProductHuntReader (the best_of_tech step) | The Product Hunt section of the daily digest renders “not configured” and the other two boards still ship — a missing token never fails the run. One app-level developer token (every caller reads the same public leaderboard), which is why it is an env var and not a producthunt:// connector. Create one under “API Dashboard” at https://www.producthunt.com/v2/oauth/applications. |
GITHUB_TOKEN | 🔒 | buildWork.ts → GitHubRepoReader (the best_of_tech step) | Nothing breaks — the step reads only public search results, so this buys only a higher rate limit (unauthenticated search allows 10 req/min; the step makes one call per run). Set it if a deployment runs many digests. |
ACCOUNTING_ENABLED | 🌐 | Feature flag (resolveFeatures) → /api/accounting + dashboard /accounting | Absent ⇒ feature OFF. Gates the incoming-documents surface (supplier invoices/receipts read from the owner’s Drive drop folder). Declared in docker-compose.coolify.yml with an off default, because a Coolify value never reaches the container without an environment: entry. Note env:coolify:sync --apply will try to revert a prod true back to that default; pin it in apps/api/.env first. |
STRIPE_SECRET_KEY | 🔒 | every Connect surface (offers, invoicing, connect) | The ONE Stripe key. The platform is not a merchant of record — nothing is charged on its own account — so this key only ever acts on connected accounts. Unset → those gateways build as null and their routes 400 (“not configured”) rather than crash-looping the boot. |
ENTITLEMENT_SELLER_ACCOUNT_ID | 🌐 | entitlements/config.ts | viite’s own Stripe connected account id (acct_…). A customer.subscription.* event from any other account grants nothing. Without this check, every org selling on the platform could mint Business Studio (pro) access for its buyers. Unset → no purchase can grant pro; every org just resolves to free, not a lockout. |
ENTITLEMENT_OFFER_SLUGS | 🌐 | entitlements/config.ts | Comma-separated offer slugs whose purchase grants access (e.g. business-studio-pro). Even on viite’s own account, an unlisted offer grants nothing — selling a €20/mo hosting offer must not hand the buyer a dashboard. Also drives requiresAccount on the public offer view (these slugs can’t be bought anonymously) and the plan list on /api/entitlements/gate. Promoting a tier is a config edit, not a migration. |
CREDIT_TOPUP_OFFER_SLUGS | 🌐 | credits/topupConfig.ts | Comma-separated offer slugs whose purchase mints AI credits (the 10,000 / 25,000 / 100,000-credit SKUs at €10 / €25 / €100 — €1 = 1,000 credits; the slugs name the euro price, not the credit count — seeded by scripts/lib/creditTopupPlan.ts — expected value credits-10,credits-25,credits-100). Reuses ENTITLEMENT_SELLER_ACCOUNT_ID rather than a second “who is viite” var. Also drives requiresAccount on the public offer view, same reasoning as ENTITLEMENT_OFFER_SLUGS — a top-up bought anonymously has no buyerUserId to credit, so offersPublic.routes.ts refuses it the same way. Empty ⇒ POST /api/credits/checkout 400s and gate.topupsAvailable is false; the dashboard Buy-credits buttons stay hidden. Declared in docker-compose.coolify.yml — a Coolify value never reaches the container without that environment: entry. |
STRIPE_CONNECT_WEBHOOK_SECRET | 🔒 | connect.webhooks.routes.ts (constructEventAsync) | Signing secret of the Connect-enabled endpoint that delivers account.updated — the only way Stripe reports that a connected account finished onboarding. Was STRIPE_TICKETING_CONNECT_WEBHOOK_SECRET, which named the vertical it happened to be mounted in; ticketing sells nothing now, but this event still gates whether an org can sell offers and issue invoices. Missing → Connect events are rejected; chargesEnabled still refreshes via the GET /api/connect/stripe/status live-sync. |
STRIPE_INVOICING_WEBHOOK_SECRET | 🔒 | invoicing.webhooks.routes.ts (constructEventAsync) | Signing secret for /api/invoicing/webhooks/stripe, distinct from the offers/connect webhook secrets. Missing → invoice.paid/invoice.voided events never reconcile onto our Invoice ledger (finalize still works locally without it, just without live status sync). Reuses STRIPE_SECRET_KEY. See Invoicing & Customers. |
STRIPE_OFFERS_WEBHOOK_SECRET | 🔒 | offers.webhooks.routes.ts (constructEventAsync) | Signing secret for /api/offers/webhooks/stripe — the Connect-enabled endpoint recording anonymous one-off offer purchases (checkout.session.completed / checkout.session.async_payment_succeeded fire on the org’s connected account, so a plain platform endpoint never receives them; locally use stripe listen --forward-connect-to). Missing → paid one-off checkouts are never recorded as Customer + Invoice and no receipt email is sent. Reuses STRIPE_SECRET_KEY. |
VIITE_AI_REBUILD_WEBHOOK_URL | 🔒 | buildOffers.ts → CoolifySiteRebuildTrigger | Coolify deploy endpoint for the viite.ai marketing site (…/api/v1/deploy?uuid=<app-uuid>). When an org edits its public offer catalog (an active offer created/updated/archived/deleted), the API POSTs this URL — debounced — to queue a viite.ai redeploy, so its pricing/funnel pages re-fetch offers via the SDK. Requires VIITE_AI_REBUILD_WEBHOOK_TOKEN too (the endpoint 401s without it). Either missing → the rebuild trigger is a silent no-op. |
VIITE_AI_REBUILD_WEBHOOK_TOKEN | 🔒 | buildOffers.ts → CoolifySiteRebuildTrigger | Bearer token for the deploy endpoint above — a Coolify API token, ideally deploy-scoped to limit blast radius (a full-access token in the API container widens exposure). Sent as Authorization: Bearer. Missing → the trigger no-ops (paired with the URL). |
2.4 Read but not in schema
Section titled “2.4 Read but not in schema”These are read directly via process.env (not validated):
| Variable | Class | Consumer |
|---|---|---|
PORT | 🌐 | apps/api/index.ts:1 (Bun listener; usually aligned with API_PORT) |
NOTIFICATION_EMAIL | 🪪 | apps/api/quotes/resolveAdminEmails.ts (resolveNotificationEmail) — RECIPIENT of the per-submission quote notification (quotes.routes.ts). For the quote-request email digest it’s the fallback recipient behind the creating user’s app:notifications.defaultEmail setting (${setting:notificationEmail}); with SMTP_FROM as the final fallback. Needs RESEND_API_KEY to actually send. (Renamed from ADMIN_EMAIL; authorization split out to ADMIN_EMAILS.) |
ADMIN_EMAILS | 🪪 | apps/api/quotes/resolveAdminEmails.ts (resolveAdminEmails) — comma-separated AUTHORIZATION allowlist for admin-only routes: quotes bulk-delete + /api/workflows/queue-stats (via requireAdmin, case-insensitive). Also the RECIPIENTS of the new-signup email (auth/adapters/better-auth/notifyAdminsOfSignup.ts, every address) and the owner of the signup-tagged card it files (the first address with an account, via resolveAdminOwner) — that card is what surfaces a signup in “Needs my attention”. An admin’s own signup notifies nobody. It does NOT route ORG events: a member joining an org notifies that org’s owners/admins from its member rows (notifyOrgAdminsOfJoin.ts), with no env var. Falls back to [NOTIFICATION_EMAIL] when unset. |
SMTP_FROM | 🪪 | apps/api/messaging/resolveFromAddress.ts — the From address of every outbound email (welcome, email verification, password reset, quotes, campaigns, receipts). Unset, ResendEmailSenderAdapter falls back to Resend’s shared sandbox sender, which delivers ONLY to the Resend account’s own verified address and silently drops the rest while still reporting success — so an unset value reads as “email is broken for every real user”. Must be on a Resend-verified domain. Also still the final fallback in the NOTIFICATION_EMAIL recipient chain. |
SUPPORT_DEFAULT_OWNER_EMAIL | 🪪 | apps/api/support/buildSupport.ts — the inbox a “Contact us” ticket is OWNED by (resolved to that user + their org, like SALES_DEFAULT_OWNER_EMAIL). Tasks are owner-scoped, so a ticket must borrow an owner; the requester is identified by a support:<email> tag instead. Optional — falls back to ADMIN_EMAILS[0], so a single-operator deployment needs nothing. Set it only to route tickets away from the admin. |
UPLOAD_DIR | 🌐 | apps/api/quotes/adapters/LocalFileStorageAdapter.ts:2 |
Phase I task: route these through the validated loader. Today they silently default and there is no audit of who reads them.
3. Client-side inventory (apps/dashboard)
Section titled “3. Client-side inventory (apps/dashboard)”3.1 Bundled into the client (Vite-injected, build-time)
Section titled “3.1 Bundled into the client (Vite-injected, build-time)”🌐 Everything VITE_* is shipped to the browser. Must be non-secret.
| Variable | Class | Consumer |
|---|---|---|
VITE_API_URL | 🌐 | apps/dashboard/src/lib/auth-client.ts:10, apps/dashboard/platformClient.ts |
3.2 Read at Vite dev/build time (not bundled)
Section titled “3.2 Read at Vite dev/build time (not bundled)”| Variable | Class | Consumer | Default |
|---|---|---|---|
DASHBOARD_PORT | 🌐 | apps/dashboard/vite.config.ts:10 | 5000 |
API_URL | 🌐 | apps/dashboard/vite.config.ts:11 (proxy target) | http://localhost:3000 |
API_PORT | 🌐 | apps/dashboard/vite.config.ts:11 | 3000 |
No dashboard env validator exists today. Phase C will add
apps/dashboard/src/lib/env.tsasserting requiredVITE_*keys at module load.
4. CLI inventory (apps/cli)
Section titled “4. CLI inventory (apps/cli)”| Variable | Class | Consumer |
|---|---|---|
PLATFORM_API_URL | 🌐 | apps/cli/lib/cli-context.ts (primary) |
API_URL | 🌐 | apps/cli/lib/cli-context.ts (fallback) |
API_PORT | 🌐 | apps/cli/lib/cli-context.ts (fallback) |
PLATFORM_TERMINAL, TERMINAL, TERM_PROGRAM | ⚙️ | apps/cli/commands/worktree/lib/term-launcher.ts (which terminal to open worktree dev servers in; OS-provided or developer-set) |
PATH | ⚙️ | apps/cli/commands/agent/recipes.ts (isCommandAvailable — whether a recipe’s tool is installed, looked up the way the shell would; OS-provided) |
Note:
apps/clialready usesPLATFORM_*prefix — supports the convention in NAMING.md.
5. Other apps and packages
Section titled “5. Other apps and packages”packages/platform-domain/— no env reads (pure domain).packages/platform-ingestion/— no env reads at runtime; configured viacreateIngestion({...}).packages/platform-sdk/— no env reads in runtime code;tests/test-config.tsreadsAPI_URL/API_PORTfor integration tests only.packages/platform-utils/— no env reads today. Phase C will add the sharedenv/module here.
6. ${env:...} resolution in step configs
Section titled “6. ${env:...} resolution in step configs”- Pattern: whole-string match
^\$\{env:([A-Z_][A-Z0-9_]*)\}$only. Mixed strings like"prefix-${env:X}"are not substituted. - Scope: any key resolvable by the
EnvResolvercallback. Today that resolver is backed byEnvConfigProvider.get()which can return any value in the parsed schema. - Used by: nothing authored today — the last consumer (the
veillepreset’s"databaseId": "${env:NOTION_DATABASE_ID}") was removed in favour of the workflow canvas, where per-node values are typed directly rather than defaulted from env, and the preset registry itself has since been deleted. - Risk: the shipped workflow templates are code-defined, so the resolver scope is fine. Users author graphs on the canvas; if a node param is ever allowed to carry
${env:...}, restrict the resolver to an explicit allowlist first (open question carried into Phase G).
7. LLM provider — one default agent everywhere
Section titled “7. LLM provider — one default agent everywhere”buildEnrichStep (apps/api/llm/buildEnrichStep.ts) picks the LLM client by the
explicit LLM_PROVIDER env. The default is cline (a multi-provider
gateway) — the single agent shared by every AI feature: enrich, analyze, and the
social-post composer (the composer reuses the enrich provider’s analyzeContent
model rather than choosing its own). Pinning one provider keeps local dev and
Coolify on the same model instead of silently diverging.
LLM_PROVIDER | key env | model (extractUrls / analyzeContent / classifyItem) |
|---|---|---|
cline (default) | LLM_CLINE_API_KEY | anthropic/claude-haiku-4.5 (all three) |
anthropic | LLM_ANTHROPIC_API_KEY | claude-haiku-4-5 (all three) |
auto | — | first provider whose key is set, precedence Cline → Anthropic |
LLM_{EXTRACT_URLS,ANALYZE_CONTENT,CLASSIFY_ITEM}_MODEL override the per-task
model. With an explicit provider, a missing key is a fail-fast boot error
(clearer than auto’s silent fallthrough).
Local ↔ Coolify parity: set the SAME LLM_PROVIDER + LLM_CLINE_API_KEY
in apps/api/.env and in the Coolify env (docker-compose.coolify.yml passes
them through). The legacy ANTHROPIC_API_KEY / CLINE_API_KEY names still
resolve via readEnv but are slated for removal — prefer the LLM_* names.
Logged at boot: [jobs] enrich step using <provider> (extractUrls=…, analyzeContent=…, classifyItem=…) — informational, no secret material.
8. /api/config exposure
Section titled “8. /api/config exposure”Whitelist lives in apps/api/config/allowed.config.types.ts and is enforced at the route handler. Post-PR #171:
ALLOWED_CONFIG_KEYS = [ 'NOTION_DATABASE_ID', // 🪪 identifier 'GMAIL_USER_EMAIL', // 🪪 user-specific 'MY_EMAIL_ADDRESS', // 🪪 legacy alias for the above]GMAIL_CLIENT_ID used to be on this list; it left with Wave C, when Gmail credentials moved onto the gmail:// connector row. The file’s header comment lists the removed keys and explains the policy. Never add 🔒-class keys here.
9. CI surface (.github/workflows)
Section titled “9. CI surface (.github/workflows)”| Secret / Var | Type | Workflows | Blast radius |
|---|---|---|---|
HETZNER_SSH_PRIVATE_KEY | 🔒 | deploy-hetzner.yml | Root SSH on API server. Highest blast radius — Phase H should narrow to a deploy user with sudo docker scope. |
COOLIFY_WEBHOOK_URL | 🔒 | deploy-hetzner.yml | Deploy trigger. |
COOLIFY_API_TOKEN | 🔒 | deploy-hetzner.yml | Coolify API. Scope unknown — Phase H should audit. |
GITLEAKS_LICENSE | 🔒 | security.yml | Gitleaks Pro license. |
GITHUB_TOKEN | ⚙️ | every workflow | Auto-provisioned, repo-scoped. |
API_DOMAIN, API_SERVER_IP, DASHBOARD_DOMAIN | 🌐 (vars, not secrets) | deploy-hetzner.yml | Non-secret config. |
Notably absent: no
ANTHROPIC_API_KEYin CI. Production app secrets live on the Hetzner host (via Coolify), not in CI. (Railway is retired:RAILWAY_TOKENand theRAILWAY_API_*_SERVICE_IDsecrets are gone from repo settings along with the workflow that used them.) No npm secret either:NPM_PUBLIC_TOKENwas deleted on 2026-09-14, afterpublish-packages.ymlmoved to npm Trusted Publishing (OIDC,id-token: write). See secrets §4.2.
10. Hetzner / Coolify surface
Section titled “10. Hetzner / Coolify surface”docker-compose.coolify.yml references (all are Coolify-resolved placeholders, no plaintext):
DATABASE_URL,BETTER_AUTH_SECRET,BETTER_AUTH_URL,API_URL,CLIENT_URLS(plural — comma-separated trusted origins, read by CORS + auth),DASHBOARD_URL.- LLM (one default agent everywhere — see §7):
LLM_PROVIDER,LLM_ANTHROPIC_API_KEY,LLM_{EXTRACT_URLS,ANALYZE_CONTENT,CLASSIFY_ITEM}_MODEL. - Steps and nodes:
GOOGLE_CLIENT_*+GMAIL_OAUTH_REDIRECT_URI(the Gmail connect flow — the retiredGMAIL_CLIENT_ID/_SECRET/_REFRESH_TOKEN/_SERVICE_ACCOUNT_PATHentries are still declared here but unread),NOTION_*(export),CHROME_CDP_ENDPOINT,RESEND_API_KEY,NOTIFICATION_EMAIL,ADMIN_EMAILS,SMTP_FROM,SUPPORT_DEFAULT_OWNER_EMAIL,KIE_API_KEY(+KIE_USD_PER_CREDIT, which only prices what that key spends). - Sync local → Coolify with
bun run env:coolify:sync(dry-run;--applyto push). It pushes the shared/integration set only and never touches the environment-specific keys above. - Dashboard build arg:
VITE_API_URL=${API_URL}(build-time injection). - Hardcoded:
NODE_ENV=production,APP_ENV=production,PORT=3000.
infra/hetzner/terraform.tfvars holds the Hetzner API token, DB password, and app secrets in plaintext (gitignored). Phase G will decide a secrets backend (sops + age recommended); Phase H will document deploy + rollback.
Database topology + resilience
Section titled “Database topology + resilience”The Postgres database is not in docker-compose.coolify.yml — there is no postgres/db service, and the compose network is coolify (external: true). The API (network_mode: host) connects via DATABASE_URL to Postgres on a separate Hetzner cloud server at the private IP 10.0.1.20 (the cpx22). Adminer (db.viite.ai, ADMINER_DEFAULT_SERVER=10.0.1.20) targets the same box. env:coolify:sync deliberately skips DATABASE_URL (environment-specific — Coolify owns it).
This is a single point of failure: a fault on that node takes the whole API down while the rest of the stack stays up. On 2026-06-29 Hetzner cloud node 104806 failed (08:08 UTC) and platform-api went running:unhealthy → Traefik 503 (“no available server”), while the Coolify host, Traefik, Adminer, viite.ai, and developer.viite.ai all stayed healthy. The API self-heals once 10.0.1.20 is reachable again.
The outage was amplified by apps/api/server/health.ts running an unbounded pool.query('SELECT 1'): an unreachable DB hangs the health endpoint → the Docker healthcheck times out → Traefik pulls the entire API, even DB-independent routes. The health probe now races a 2s timeout and returns 200 {db:'down', status:'degraded'} so a DB blip degrades gracefully instead of blackholing the API.
Resilience backlog (durability before availability):
- Durability (do first): confirm off-node backups exist at all; automate
pg_dumporpgBackRest/wal-gPITR → an off-node store (Hetzner Storage Box / B2 / S3) with a tested restore runbook; move PGDATA from local NVMe to a Hetzner Volume (survives a node fault — re-attach to a fresh VM). - Blast-radius: the health-probe timeout (done) + pool
connectionTimeoutMillis/statement_timeoutso app routes fail fast rather than hang. - Availability (later): managed Postgres (mind cross-internet latency vs the current private-network DB) or a primary + replica with
pg_auto_failover.
11. Drift, gaps, and follow-ups
Section titled “11. Drift, gaps, and follow-ups”Items below are tracked here so Phase E (regen .env.example) and Phase F (renames) can close them.
| Item | Impact | Phase |
|---|---|---|
DATABASE_HOST/USER/PASSWORD/NAME/PORT required in schema but never read | Hostile boot for fresh clones | C (schema fix) + E (regen example) |
AUTH_SECRET required but unused; only BETTER_AUTH_SECRET consumed | Hostile boot | C + F #2 |
parseEnvSchema.ts does not validate APP_ENV as enum | Silently accepts typos like prouction | C |
| No dashboard env validator | Misconfig surfaces only at request time | C |
Direct process.env reads in db.ts, drizzle.config.ts, cli-context.ts, LocalFileStorageAdapter.ts | Bypasses validation; no central inventory | I |
${env:...} resolver scope unrestricted | Latent risk if user-authored node params ship | G (decision) |
Coolify-only env vars (set in UI, not in docker-compose.coolify.yml) | Unknown — needs operator export | H |
12. Provenance
Section titled “12. Provenance”- Source comment with full audit: issue #168 comment.
- Re-verified against
mainon 2026-05-22 (commits up to656f8b5). - Closed-loop fixes already on
main: PR #169 (stdout leak), PR #171 (/api/configleak).
