Files
motovaultpro/.claude/skills/mvp-config-and-secrets/SKILL.md
T
Eric GullicksonandClaude Fable 5 c239bb9347
Deploy to Staging / Build Images (push) Successful in 5m32s
Deploy to Staging / Deploy to Staging (push) Successful in 44s
Deploy to Staging / Verify Staging (push) Successful in 5s
Deploy to Staging / Notify Staging Ready (push) Successful in 4s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
chore: replace AI skill library with 16 verified mvp-* skills
Removes the old planner/decision-critic/role-agents/domain-agents system
(step-injector scripts, stale scopes, sprint-era workflow) and replaces it
with 16 ground-truth-verified skills under .claude/skills/: change control,
debugging playbook, failure archaeology, architecture contract, domain
reference, OCR/Gemini pipeline, config and secrets, build and env, run and
operate, diagnostics (with tested scripts), validation and QA, docs and
writing, launch readiness, deploy-safety campaign, proof and analysis
toolkit, and research frontier. RULE 0/1/2, the temporal-contamination rule,
and the decision stress-test protocol are carried forward into the new
skills; the retired content remains in git history.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-09 20:21:42 -05:00

22 KiB

name, description
name description
mvp-config-and-secrets Load when working with MotoVaultPro configuration or secrets in any form - adding/changing an env var, YAML config field, Docker secret, feature flag, tier gate, vehicle limit, or Stripe price ID. Also load on these symptoms - backend crashes at startup with "Configuration file not found" or "Secrets loading failed", "Secret file not found" errors in logs, a secret file on disk is unexpectedly a DIRECTORY, RESEND_WEBHOOK_SECRET is not configured, Stripe checkout returns wrong tier, VITE_ env var change has no effect until rebuild, Google Maps key missing in frontend, 403 TIER_REQUIRED, or confusion about which env vars a container actually reads.

MotoVaultPro Configuration and Secrets Catalog

Authored 2026-07-07. All facts verified against repo code on that date. Where any doc contradicts this file or the code, code wins.

When to use / When NOT to use

Use this skill when you need to know where a configuration value lives, which env vars a service actually reads, what secrets exist and how they get onto servers, how feature tiers gate behavior, or how to add any of these.

Do NOT use this skill for:

  • Deploy mechanics, blue-green, rollback, .env generation during deploys -> mvp-run-and-operate
  • Getting a local environment running, missing-secret workarounds for local dev -> mvp-build-and-env
  • OCR/Gemini engine knobs in depth (WIF auth chain, engine selection semantics) -> mvp-ocr-gemini-pipeline
  • Why the config architecture is YAML-file based (design rationale) -> mvp-architecture-contract
  • Triage of a live failure whose cause is unknown -> mvp-debugging-playbook
  • Tier/subscription domain semantics (what tiers mean to the business) -> mvp-vehicle-domain-reference

1. Config architecture: YAML + file secrets; env vars are the EXCEPTION

The backend deliberately does NOT use env vars for most configuration. backend/src/core/config/config-loader.ts loads:

  1. A YAML config file from CONFIG_PATH (default /app/config/production.yml), validated by a Zod schema (configSchema). The file is the committed, non-sensitive config/app/production.yml, bind-mounted read-only by compose. Database host/port/name/user, Redis, Auth0 domain/audience, CORS, health probe timings, and performance settings all live HERE, not in env.
  2. File-based secrets from SECRETS_DIR (default /run/secrets), validated by secretsSchema. Each secret is one file, name matching the compose mount (no .txt inside the container).

The loader is a module-level singleton: export const appConfig = configLoader.load() runs at import time. Missing config file or a missing REQUIRED secret means the backend process throws before the server starts. It also self-injects two values into process.env after loading: RESEND_API_KEY always, RESEND_WEBHOOK_SECRET only if present (config-loader.ts lines 266-270).

Files (all committed):

  • config/app/production.yml - the live backend config (mounted to /app/config/production.yml)
  • config/app/production.yml.example - STALE: mentions minio and admin-postgres hosts and lacks most sections the Zod schema requires. Do not copy it as a template; copy production.yml itself.
  • config/app/ci.yml - no references found anywhere in the repo as of 2026-07-07 (candidate dead config)
  • config/shared/production.yml - mounted to /app/config/shared.yml by compose, but NO backend code reads it (dead mount as of 2026-07-07)

Backend env vars that actually exist (verified against all process.env reads in backend/src/, non-test)

Var Read at Default Notes
CONFIG_PATH core/config/config-loader.ts:155 /app/config/production.yml File must exist or startup throws
SECRETS_DIR core/config/config-loader.ts:156 /run/secrets
LOG_LEVEL core/logging/logger.ts:10 info (invalid values fall back with a console warning) Compose sets ${BACKEND_LOG_LEVEL:-debug}
NODE_ENV app.ts (single read, health payload) none Compose sets production
STRIPE_PRO_MONTHLY_PRICE_ID, STRIPE_PRO_YEARLY_PRICE_ID, STRIPE_ENTERPRISE_MONTHLY_PRICE_ID, STRIPE_ENTERPRISE_YEARLY_PRICE_ID features/subscriptions/domain/subscriptions.service.ts (plan-to-priceId map ~line 833 and reverse tier inference ~line 885) Sandbox price IDs baked into docker-compose.yml backend env Staging/prod override via .env generated from Gitea Actions variables. Wrong values = checkout works but tier inference breaks
OCR_SERVICE_URL features/ocr/external/ocr-client.ts:8 http://mvp-ocr:8000
MIGRATIONS_DIR _system/migrations/run-all.ts:38 /app/migrations set in backend/Dockerfile:58; source fallback ../../../migrations
TERMS_CONTENT_HASH features/terms-agreement/domain/terms-config.ts computed fallback Optional override
FROM_EMAIL features/notifications/domain/email.service.ts [email protected]
BACKUP_STORAGE_PATH features/backup/domain/backup.types.ts /app/data/backups
HOSTNAME features/backup/domain/backup-archive.service.ts mvp-backend Docker-provided
RESEND_API_KEY, RESEND_WEBHOOK_SECRET email service / features/email-ingestion/external/resend-inbound.client.ts:30 none NOT set from outside - config-loader injects them from secret files. See the webhook gap in section 2

DEAD env vars: DATABASE_HOST and REDIS_HOST are set on the backend service in docker-compose.yml but NO backend code reads them. Actual DB/Redis hosts come from the YAML config. Do not "fix" a connection problem by changing these; they do nothing. (Cleanup candidate.)

Frontend env vars (Vite - baked at BUILD time)

Vite inlines import.meta.env.VITE_* into the JS bundle during vite build. In this project the build happens inside the Docker image build, fed by compose build.args (docker-compose.yml frontend service) and, in CI, by --build-arg values from Gitea Actions variables (.gitea/workflows/staging.yaml lines 69-73). Consequence: changing a VITE_* value requires REBUILDING the frontend image. Setting it in .env or container environment after build does nothing.

Var Consumed at Compose build-arg default
VITE_AUTH0_DOMAIN / VITE_AUTH0_CLIENT_ID / VITE_AUTH0_AUDIENCE src/core/auth/Auth0Provider.tsx Real Auth0 tenant values
VITE_API_BASE_URL src/core/api/client.ts, src/features/auth/api/auth.api.ts /api
VITE_STRIPE_PUBLISHABLE_KEY src/features/subscription/pages/SubscriptionPage.tsx, mobile/SubscriptionMobileScreen.tsx none (empty = Stripe silently broken)
VITE_LOG_LEVEL src/utils/logger.ts (default info) not passed as a build arg - container builds always get info

import.meta.env.MODE === 'development' gates debug panels; always production in container builds.

EXCEPTION - runtime injection: the Google Maps API key and Map ID are NOT build-time. frontend/scripts/load-config.sh runs at container start (frontend/Dockerfile:81 CMD) and reads /run/secrets/google-maps-api-key and /run/secrets/google-maps-map-id (mounted from secrets/app/*.txt), writing them into /usr/share/nginx/html/config.js as window.CONFIG. So Maps keys rotate with a container restart, no rebuild. VITE_GOOGLE_MAPS_API_KEY in frontend/.env.example is for local npm run dev only.

OCR service env vars (ocr/app/config.py, all os.getenv)

Var Code default Compose override (docker-compose.yml mvp-ocr)
LOG_LEVEL info ${BACKEND_LOG_LEVEL:-debug}
HOST / PORT 0.0.0.0 / 8000 not set
OCR_PRIMARY_ENGINE paddleocr google_vision
OCR_FALLBACK_ENGINE none paddleocr
OCR_CONFIDENCE_THRESHOLD / OCR_FALLBACK_THRESHOLD 0.6 / 0.6 0.6 / 0.6
GOOGLE_VISION_KEY_PATH /run/secrets/google-wif-config.json same
VISION_MONTHLY_LIMIT 1000 1000
VERTEX_AI_PROJECT "" motovaultpro
VERTEX_AI_LOCATION global global
GEMINI_MODEL gemini-2.5-flash gemini-3-flash-preview (compose wins in containers)
REDIS_HOST / REDIS_PORT / REDIS_DB mvp-redis / 6379 / 1 same (OCR uses Redis DB 1; backend uses DB 0 via YAML)

The compose defaults differ from code defaults - when reasoning about container behavior, read compose, not config.py. Engine semantics: see mvp-ocr-gemini-pipeline.

2. Secrets inventory (names and paths ONLY - never print values)

All app secrets live in secrets/app/ on the host and are bind-mounted read-only to /run/secrets/<name-without-.txt> in containers. Real .txt files are gitignored; only .example siblings and google-wif-config.json are committed (the WIF config is checked in by design - it contains no key material, it is a Workload Identity Federation descriptor).

Full set (matches scripts/inject-secrets.sh SECRET_FILES, 12 files): postgres-password.txt, auth0-client-secret.txt, auth0-management-client-id.txt, auth0-management-client-secret.txt, auth0-ocr-client-id.txt, auth0-ocr-client-secret.txt, google-maps-api-key.txt, google-maps-map-id.txt, cloudflare-dns-token.txt, resend-api-key.txt, stripe-secret-key.txt, stripe-webhook-secret.txt - plus committed google-wif-config.json.

Who mounts what (base docker-compose.yml; docker-compose.blue-green.yml mirrors via anchors):

  • backend: postgres-password, auth0-client-secret, auth0-management-client-id/secret, google-maps-api-key, google-maps-map-id, resend-api-key, stripe-secret-key, stripe-webhook-secret
  • frontend: google-maps-api-key, google-maps-map-id (runtime injection, section 1)
  • ocr: auth0-ocr-client-id, auth0-ocr-client-secret, google-wif-config.json
  • traefik: cloudflare-dns-token (DNS-01 cert challenge)
  • postgres: postgres-password (POSTGRES_PASSWORD_FILE)

The backend config-loader loadSecrets() reads exactly these 9 names: postgres-password, auth0-client-secret, auth0-management-client-id, auth0-management-client-secret, google-maps-api-key, resend-api-key, resend-webhook-secret, stripe-secret-key, stripe-webhook-secret. All required by the Zod schema except resend-webhook-secret (optional).

Anomalies to know (verified 2026-07-07):

  • auth0-ocr-client-id.txt / auth0-ocr-client-secret.txt are mounted into mvp-ocr and required by inject-secrets, but NO Python code reads them (only a docstring mentions Auth0 M2M). Mounted-but-unread; do not assume OCR auth is enforced by them.
  • cloudflare-dns-token.txt and google-maps-map-id.txt have no .example sibling.

Injection flow - editing secrets on servers is futile

On EVERY staging and production deploy, scripts/inject-secrets.sh regenerates ALL 12 secret files under $DEPLOY_PATH/secrets/app from Gitea Actions secrets (env vars POSTGRES_PASSWORD, AUTH0_CLIENT_SECRET, AUTH0_MANAGEMENT_CLIENT_ID, AUTH0_MANAGEMENT_CLIENT_SECRET, AUTH0_OCR_CLIENT_ID, AUTH0_OCR_CLIENT_SECRET, GOOGLE_MAPS_API_KEY, GOOGLE_MAPS_MAP_ID, CF_DNS_API_TOKEN, RESEND_API_KEY, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET). Any missing variable fails the deploy. Files are written chmod 644 inside a chmod 700 dir. google-wif-config.json is copied from the checkout by the workflows themselves (staging.yaml ~lines 123-125, production.yaml ~lines 115-117).

Therefore: to rotate or fix a secret, update the Gitea Actions secret (repo settings or mcp__gitea-mcp__upsert_repo_action_secret) and redeploy. Hand-editing /opt/motovaultpro/secrets/app/* survives only until the next deploy - and hand-editing server files is an owner non-negotiable violation anyway.

The missing-secret DIRECTORY trap

Docker bind-mounting a host file that does not exist silently creates an empty DIRECTORY at that path. Symptoms: backend logs Secret file not found or Failed to read secret file (EISDIR), then Secrets loading failed; ls -la secrets/app/ shows drwxr-xr-x entries ending in .txt. This is the current state of several secrets on the dev machine (as of 2026-07-07: auth0-ocr-client-id.txt, auth0-ocr-client-secret.txt, stripe-secret-key.txt, stripe-webhook-secret.txt are directories locally). inject-secrets.sh and both deploy workflows carry rm -rf cleanup for exactly this failure mode. Local fix: rm -rf secrets/app/<name>.txt then create the file with a real value (or, if you only need the stack to build, see mvp-build-and-env - do not invent fake Stripe keys and call billing "tested").

Known gap: resend-webhook-secret

resend-webhook-secret is in the config-loader's load list (optional in the schema) but: no secrets/app/resend-webhook-secret.txt exists, no compose file mounts it, inject-secrets.sh has no entry for it, and no Gitea secret feeds it. So RESEND_WEBHOOK_SECRET is never set anywhere, the backend logs Secret file not found: /run/secrets/resend-webhook-secret on every boot (harmless noise), and features/email-ingestion/external/resend-inbound.client.ts:39 throws RESEND_WEBHOOK_SECRET is not configured if webhook signature verification is exercised. Resend inbound webhook verification is presently UNCONFIGURED (known gap, 2026-07-07). Closing it = follow the "new secret" checklist in section 4 for resend-webhook-secret.txt plus a backend compose mount.

3. Feature flags / tiers as config

There is no external flag service. Tier gating is code-as-config in backend/src/core/config/feature-tiers.ts:

  • TIER_LEVELS: free(0) < pro(1) < enterprise(2), hierarchical (higher tier inherits lower).
  • FEATURE_TIERS registry - 4 entries as of 2026-07-07, all minTier: 'pro': document.scanMaintenanceSchedule, vehicle.vinDecode, fuelLog.receiptScan, maintenance.receiptScan. Each has name and upgradePrompt.
  • VEHICLE_LIMITS: free=2, pro=5, enterprise=null (unlimited), with canAddVehicle() / getVehicleLimitConfig().
  • FAIL-OPEN: canAccessFeature() returns TRUE for unknown feature keys (deliberate). A typo in a feature key silently un-gates the feature wherever canAccessFeature is called directly (e.g. features/documents/api/documents.controller.ts).

Two enforcement mechanisms, both returning 403 {error: 'TIER_REQUIRED', requiredTier, currentTier, featureName, upgradePrompt, ...}:

  1. Standalone middleware requireTier('<featureKey>') from core/middleware/require-tier.ts - must come AFTER requireAuth in the preHandler array; fails CLOSED (500) on unknown keys.
  2. Fastify decorator fastify.requireTier({minTier}|{featureKey}) from core/plugins/tier-guard.plugin.ts - calls authenticate itself; the featureKey path FAILS OPEN on unknown keys, exactly like direct canAccessFeature calls: canAccessFeature returns true for unregistered keys (tier-guard.plugin.ts:70), so a typo'd or unregistered featureKey silently un-gates the route for ALL tiers. The config?.minTier || 'pro' fallback (line 73) only shapes the 403 denial payload and is never reached for unknown keys. Only the standalone middleware (mechanism 1) fails closed on unknown keys.

Live examples: features/ocr/api/ocr.routes.ts lines 29/35/41 and features/vehicles/api/vehicles.routes.ts line 80.

Frontend contract: GET /api/config/feature-tiers (public, no auth - core/config/config.routes.ts) returns {tiers: TIER_LEVELS, features: FEATURE_TIERS}. Consumed by frontend/src/core/hooks/useTierAccess.ts (react-query key feature-tiers); denial UX via frontend/src/shared-minimal/components/UpgradeRequiredDialog.tsx; vehicle limits via frontend/src/features/vehicles/hooks/useVehicleLimitCheck.ts. The user's own tier comes from their profile (subscription_tier), not this endpoint.

4. How to add a configuration axis

Reminder: there is no working full local loop for most of these (missing local secrets). End-to-end proof happens on STAGING via the PR pipeline. CI runs zero tests/lint - the only gate is that images build and staging boots healthy - so run npm run type-check and relevant tests yourself in backend/ / frontend/ before pushing (see mvp-validation-and-qa).

A. New YAML config field (non-sensitive backend config)

  1. Add the field to configSchema in backend/src/core/config/config-loader.ts (give it .optional().default(...) unless you can update every environment's YAML in the same PR - a required field missing from the mounted YAML crashes the backend at import time).
  2. Add the value to config/app/production.yml (this one committed file serves all environments; there are no per-env YAML variants).
  3. No compose change needed - config/app/production.yml is already mounted. Consume via appConfig.config.<section>.<field>.
  4. Deploy note: config reaches servers via the workflows' rsync of config/; backend containers read it at start, so the normal deploy (which recreates backend) picks it up.

B. New secret

  1. Create secrets/app/<name>.txt locally with the real value AND a committed <name>.txt.example sibling with a placeholder.
  2. If the backend reads it: add the filename to the secretFiles array in loadSecrets() AND a snake_case key to secretsSchema in config-loader.ts (use .optional() only if genuinely optional - remember the resend-webhook-secret gap started life as "optional").
  3. Mount it in EVERY compose file that runs the consuming service: base docker-compose.yml and, for backend/frontend, the YAML-anchored blocks in docker-compose.blue-green.yml (prod runs blue/green services, not the base ones).
  4. Add the Gitea Actions secret (repo settings) and an inject_secret "VAR_NAME" "<name>.txt" entry plus SECRET_FILES entry in scripts/inject-secrets.sh - otherwise the next deploy will not create the file on the server and Docker will manufacture the directory trap.
  5. Verify on staging: deploy, then docker exec mvp-backend-staging ls -l /run/secrets/ (names only - never cat values into logs or PRs).

C. New env var

  1. Set it in the consuming service's environment: block in docker-compose.yml (and the anchored env in docker-compose.blue-green.yml for backend/frontend), with a sane ${VAR:-default}.
  2. Read it in exactly one place in the consumer (backend: prefer YAML config unless it is genuinely deploy-varying like the Stripe price IDs; ocr: ocr/app/config.py).
  3. If the value differs per environment, add it to the .env generation step in .gitea/workflows/staging.yaml and production.yaml (Gitea Actions variable) and document it in .env.example.
  4. Frontend VITE_*: add a compose build.args entry AND --build-arg lines in the staging workflow build step - and remember it is baked at build time.

D. New tier-gated feature

  1. Add the entry to FEATURE_TIERS in backend/src/core/config/feature-tiers.ts (key, minTier, name, upgradePrompt).
  2. Guard the route: preHandler: [requireAuth, requireTier('<key>')] or fastify.requireTier({featureKey: '<key>'}). Copy the exact key string - fail-open means a typo disables the gate silently. Add a test in backend/src/core/config/tests/feature-tiers.test.ts style.
  3. Frontend: gate the UI with useTierAccess and route denials to UpgradeRequiredDialog. The feature list arrives automatically via GET /api/config/feature-tiers - no frontend registry to update.
  4. Validate on BOTH mobile and desktop (project hard requirement).

E. New Stripe price

  1. Add the env var to the backend environment: block in docker-compose.yml AND the blue-green anchor, and to .env.example.
  2. Wire it into BOTH maps in backend/src/features/subscriptions/domain/subscriptions.service.ts: the plan-name-to-env-var map (~line 833) and the reverse priceId-to-tier inference (~line 885). Missing the reverse map means webhooks classify the subscription as the wrong tier.
  3. Add the Gitea Actions VARIABLE (not secret - price IDs are config) and echo it into .env in both deploy workflows' env-generation step.
  4. The publishable key side (VITE_STRIPE_PUBLISHABLE_KEY) is build-time frontend config - see C.4.

Provenance and maintenance

Authored 2026-07-07 from direct inspection of the repo at commit e729d42 era. Volatile facts and how to re-verify each (run from repo root):

Fact (as of 2026-07-07) Re-verify with
Backend env-var catalog (and DATABASE_HOST/REDIS_HOST still dead) `grep -rnoE "process.env(.[A-Z_]+
CONFIG_PATH / SECRETS_DIR defaults grep -n "CONFIG_PATH|SECRETS_DIR" backend/src/core/config/config-loader.ts
The 9 backend-loaded secret names and which are optional sed -n '113,190p' backend/src/core/config/config-loader.ts
resend-webhook-secret still unconfigured grep -rn "resend-webhook" docker-compose*.yml scripts/inject-secrets.sh; ls secrets/app/ | grep resend
inject-secrets file list (12) and required env vars grep -n "inject_secret |SECRET_FILES" scripts/inject-secrets.sh
Compose secret mounts per service grep -n "/run/secrets" docker-compose.yml docker-compose.blue-green.yml
Secrets committed to git (should be .example + google-wif-config.json only) git ls-files secrets/
Local directory-trap state ls -la secrets/app/ | grep ^d
FEATURE_TIERS entries and VEHICLE_LIMITS (free 2 / pro 5 / enterprise unlimited) grep -n "minTier|VEHICLE_LIMITS" -A3 backend/src/core/config/feature-tiers.ts
Fail-open on unknown feature keys sed -n '57,64p' backend/src/core/config/feature-tiers.ts
feature-tiers endpoint shape cat backend/src/core/config/config.routes.ts
requireTier usage sites grep -rn "requireTier" backend/src/features --include="*.ts" | grep -v test
Stripe price env vars in service maps grep -n "STRIPE_.*PRICE_ID" backend/src/features/subscriptions/domain/subscriptions.service.ts
Frontend VITE_* consumption grep -rnoE "import\.meta\.env\.[A-Z_]+" frontend/src | sort -u
Frontend runtime Maps-key injection cat frontend/scripts/load-config.sh; grep -n "load-config" frontend/Dockerfile
OCR env catalog and compose overrides cat ocr/app/config.py; sed -n '/mvp-ocr:/,/mvp-postgres:/p' docker-compose.yml
config/app/ci.yml and config/shared/production.yml still unreferenced/unread grep -rn "ci\.yml" backend/ scripts/ .gitea/ docker-compose*.yml Makefile; grep -rn "shared.yml" backend/src (grep's --include does NOT expand brace globs like *.{ts,sh} - it silently matches nothing)
production.yml.example still stale vs schema diff <(grep -oE "^[a-z_]+:" config/app/production.yml) <(grep -oE "^[a-z_]+:" config/app/production.yml.example)
Workflow .env generation and wif-config copy grep -n "generate-log-config|inject-secrets|google-wif" .gitea/workflows/staging.yaml .gitea/workflows/production.yaml
auth0-ocr secrets still mounted-but-unread grep -rni "auth0" ocr/app --include="*.py"