--- name: mvp-launch-readiness description: >- Load when assessing whether MotoVaultPro is ready to launch, take paying users, or make a public claim ("is this production ready", "can we ship", "launch checklist", "is billing working", "is this secure enough", "what is blocking launch", "can we say X is supported"). Also load before writing any user-facing statement about supported features, security posture, or reliability, and when triaging the known launch blockers - Stripe upgrade dead-end, Resend webhook secret, backup emails, fuel-log cache bug, Traefik/Grafana default credentials, stale AUDIT.md. --- # MVP Launch Readiness External positioning for a PRE-LAUNCH product heading toward paying users. This skill defines what must be PROVEN before claiming readiness or shipping to humans, and lists the launch blockers with evidence. Its purpose is to prevent claiming readiness by eye. ## When to use / When NOT to use Use this skill when: - Deciding whether a feature can be called "supported" in any user-facing or public claim. - Building or reviewing a launch checklist, readiness report, or go/no-go decision. - Triaging any of the launch-blocking gaps listed below. Do NOT use this skill for: - Fixing the CI gate hole itself — that is `mvp-deploy-safety-campaign` (the executable campaign). - How to deploy, roll back, or operate environments — `mvp-run-and-operate`. - What counts as test evidence and how to run suites — `mvp-validation-and-qa`. - Secrets/config mechanics (adding a secret, YAML config loader) — `mvp-config-and-secrets`. - Doc trust levels and doc-sync — `mvp-docs-and-writing`. - Issue/branch/PR mechanics for fixing any blocker — `mvp-change-control`. ## Verdict labels (use these exact words) | Label | Meaning | |---|---| | PROVEN | Verified against code/config on 2026-07-07, with a test or a staging-verified procedure behind it | | UNPROVEN | Implemented in code but never verified end-to-end; no test, no recorded staging run | | BROKEN | Verified defective: the code path exists and does the wrong thing (or nothing) | A claim without a label is not a claim. "It compiles" and "staging boots" are not evidence: CI gates nothing beyond build + boot (canonical statement: `mvp-validation-and-qa` section 1). ## 1. Launch-blocking gaps (each verified 2026-07-07 — re-verify before acting) | # | Gap | Verdict | Evidence | |---|---|---|---| | 1 | Upgrade path from tier-gated features dead-ends: the Upgrade button in `UpgradeRequiredDialog` just closes the dialog (`// TODO: Navigate to upgrade page`) | BROKEN | `frontend/src/shared-minimal/components/UpgradeRequiredDialog.tsx:55-58`. Nuance: a working checkout UI exists at route `/garage/settings/subscription` (`frontend/src/App.tsx:1175`), so users who find Settings can upgrade — but the upsell moment (hitting a gated feature) converts to nothing. Fix is a navigate call, not a Stripe build-out. | | 2 | Resend inbound email webhook secret is unconfigured in every environment: `resend-webhook-secret` is absent from `secrets/app/`, from the backend secret mounts in `docker-compose.yml:133-141`, and from the 12-file list in `scripts/inject-secrets.sh:30-43` | BROKEN (feature), not open (security) | `POST /api/webhooks/resend/inbound` is a PUBLIC endpoint (no JWT — `backend/src/features/email-ingestion/api/email-ingestion.routes.ts:13`). It fails CLOSED: `verifyWebhookSignature` throws when `RESEND_WEBHOOK_SECRET` is unset (`resend-inbound.client.ts:39`) and the handler returns 400 (`email-ingestion.controller.ts:141-146`). So no unsigned request is accepted — but email ingestion cannot work at all until the secret is provisioned end-to-end (secret file + compose mount + inject-secrets + Gitea secret). Config schema already treats it as optional (`backend/src/core/config/config-loader.ts:121,185`). | | 3 | Backup failure emails are never sent — the notification path checks settings and the admin email, then only logs "Backup notification would be sent" | BROKEN | `backend/src/features/backup/domain/backup.service.ts:441` (`TODO: Integrate with email service`). The settings UI implies emails that do not exist. A paying user's backup can fail silently. | | 4 | Fuel-log delete invalidates only the imperial cache: `deleteFuelLog` hardcodes `'imperial'` and `invalidateCaches` deletes only that unit-system's two keys | BROKEN | `backend/src/features/fuel-logs/domain/fuel-logs.service.ts:223` and `:266-271`. The inline comment "simple sweep below" is false — there is no sweep. Metric-unit users see deleted logs for up to the cache TTL. | | 5 | Traefik dashboard on prod has placeholder auth: basicauth hash is `admin:$2y$10$foobar` (not a valid bcrypt digest), while `api.insecure: true` with port 8080 published in the base compose | BROKEN (relies on firewall only) | `docker-compose.prod.yml:23`; `config/traefik/traefik.yml:1-4`. Generate a real htpasswd bcrypt hash before launch. | | 6 | Grafana admin password defaults to `admin` unless the `GRAFANA_ADMIN_PASSWORD` Gitea secret is set | UNPROVEN (mitigated) | `docker-compose.yml:350` (`${GRAFANA_ADMIN_PASSWORD:-admin}`). Mitigation exists: `grafana-ipwhitelist` middleware allows only RFC1918 source ranges (`config/traefik/dynamic/grafana.yml:3-5`), so it is not internet-reachable — but confirm the Gitea secret is actually set on staging and prod before launch. | | 7 | CI gates nothing: no tests, no lint, no security scan on any PR; PR builds clobber `:latest` and the prod deploy input defaults to `latest`, so prod can ship an unmerged PR build | BROKEN (process) | `.gitea/workflows/staging.yaml` (PR-triggered, pushes `:latest`), `.gitea/workflows/production.yaml` (`image_tag` default `latest`). Do not fix here — route to `mvp-deploy-safety-campaign`. Launch gate: prod deploys by SHA, and at least one real test job on PR. | | 8 | No account-deletion / data-retention policy document, despite the feature being live: a 30-day grace period is implemented (`backend/src/features/user-profile/jobs/account-purge.job.ts`) and mentioned once in `docs/USER-GUIDE.md:613` | UNPROVEN (code exists, policy does not) | Paying users require a stated retention policy. `docs/AUDIT.md` flagged this MEDIUM in 2025-12 (AUDIT.md:303); still no doc. Write the policy doc; verify the purge job's behavior matches it before publishing. | | 9 | `docs/AUDIT.md` (dated 2025-12-13, verdict "CONDITIONALLY READY") was never updated after remediation, so the real open findings are unknowable from the doc | BROKEN (as a record) | Several findings are demonstrably fixed in code (see section 3) while the doc presents them as open. Do a remediation-status pass: add a Status column (FIXED with file:line / OPEN / SUPERSEDED) so a reader can trust it. Until then, treat AUDIT.md as an archive, never as the current posture. | ## 2. Claim discipline Rule: nothing is "supported" until it has (a) a verified runbook in the repo and (b) a test or a staging-verified procedure. "The code exists" is UNPROVEN. When docs contradict code, code wins — say so in the claim. ### Billing claims specifically Do not claim billing works until the FULL cycle has been exercised on staging with Stripe test mode: checkout -> webhook -> tier sync -> downgrade. Current status, checked 2026-07-07: - Status: UNPROVEN. The subscriptions capsule (`backend/src/features/subscriptions/`) has ZERO test files (`find backend/src/features/subscriptions -name "*.test.ts"` returns nothing). Git history shows real-world debugging fixes on 2026-02-16 ("charge immediately on subscription", "attach payment method to customer" — commits `8a73352`, `72e5573`), which implies the flow was manually exercised around then, but there is no test and no recorded staging verification of the full cycle since. Honest label: implemented, manually poked in Feb 2026, never verified end-to-end as a procedure. How to verify it (the moving parts): 1. Price IDs: four env vars map plan -> Stripe price and back: `STRIPE_PRO_MONTHLY_PRICE_ID`, `STRIPE_PRO_YEARLY_PRICE_ID`, `STRIPE_ENTERPRISE_MONTHLY_PRICE_ID`, `STRIPE_ENTERPRISE_YEARLY_PRICE_ID`. Sandbox defaults are baked into `docker-compose.yml:124-127` and `.env.example:12-15`; staging/prod override via `.env` generated from Gitea variables. Forward mapping in `getPriceId`, reverse tier inference in `determineTierFromStripeSubscription` (`backend/src/features/subscriptions/domain/subscriptions.service.ts:831-900`). An unknown price ID silently maps to tier `free` — a misconfigured price ID downgrades paying users without error. 2. Secrets: `stripe-secret-key` and `stripe-webhook-secret` are file secrets read by `getStripeConfig()` (`backend/src/core/config/config-loader.ts:258-263`), mounted at `docker-compose.yml:140-141`, injected on deploy by `scripts/inject-secrets.sh`. 3. Endpoints: `POST /api/subscriptions/checkout` (authenticated, `subscriptions.routes.ts:28-29`) and `POST /api/webhooks/stripe` (PUBLIC, signature- verified, `webhooks.controller.ts:24`). Tier lands in `user_profiles.subscription_tier` via `syncTierToUserProfile` and gates features through `backend/src/core/config/feature-tiers.ts` (limits and key catalog canonical in `mvp-config-and-secrets` section 3; unknown feature keys FAIL OPEN). 4. Staging procedure: on staging with Stripe TEST-mode keys in the Gitea secrets, drive checkout from `/garage/settings/subscription` with card 4242 4242 4242 4242; confirm the webhook fired (`{container="mvp-backend-staging"} | json` in Grafana, and the `subscription_events` table); confirm `user_profiles.subscription_tier` flipped; then cancel/downgrade and confirm the tier reverts and the vehicle-selection flow triggers when over the free limit. The Stripe webhook endpoint for staging must be registered in the Stripe dashboard pointing at `https://staging.motovaultpro.com/api/webhooks/stripe` and its signing secret stored as the `STRIPE_WEBHOOK_SECRET` Gitea secret. Record the run (date, commit SHA, screenshots or log queries) in the issue that tracks the claim. Remember owner non-negotiables while doing this: no destructive DB operations without a fresh backup, and never hand-edit files on staging/prod servers (rsync --delete reverts them; all changes flow through the repo and workflows). ## 3. Security posture — the honest list (verified 2026-07-07) What IS solid (PROVEN in code): | Item | Evidence | |---|---| | Auth0 JWT validation: JWKS-based verification with issuer and audience pinning | `backend/src/core/plugins/auth.plugin.ts:105-108` (`allowedIss`, `allowedAud`) | | Network isolation: database network is `internal: true`; prod removes postgres/redis host ports | `docker-compose.yml:405-407`; `docker-compose.prod.yml` sets `ports: []` | | File-based secrets, never env, never committed (only `.example` siblings; `google-wif-config.json` is deliberately committed WIF config) | `backend/src/core/config/config-loader.ts:175-188`; `scripts/inject-secrets.sh` | | Upload magic-byte validation: content vs claimed MIME type checked before accepting documents | `backend/src/features/documents/api/documents.controller.ts:253-285` | | Storage keys from `crypto.randomBytes(32)` (the AUDIT.md CRITICAL, since fixed) | `backend/src/features/documents/api/documents.controller.ts:587` | | Stripe and Resend webhooks verify signatures and fail closed | `webhooks.controller.ts:36-46`; `resend-inbound.client.ts:37-52` | | Audit-log capsule and backend ESLint exist (both were AUDIT.md gaps) | `backend/src/features/audit-log/`; `backend/eslint.config.js` | Open or unknown (do not claim these): | Item | Status | Truth | |---|---|---| | Content-Security-Policy on the served frontend | OPEN | `frontend/nginx.conf` contains zero `add_header` directives — the SPA is served with no CSP. Backend registers `@fastify/helmet` with defaults (`backend/src/app.ts:49`), which covers API responses only. AUDIT.md finding still open. | | Google Maps API key exposure | OPEN (by design, unmitigated) | The key is injected at container startup into `window.CONFIG` served to every browser (`frontend/scripts/load-config.sh` -> `/usr/share/nginx/html/config.js`). Browser-side Maps keys are normal ONLY if referrer/API restrictions are set on the key in Google Cloud Console — that restriction is not verifiable from this repo. UNVERIFIED until checked in the console. AUDIT.md also flags key leakage via photo URLs. | | PostgreSQL connection encryption | RESOLVED CONTRADICTION — connections are UNENCRYPTED | `docs/ARCHITECTURE-OVERVIEW.md:511` claims "PostgreSQL SSL/TLS"; `docs/SECURITY.md:30` says unencrypted, network-isolated. Code wins: no `ssl` option anywhere in `backend/src/core/config/database.ts` or the `database:` block of `config/app/production.yml:12-18`. SECURITY.md is correct. Acceptable for a single-host internal-network deployment; never claim encrypted-in-transit DB. | | Tier guard fail-open | OPEN (deliberate, document it) | `canAccessFeature` returns true for unknown feature keys (`backend/src/core/config/feature-tiers.ts`). A typo in a feature key silently un-gates a paid feature. | | Admin guard typing in backup routes | OPEN | `backend/src/features/backup/api/backup.routes.ts` uses `(fastify as any).requireAdmin` 16 times — a typo compiles clean and would ship an unguarded admin endpoint. | ## 4. Reproducibility standard for launch Launch bar: a new machine must be able to stand up the product from the repo plus Gitea secrets alone. No snowflake state on any server. - Docs to trust: `docs/BUILD-SERVER-SETUP.md` (VPS + act_runner provisioning) and `docs/CICD-DEPLOY.md` (deploy pipeline — the most code-accurate infra doc). Known defects in both, verified 2026-07-07: - BUILD-SERVER-SETUP.md installs act_runner v0.2.13 (line 84) but its "Update Runner" section downloads v0.2.12 (line 247) — following the update procedure downgrades. - CICD-DEPLOY.md says runner labels `mvp-build`/`mvp-prod` (lines 34, 59-60); actual labels are `stage`/`prod` (`.gitea/workflows/staging.yaml:29`, `production.yaml:34`). - Ansible caveat (the prune-cron gap): the staging runner's daily Docker prune cron was installed out-of-band and is deliberately NOT in the repo; worse, `ansible/deploy-staging-runner.yml:317-324` REMOVES a legacy "Docker cleanup" cron. Rebuilding the staging box from ansible silently drops disk hygiene, and the 29G root disk then fills from per-commit image builds. This is the one standing exception to "everything flows through the repo" — a rebuilt box needs the prune cron reinstalled manually until it is codified. - Everything on-server is disposable by design: `scripts/inject-secrets.sh` regenerates all 12 secret files from Gitea Actions secrets on every deploy, and rsync `--delete` replaces `config/` and `scripts/`. If standing up a new box requires copying anything from an old box other than data volumes, that is a launch-blocking reproducibility bug. - Trap: bind-mounting a missing secret file makes Docker create a DIRECTORY at that path; a new machine hitting "secret is a directory" errors is this failure mode (canonical description and fix: `mvp-config-and-secrets` section 2). ## 5. Ranked pre-launch checklist Run every evidence command from the repo root. A row is done when the command output contradicts the gap, not when a PR merged. ### Blocking (no paying users until green) | Item | Evidence command | |---|---| | Wire UpgradeRequiredDialog to `/garage/settings/subscription` | `grep -n "TODO: Navigate" frontend/src/shared-minimal/components/UpgradeRequiredDialog.tsx` (must return nothing) | | Full billing cycle verified on staging, Stripe test mode, recorded in an issue | See section 2 procedure; `find backend/src/features/subscriptions -name "*.test.ts"` should also stop returning empty | | Real bcrypt hash on prod Traefik dashboard; kill `api.insecure` or unpublish 8080 | `grep -n "foobar" docker-compose.prod.yml` (nothing); `grep -n "insecure" config/traefik/traefik.yml` | | Backup failure emails actually send | `grep -n "would be sent" backend/src/features/backup/domain/backup.service.ts` (nothing) | | CI runs at least one real test job; prod deploys pinned by SHA (route to `mvp-deploy-safety-campaign`) | `grep -n "npm test\|jest\|pytest" .gitea/workflows/*.yaml` (currently nothing); `grep -n "default: 'latest'" .gitea/workflows/production.yaml` | | Account-deletion / data-retention policy doc exists and matches `account-purge.job.ts` behavior | `ls docs/ \| grep -i -E "retention\|deletion"` (currently nothing) | | GRAFANA_ADMIN_PASSWORD Gitea secret set for staging and prod | Check Gitea repo secrets; on server: `docker exec mvp-grafana env \| grep GF_SECURITY` must not show `admin` | ### Important (fix before public claims, not necessarily before first user) | Item | Evidence command | |---|---| | Fix fuel-log delete cache invalidation for both unit systems | `grep -n "'imperial'" backend/src/features/fuel-logs/domain/fuel-logs.service.ts` (line 223 hardcode gone) | | Provision `resend-webhook-secret` end-to-end or disable/hide email ingestion | `grep -n "resend-webhook-secret" scripts/inject-secrets.sh docker-compose.yml` (currently no hits in either) | | Remediation-status pass on `docs/AUDIT.md` | `grep -n "Status" docs/AUDIT.md` shows a per-finding FIXED/OPEN column | | CSP header on the frontend (nginx or Traefik middleware) | `grep -n "add_header" frontend/nginx.conf` or `grep -rn "Content-Security" config/traefik/` | | Verify Google Maps key has referrer restrictions | Google Cloud Console (not verifiable from repo — record the check in an issue) | | Type the backup routes' admin guard | `grep -c "as any).requireAdmin" backend/src/features/backup/api/backup.routes.ts` returns 0 | | Codify the staging-runner prune cron in ansible | `grep -n "prune" ansible/deploy-staging-runner.yml` shows an install task, not just removal of the legacy cron | ### Nice-to-have | Item | Evidence command | |---|---| | Tier guard fail-closed (or an explicit allowlist test) | `grep -n "fail open\|return true" backend/src/core/config/feature-tiers.ts` | | Fix BUILD-SERVER-SETUP.md v0.2.12/v0.2.13 and CICD-DEPLOY.md label drift | `grep -n "0.2.12" docs/BUILD-SERVER-SETUP.md; grep -n "mvp-build" docs/CICD-DEPLOY.md` | | Silent-failure TODOs: fuel-log delete error toast, stations pull-to-refresh no-op | `grep -rn "TODO" frontend/src/features/fuel-logs/components/FuelLogsList.tsx frontend/src/features/stations/mobile/StationsMobileScreen.tsx` | | PostgreSQL in-transit encryption (only if topology ever spans hosts) | `grep -n "ssl" backend/src/core/config/database.ts config/app/production.yml` | ## Provenance and maintenance Authored 2026-07-07 from direct repo inspection at commit `e729d42`-era main. Every file:line above was verified on that date. Volatile facts and their re-verification commands: - UpgradeRequiredDialog dead-end: `grep -n "TODO: Navigate" frontend/src/shared-minimal/components/UpgradeRequiredDialog.tsx` - Resend webhook secret absent from deploy path: `grep -n "resend-webhook-secret" scripts/inject-secrets.sh docker-compose.yml secrets/app/ -r` - Backup email log-only: `grep -n "would be sent" backend/src/features/backup/domain/backup.service.ts` - Fuel-log cache hardcode: `grep -n "invalidateCaches" backend/src/features/fuel-logs/domain/fuel-logs.service.ts` - Traefik placeholder auth: `grep -n "foobar" docker-compose.prod.yml && grep -n "insecure" config/traefik/traefik.yml` - Grafana default password: `grep -n "GRAFANA_ADMIN_PASSWORD" docker-compose.yml` - CI runs no tests: `grep -n "npm test\|jest\|pytest" .gitea/workflows/*.yaml` (expect no hits until fixed) - Prod deploy default tag: `grep -n "default: 'latest'" .gitea/workflows/production.yaml` - Subscriptions capsule untested: `find backend/src/features/subscriptions -name "*.test.ts"` - Stripe price-ID mapping: `grep -n "PRICE_ID" docker-compose.yml backend/src/features/subscriptions/domain/subscriptions.service.ts` - Tier limits and fail-open: `grep -n "VEHICLE_LIMITS\|return true" backend/src/core/config/feature-tiers.ts` - pg unencrypted: `grep -n "ssl" backend/src/core/config/database.ts config/app/production.yml` (expect no hits) - No frontend CSP: `grep -n "add_header" frontend/nginx.conf` (expect no hits) - 30-day grace period: `grep -rn "grace" backend/src/features/user-profile/jobs/account-purge.job.ts docs/USER-GUIDE.md` - AUDIT.md staleness: `grep -n "CONDITIONALLY READY" docs/AUDIT.md` - Ansible prune-cron gap: `grep -n "cron" ansible/deploy-staging-runner.yml` - Runner label drift: `grep -n "runs-on" .gitea/workflows/staging.yaml .gitea/workflows/production.yaml && grep -n "mvp-build" docs/CICD-DEPLOY.md` Not verifiable from the repo (recheck externally): Google Maps key referrer restrictions (Google Cloud Console); whether the GRAFANA_ADMIN_PASSWORD and Stripe/Resend Gitea secrets are actually set (Gitea repo settings); whether a Stripe test-mode webhook endpoint is registered for staging (Stripe dashboard).