diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000..6d60cce --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,208 @@ +name: Playwright E2E + +# Runs Playwright flows against a single long-lived staging app +# (campnw-staging.fly.dev). Each PR's smoke job deploys the PR branch +# to staging, seeds fixtures, and runs the upgrade smoke flow. +# Nightly cron deploys the current dev HEAD and runs all four flows. +# +# Why single staging instead of preview-per-PR: +# - Stripe test-mode webhooks deliver to ONE configured URL, so +# N preview apps can't all receive webhook events anyway +# - Solo dev → no concurrent PR mutex needed +# - One Fly app instead of N (cleaner dashboard, simpler secrets) +# +# Required repo secrets: +# FLY_API_TOKEN (org-scoped — deploys to staging) +# E2E_FIXTURE_PASSWORD (shared password for fixture users) +# SUPABASE_SERVICE_ROLE_KEY (admin JWT for fixture seeding) +# VITE_PUBLIC_SUPABASE_URL (existing — build-time + seed env) +# VITE_PUBLIC_SUPABASE_ANON_KEY (existing — build-time) +# VITE_PUBLIC_POSTHOG_PROJECT_TOKEN (existing — build-time) + +on: + pull_request: + branches: [main, dev] + schedule: + - cron: '0 8 * * *' # nightly 08:00 UTC + workflow_dispatch: + +env: + STAGING_APP: campnw-staging + STAGING_URL: https://campnw-staging.fly.dev + +# Serialize concurrent runs — they share one Fly staging app, and two +# parallel deploys race on the same machine. +concurrency: + group: playwright-staging + cancel-in-progress: false + +jobs: + smoke: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Build frontend + working-directory: web + env: + VITE_PUBLIC_POSTHOG_PROJECT_TOKEN: ${{ secrets.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN }} + VITE_PUBLIC_POSTHOG_HOST: https://eu.i.posthog.com + VITE_PUBLIC_SUPABASE_URL: ${{ secrets.VITE_PUBLIC_SUPABASE_URL }} + VITE_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.VITE_PUBLIC_SUPABASE_ANON_KEY }} + run: | + npm ci + npm run build + + - name: Copy design tokens for SEO pages + run: cp web/src/tokens.css src/pnw_campsites/seo-static/tokens.css + + - name: Setup Fly CLI + uses: superfly/flyctl-actions/setup-flyctl@63da3ecc5e2793b98a3f2519b3d75d4f4c11cec2 + + - name: Deploy PR branch to staging + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + run: | + flyctl deploy --remote-only --app "$STAGING_APP" + for i in $(seq 1 30); do + if curl -sf -o /dev/null "$STAGING_URL/healthz"; then + echo "Staging ready" + exit 0 + fi + sleep 10 + done + echo "::error::Staging never became healthy" + exit 1 + + - name: Seed E2E fixtures + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + E2E_FIXTURE_PASSWORD: ${{ secrets.E2E_FIXTURE_PASSWORD }} + run: | + # SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY are already Fly + # secrets on campnw-staging (mirrored from prod/.env). Only + # E2E_FIXTURE_PASSWORD needs to be passed inline. + flyctl ssh console -a "$STAGING_APP" \ + -C "env E2E_FIXTURE_PASSWORD='$E2E_FIXTURE_PASSWORD' python scripts/seed_e2e_fixtures.py" + + - name: Install Playwright + browser + working-directory: e2e + run: | + npm ci + npx playwright install --with-deps chromium + + - name: Run smoke flow + working-directory: e2e + env: + E2E_BASE_URL: ${{ env.STAGING_URL }} + E2E_FIXTURE_PASSWORD: ${{ secrets.E2E_FIXTURE_PASSWORD }} + CI: "true" + run: npx playwright test tests/upgrade.spec.ts + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report-smoke + path: e2e/playwright-report/ + retention-days: 14 + + nightly: + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 40 + permissions: + contents: read + issues: write + steps: + - uses: actions/checkout@v4 + # No explicit ref — defaults to github.ref. For schedule that's + # the default branch (dev); for workflow_dispatch it's whatever + # branch the dispatch targeted. + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Build frontend + working-directory: web + env: + VITE_PUBLIC_POSTHOG_PROJECT_TOKEN: ${{ secrets.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN }} + VITE_PUBLIC_POSTHOG_HOST: https://eu.i.posthog.com + VITE_PUBLIC_SUPABASE_URL: ${{ secrets.VITE_PUBLIC_SUPABASE_URL }} + VITE_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.VITE_PUBLIC_SUPABASE_ANON_KEY }} + run: | + npm ci + npm run build + + - name: Copy design tokens for SEO pages + run: cp web/src/tokens.css src/pnw_campsites/seo-static/tokens.css + + - name: Setup Fly CLI + uses: superfly/flyctl-actions/setup-flyctl@63da3ecc5e2793b98a3f2519b3d75d4f4c11cec2 + + - name: Deploy dev HEAD to staging + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + run: | + flyctl deploy --remote-only --app "$STAGING_APP" + for i in $(seq 1 30); do + if curl -sf -o /dev/null "$STAGING_URL/healthz"; then + exit 0 + fi + sleep 10 + done + exit 1 + + - name: Seed E2E fixtures + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + E2E_FIXTURE_PASSWORD: ${{ secrets.E2E_FIXTURE_PASSWORD }} + run: | + # SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY are already Fly + # secrets on campnw-staging (mirrored from prod/.env). Only + # E2E_FIXTURE_PASSWORD needs to be passed inline. + flyctl ssh console -a "$STAGING_APP" \ + -C "env E2E_FIXTURE_PASSWORD='$E2E_FIXTURE_PASSWORD' python scripts/seed_e2e_fixtures.py" + + - name: Install Playwright + browser + working-directory: e2e + run: | + npm ci + npx playwright install --with-deps chromium + + - name: Run all flows + working-directory: e2e + env: + E2E_BASE_URL: ${{ env.STAGING_URL }} + E2E_FIXTURE_PASSWORD: ${{ secrets.E2E_FIXTURE_PASSWORD }} + CI: "true" + run: npx playwright test + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report-nightly + path: e2e/playwright-report/ + retention-days: 30 + + - name: Open issue on nightly failure + if: failure() + uses: actions/github-script@v7 + with: + script: | + const date = new Date().toISOString().split('T')[0]; + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `Nightly Playwright E2E failed (${date})`, + body: `Run: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}\n\nThe Playwright HTML report + trace are attached as the run's artifact.`, + labels: ['e2e-failure'], + }); diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 523c16f..bc3be79 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -39,12 +39,14 @@ v1.33 -------> Supabase Auth — Replace custom auth with Supabase, B v1.34 [SHIPPED] Weather Context — Typical temps + precipitation on search results via Visual Crossing v1.35 [SHIPPED] Source Photos — Campground photos from RIDB/RA + SVG postcard placeholder for missing sources v1.36 -------> OAuth Login — Google, Apple, + GitHub sign-in (Google/Apple blocked on LLC/developer accounts) -v1.4 -------> Monetization Launch — Pro tier gate, payment, freemium conversion flows +v1.4 [SHIPPED] Monetization Launch — Pro tier gate, Stripe Checkout/Portal, webhook handler, 1202 tests (test mode validated; live keys pending) +v1.41 [SHIPPED] Playwright E2E — Playwright E2E suite — smoke + watch/planner limits + cancel — 4/4 nightly green (2026-05-31) +v1.42 -------> Site Polish + Legal — About, Privacy, Terms, footer. Unblocks Stripe live-mode review + Apple App Store URL requirement. v1.45 -------> Native Apps — Capacitor shell, iOS App Store + Google Play, native push/GPS/offline registry v2.0 -------> Predictions+ — Statistical model, anomaly alerts, post-mortems (~Q1 2027) ``` -Each milestone is a shippable increment with clear user value. v1.33 establishes production auth (Supabase), v1.34 adds weather context to search results, v1.35 enriches result cards with source-site photos (engagement input to monetization), v1.36 adds OAuth sign-in, v1.4 transitions campable from personal tool to public product (note: v0.95's billing prototype lives on the abandoned `feature/monetization` branch — v1.4 will reference but not merge it, see the v1.4 entry for details), v1.45 wraps the app for iOS + Android via Capacitor (sequenced after v1.4 so monetization is validated on the web before committing to App Store review cycles). v2.0 (Predictions+) deferred until Q1 2027 — data collection running since v0.5, quality improves with time. +Each milestone is a shippable increment with clear user value. v1.33 establishes production auth (Supabase), v1.34 adds weather context to search results, v1.35 enriches result cards with source-site photos (engagement input to monetization), v1.36 adds OAuth sign-in, v1.4 transitions campable from personal tool to public product (code shipped + validated in Stripe test mode 2026-05-28; live keys + Stripe Customer Portal config still pending — see v1.4 Post-ship Status), v1.41 stands up Playwright E2E coverage (the 5 production bugs hit during v1.4 validation would have been caught by a single upgrade-flow smoke test — original plan was Maestro Web Beta, pivoted after Phase 0 spike found ~14min iframe lookups; see v1.41 entry), v1.42 adds the site pages v1.4 deferred (About + Privacy + Terms + footer — required for Stripe live-mode review and Apple App Store submission), v1.45 wraps the app for iOS + Android via Capacitor (sequenced after v1.4 so monetization is validated on the web before committing to App Store review cycles). v2.0 (Predictions+) deferred until Q1 2027 — data collection running since v0.5, quality improves with time. --- @@ -1479,7 +1481,7 @@ Enable Google, Apple, and GitHub sign-in. Supabase infrastructure from v1.33 alr --- -## v1.4 "Monetization Launch" +## v1.4 "Monetization Launch" [SHIPPED 2026-05-28] ### Theme Turn traffic into revenue. v1.3's SEO pages bring organic visitors. v1.4 gates the pro features (watches, alerts, trips) behind a subscription and builds the conversion flows that move free users to paid. The v0.95 billing prototype on `feature/monetization` is preserved as a reference but will not be merged — v1.4 rebuilds against post-v1.33 Supabase auth and the post-v1.27/v1.29 design system. See Implementation Approach below. @@ -1511,6 +1513,218 @@ v0.95's full monetization layer was built on `feature/monetization` (March 2026) ### Key Risk Premature monetization — if v1.3 hasn't generated meaningful organic traffic, gating features could hurt growth. Monitor Search Console data from v1.3 before activating gates. Auth must be stable (v1.33/v1.36) before tying subscription state to user accounts. +### Post-ship Status (2026-05-28) + +**Code complete + validated in Stripe test mode.** Six commits on `feat/v1.4-billing-foundation` shipped through `dev → main` in the order: roadmap reconciliation (slice 0) → schema + billing module + 31 tests (slice 1) → self-review fixes (slice 2.0) → HTTP routes + watch cap (slice 2 main) → frontend (slice 3) → planner gating + 5-min Pro polling (slice 4). Plus 6 follow-up PRs for production bugs surfaced during end-to-end validation. **1202 tests passing** across backend (999) + frontend (203). + +**End-to-end test mode flow validated** in production browser (via Chrome DevTools MCP, 2026-05-28): test account signup → /pricing renders Upgrade button → Stripe Checkout completes with `4242 4242 4242 4242` → PRO badge appears → `/api/billing/status` returns `is_pro:true` → cancel at period end via Customer Portal → `subscription_expires_at` populates with period end → reactivate → expires_at cleared → re-cancel → expires_at re-populates. + +**Five real bugs surfaced during validation** (all fixed): +1. `VITE_PUBLIC_SUPABASE_*` not forwarded to the deploy workflow's build step → production bundle baked in `localhost:54321` fallback → "Failed to fetch" on signup. Fix: PR #25/#26. +2. `SUPABASE_URL` Fly secret set without `https://` prefix → CSP `connect-src` malformed → blocked. Defensive fix: PR #35 added `_supabase_csp_origin()` that prepends scheme. +3. Modal drawer width media query missing `max-width: none` override → narrow viewports kept the desktop cap. Fix: PR #27/#28 (initial), #29/#30 (cascade specificity follow-up). +4. `useAuth` INITIAL_SESSION race condition → returning users stuck on "Loading…" forever. Fix: PR #31/#32 calls `getSession()` on mount explicitly. +5. Stripe API version `2026-03-25.dahlia` moved `current_period_end` from subscription root to `items.data[0]` → `subscription_expires_at` empty after cancel. Fix: PR #33/#34 added `_current_period_end()` helper checking both locations. + +**Live mode activation — still operational/pending.** Currently running on Stripe test-mode keys. To start accepting real money: +- Replace `STRIPE_SECRET_KEY` with `sk_live_...` from Stripe Dashboard (live mode → Developers → API keys) +- Create a NEW webhook endpoint in live mode (Dashboard → Webhooks → Add endpoint, URL = `https://campable.co/api/billing/webhook`, same 4 event types as test mode) and replace `STRIPE_WEBHOOK_SECRET` with its `whsec_...` +- Create a live-mode price for "Campable Pro" at $5/mo and replace `STRIPE_PRO_PRICE_ID` with the new `price_live_...` ID +- Activate Stripe account in Dashboard: business verification (LLC docs, EIN), bank account for payouts, decide on tax (Stripe Tax at $0.50/transaction OR self-managed) +- Configure Customer Portal for live mode (Dashboard → Settings → Billing → Customer Portal): allow cancel, allow payment-method update; same config that already worked in test mode +- Optional: brief Terms of Service link in footer (Stripe flags missing terms on live-mode review for some accounts) + +**Deferred (not blocking ship)**: +- Grandfather migration script for users with >3 watches at activation time — irrelevant pre-launch with no real users; a small one-shot if any real users land in this state post-launch +- Announcement email (per v0.95 spec, "one honest email about Pro") +- v1.41 Playwright E2E suite would have caught 4 of the 5 production bugs above with a single upgrade-flow smoke test (see v1.41 entry) + +--- + +## v1.41 "Playwright E2E" [SHIPPED 2026-05-31] + +### Theme +Stand up end-to-end browser test coverage that mirrors real production failure patterns, using Playwright. A single upgrade-flow smoke test would have caught 4 of the 5 bugs hit during v1.4 validation in seconds rather than hours. + +Original v1.41 plan chose Maestro Web Beta for cognitive consistency with the Being mobile app. Phase 0 spike against `campable.co` (2026-05-29) validated framework capability — all interaction patterns work — but **each cross-origin Stripe iframe element lookup took ~14 minutes** in Maestro Web Beta 2.6.0. Full upgrade smoke would have run 90+ min/run. Unusable for CI gating. Pivoted to Playwright. Full post-mortem under Architecture Decisions below. + +### Features + +| Feature | Size | Description | +|---------|------|-------------| +| Playwright setup (self-hosted, free) | XS | `e2e/` directory at repo root with `package.json`, `playwright.config.ts`, shared fixtures. Runs via `@playwright/test` in GitHub Actions (uses existing CI minutes, $0/mo). | +| Flow 1 — Upgrade smoke test | M | Anonymous → signup → /pricing → Upgrade to Pro → Stripe Checkout with `4242 4242 4242 4242` → assert PRO badge in header. Single flow that would have caught the missing VITE_PUBLIC_SUPABASE env vars, the CSP scheme bug, the modal width regression, and the useAuth race. | +| Flow 2 — Watch limit smoke test | S | Free user → create 4th watch (3 seeded) → assert UpgradeModal opens with reason=watch_limit. Validates the 402 → modal flow. | +| Flow 3 — Planner limit smoke test | S | Free user → submit 4th planner prompt (3 sessions seeded this month) → assert UpgradeModal opens with reason=planner_limit. | +| Flow 4 — Cancel + reactivate | M | Pro user → Manage billing → Stripe Customer Portal → Cancel → assert `subscription_expires_at` rendered in BillingSettings → Reactivate → assert "Pro until X" text removed. Webhook → DB → UI loop validation. | +| Staging URL strategy | S | Fly preview branches per PR (auto-deployed, real DB, isolated) plus a long-lived `campnw-staging` app for nightly runs. Tests never hit `campable.co` production. | +| Fixture seeding script | S | `scripts/seed_e2e_fixtures.py` creates 3 fixture users idempotently via Supabase Admin API + SQLite UPSERT. Runs in CI via `flyctl ssh console`. | +| Email confirmation off in prod Supabase | XS | Already configured (verified during Phase 0 spike). Saved to memory so future sessions don't propose email-verification waits. | +| CI integration | S | GitHub Actions job (`playwright.yml`) installs Playwright + Chromium and runs the smoke flow on every PR (~60s), nightly cron for all flows. Failures upload Playwright HTML report + trace as artifact. | + +### Architecture Decisions + +**Playwright after Maestro Web Beta failed Phase 0 perf bar.** Phase 0 spike (2026-05-29) against `campable.co` confirmed Maestro Web 2.6.0 _can_ drive the full flow (signup, tap-then-inputText for React-controlled inputs, onboarding modal skip via `runFlow: when:`, cross-origin Stripe iframe pierce). The fatal finding from `~/.maestro/tests/2026-05-29_130340/maestro.log`: every iframe element lookup took **~14 minutes** (visible in the gap between RUNNING and "Refreshed element" log lines). A complete upgrade flow would have run 90+ min/run — unusable for CI smoke gates. Playwright's `frameLocator` handles cross-origin iframes in <5s. Cognitive-consistency-with-Being argument loses when web tests need a separate tool anyway. **Follow-up:** file upstream perf issue at github.com/mobile-dev-inc/maestro with the log timestamps. + +**Self-hosted (GitHub Actions), not a paid runner.** $0/mo until revenue justifies otherwise. GitHub Actions already runs Campable's other CI (test, security, lighthouse, bundle-size); adding a Playwright job uses the same already-paid-for minutes. + +**Staging environment, not production.** Tests like "cancel subscription" pollute metrics and burn test users when run against prod. Fly preview deployments per PR (ephemeral URL, isolated DB, isolated Stripe test keys). Auto-suspend on idle = $0/mo standing cost. + +**Test mode Stripe keys only.** E2E never touches live-mode Stripe. The `4242 4242 4242 4242` test card is fast, deterministic, and free. + +**`data-testid` selectively, not blanket.** Only on form inputs where the visible-text/label selectors are unreliable for automation (`email-input`, `password-input`, `display-name-input` in AuthModal). Everything else uses visible text + role + CSS class. Keeps production code clean. + +**Fixture seeding via Python, not SQL.** Supabase auth user creation requires HTTP Admin API calls — pure SQL can't do that. `scripts/seed_e2e_fixtures.py` does both layers (HTTP for auth, SQLite for app). + +### Files Changed + +**New:** +- `e2e/package.json` — Playwright deps only (separate from `web/`) +- `e2e/playwright.config.ts` — base URL via `E2E_BASE_URL` env, retries, traces, reporter +- `e2e/tsconfig.json` — standalone TS config +- `e2e/fixtures/auth.ts` — `signupFresh()`, `loginAsFixture()`, `skipOnboarding()` +- `e2e/fixtures/stripe.ts` — `payWithCard()`, `waitForProBadge()` +- `e2e/tests/upgrade.spec.ts` — Flow 1 +- `e2e/tests/watch-limit.spec.ts` — Flow 2 +- `e2e/tests/planner-limit.spec.ts` — Flow 3 +- `e2e/tests/cancel-reactivate.spec.ts` — Flow 4 +- `e2e/.gitignore` — ignore reports + trace artifacts +- `.github/workflows/playwright.yml` — CI integration +- `scripts/seed_e2e_fixtures.py` — fixture user seeding (Supabase Admin API + SQLite) + +**Modified:** +- `web/src/components/AuthModal.tsx` — `data-testid` on email/password/display-name inputs + +**External config:** +- Long-lived `campnw-staging` Fly app for nightly runs (TODO: create) +- Stripe test-mode keys reused from v1.4 +- Repo secrets: `E2E_FIXTURE_PASSWORD`, `SUPABASE_SERVICE_ROLE_KEY` + +### Quality Bar + +- Smoke test (Flow 1) runs in **< 60s** end-to-end (realistic with Playwright; was <90s with Maestro) +- All four flows pass on `dev` HEAD +- CI failure uploads the Playwright HTML report + trace as a GitHub Actions artifact (interactive trace viewer makes failures self-diagnosable) +- Flow 1 generates a fresh randomly-emailed user per run; Flows 2-4 use idempotent fixtures +- **Monthly infrastructure cost: $0** (GitHub Actions free minutes + Fly preview branches auto-suspend) + +### Dependencies + +- v1.4 shipped (we're testing v1.4's flows) +- Fly preview deployments enabled (configuration on the Fly side) +- Email confirmation off in prod Supabase (already verified during Phase 0) + +### Key Risks + +| Risk | Mitigation | +|------|------------| +| Stripe Checkout UI changes | Stripe's Checkout UI is stable across years but does occasionally rev (modern variant now wraps the card form inside a "Card" radio under payment-method tabs — discovered during Phase 0 spike and handled in `fixtures/stripe.ts`). If a flow breaks, fix that single fixture — don't add wrapper layers that "future-proof" against hypothetical changes. | +| Fixture user drift if schema changes | `scripts/seed_e2e_fixtures.py` is idempotent and re-runnable. Schema changes invalidate fixtures; rerun seed = fixed. | +| Test account email collision | Flow 1 uses `e2e-fresh-{timestamp}-{random}@maestro.test` per run. Flows 2-4 use stable fixtures named by purpose. | +| Live-mode bugs missed by test-mode tests | Test mode covers ~95% of real flows. Risks not covered: real fraud detection, real bank decline codes, real refund processing. Need a manual smoke when first live transaction goes through. | +| Maestro Web perf regression doesn't affect Playwright | Playwright's iframe handling is mature and used by thousands of projects — not at risk of the same beta perf trap. | + +### What v1.41 catches that v1.4's 1202 tests don't + +The unit tests we added in v1.4 lock in code behavior. They CAN'T catch: +- Build-time env var omissions (VITE_PUBLIC_* bug) +- CSP construction at the middleware layer with real Supabase domain +- React effect timing bugs in production (useAuth race surfaced from live network behavior) +- CSS specificity bugs across responsive breakpoints +- Cross-tab Supabase session sync +- Real Stripe Checkout iframe behavior +- Webhook delivery from real Stripe in real network conditions + +That's the gap v1.41 fills. Five real bugs from v1.4 validation map directly to flows here. + +--- + +## v1.42 "Site Polish + Legal Footing" + +### Theme +Add the site pages v1.4 deferred and that v1.45 (Apple App Store) will require regardless. Privacy Policy + Terms unblock Stripe live-mode review. About + Contact give trust signals that convert fence-sitters on /pricing. Footer ties everything together. ~1-2 days of focused work; the legal text comes from a generator (Termly or similar) and the user customizes the specifics, so most of the effort is page structure + content writing, not legal drafting. + +Sequencing: realistically wants to happen before live Stripe mode activates (Stripe flags missing Privacy/ToS on subscription products) and before v1.45 (Apple requires a Privacy Policy URL at submission). Can be done in parallel with v1.41 since they touch different surfaces. + +### Features + +| Feature | Size | Description | +|---------|------|-------------| +| About page (/about) | M | _Shipped early, ahead of v1.42._ Honest first-person voice matching the Pricing page. Covers: what Campable does, why it exists, who's behind it, how it's funded, where data comes from, what's coming. Trust signal for fence-sitters on /pricing. | +| Privacy Policy (/privacy) | S | Hand-written in Campable voice. See Architecture Decisions for why Termly was rejected. Honest disclosure of every third party that touches user data (Supabase, Stripe, PostHog, Mapbox, Visual Crossing, Cloudflare, Fly), retention policy, and GDPR/CCPA rights. | +| Terms of Service (/terms) | S | Hand-written. $5/mo subscription terms, explicit 30-day refund policy (per Stripe's preference), liability limit, governing law (Washington), right to terminate abusive accounts. Linked from /pricing. | +| Footer component | S | New `