From cc45b7e1d073f381db2a31334cde36d8d16f4497 Mon Sep 17 00:00:00 2001 From: Max Pengilly <182439403+MP2EZ@users.noreply.github.com> Date: Thu, 28 May 2026 20:34:43 -0700 Subject: [PATCH 01/36] test(v1.4): close coverage gaps surfaced during production validation (#35) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comprehensive review of v1.4 test coverage after tonight's end-to-end validation. Five real bugs surfaced during the upgrade-flow walkthrough; three were unit-testable, plus seven additional coverage gaps in production-critical paths. Backend (+10, 999 total) Stripe API shape (3) — current_period_end migration to items[] caused empty subscription_expires_at after live cancellation testing: - test_subscription_updated_root_period_end_preferred_over_items locks in the backwards-compat ordering: root wins when both are present - test_subscription_updated_empty_items_array_safe defends against malformed payloads with empty items.data - (test_subscription_updated_reads_period_end_from_items_array already added in slice 5 hotfix) Webhook audit + forensics (3) — previously untested branches: - test_payment_failed_audit_row_preserves_status asserts the old/new status equality on payment_failed (analytics depends on this shape) - test_unknown_event_type_still_recorded_for_forensics covers the "ignore unknown event_type" code path that must still save the payload to stripe_events for operator forensics - test_handler_crash_preserves_claim_row_for_operator_runbook locks in the contract documented in billing.handle_webhook_event's docstring: partial-failure mid-handler leaves the claim row, requiring manual DELETE FROM stripe_events to re-enable Stripe's retry CSP defensive coding (4) — fix + tests for SUPABASE_URL bare-hostname bug that surfaced in production: - new api._supabase_csp_origin() helper prepends https:// when missing, preventing silent CSP breakage from operator-set env var typos - TestCSPSupabaseOrigin class covers: bare hostname gets https://, https:// passthrough, unset → empty, whitespace trimmed - Same helper now used in CSP middleware; backend auth.py JWKS lookup is unaffected (it builds its own URL from project ref) Frontend (+2, 203 total) - useAuth: persisted session triggers getMe() on mount without waiting for onAuthStateChange — regression test for tonight's silent INITIAL_SESSION race condition where returning users were stuck on "Loading…" forever - useBilling: strips ?billing=success from URL after refresh — covers the post-checkout URL cleanup that was implemented but untested Verification: 1202 tests pass (999 backend + 203 frontend), zero regressions, ruff + typescript clean. What's deliberately NOT added (out of scope or different defense): - E2E test framework (would have caught the build-time env var bug more decisively than any unit test — separate effort) - CI workflow assertion that VITE_PUBLIC_SUPABASE_* are present (lint-style check on workflows, not in test suite) - Modal width computed-style assertion (CSS specificity bug; better addressed by adopting a CSS-in-JS or design-system constraint) - Live webhook fixture recorder (would help future API version migrations like the items[] one; separate tooling effort) Co-authored-by: Claude Opus 4.7 (1M context) --- src/pnw_campsites/api.py | 22 +++- tests/test_billing.py | 156 +++++++++++++++++++++++++++++ tests/test_routes_billing.py | 44 ++++++++ web/src/__tests__/billing.test.tsx | 69 +++++++++++++ web/src/__tests__/useAuth.test.tsx | 25 +++++ 5 files changed, 315 insertions(+), 1 deletion(-) diff --git a/src/pnw_campsites/api.py b/src/pnw_campsites/api.py index e97b402..9ed58c6 100644 --- a/src/pnw_campsites/api.py +++ b/src/pnw_campsites/api.py @@ -453,6 +453,26 @@ async def capture_unhandled_exception(request: Request, exc: Exception) -> JSONR _search_logger = logging.getLogger("pnw_campsites.timing") +def _supabase_csp_origin() -> str: + """Return the Supabase URL in CSP `connect-src` form, defensively. + + Operators sometimes set SUPABASE_URL to a bare hostname (e.g. + `wvtsutjdtzztaghaskze.supabase.co`). Without a scheme the browser + treats the value as an unknown token and refuses the connection, + silently breaking Supabase auth in production. Prepend `https://` + when missing so the CSP is always well-formed regardless of how + the secret was set. + + Empty input → empty output (no Supabase configured = no rule needed). + """ + raw = os.getenv("SUPABASE_URL", "").strip() + if not raw: + return "" + if raw.startswith(("https://", "http://")): + return raw + return f"https://{raw}" + + @app.middleware("http") async def timing_middleware(request: Request, call_next): start = time.monotonic() @@ -475,7 +495,7 @@ async def timing_middleware(request: Request, call_next): " https://*.tile.openstreetmap.org" " https://cdn.recreation.gov" " https://www.reserveamerica.com; " - f"connect-src 'self' https://*.tile.openstreetmap.org {os.getenv('SUPABASE_URL', '')}; " + f"connect-src 'self' https://*.tile.openstreetmap.org {_supabase_csp_origin()}; " "frame-ancestors 'none'" ) return response diff --git a/tests/test_billing.py b/tests/test_billing.py index f61f1d5..daa384e 100644 --- a/tests/test_billing.py +++ b/tests/test_billing.py @@ -362,6 +362,76 @@ def test_subscription_updated_scheduled_cancel_keeps_pro_with_expires_at( for e in audit ) + def test_subscription_updated_root_period_end_preferred_over_items( + self, + watch_db: WatchDB, + free_user_with_stripe_customer: User, + ) -> None: + # Backwards compat: older Stripe API versions populate + # current_period_end at the subscription root. _current_period_end + # MUST check root first so accounts on older API versions still + # populate subscription_expires_at correctly. Items[] is the + # fallback, not the primary. + watch_db.update_user( + free_user_with_stripe_customer.id, subscription_status="pro", + ) + event = { + "id": "evt_root_wins", + "type": "customer.subscription.updated", + "data": { + "object": { + "customer": "cus_hook_123", + "status": "active", + "cancel_at_period_end": True, + "current_period_end": 1735689600, # 2025-01-01 + # items also present with a DIFFERENT date + "items": { + "data": [ + {"current_period_end": 1893456000}, # 2030-01-01 + ], + }, + }, + }, + } + billing.handle_webhook_event(event, watch_db) + refreshed = watch_db.get_user_by_id(free_user_with_stripe_customer.id) + # Root value (2025) wins, not items[0] value (2030) + assert "2025" in refreshed.subscription_expires_at, ( + f"root field should take precedence; got {refreshed.subscription_expires_at!r}" + ) + assert "2030" not in refreshed.subscription_expires_at + + def test_subscription_updated_empty_items_array_safe( + self, + watch_db: WatchDB, + free_user_with_stripe_customer: User, + ) -> None: + # Defensive: malformed Stripe payload with both root and items[] + # absent or empty must not crash. Pro status should still be + # retained; subscription_expires_at just stays empty. + watch_db.update_user( + free_user_with_stripe_customer.id, subscription_status="pro", + ) + event = { + "id": "evt_empty_items", + "type": "customer.subscription.updated", + "data": { + "object": { + "customer": "cus_hook_123", + "status": "active", + "cancel_at_period_end": True, + # No root current_period_end, items.data empty + "items": {"data": []}, + }, + }, + } + # Must not raise + billing.handle_webhook_event(event, watch_db) + refreshed = watch_db.get_user_by_id(free_user_with_stripe_customer.id) + assert refreshed.subscription_status == "pro" + # Empty expires_at acceptable when payload doesn't carry the field + assert refreshed.subscription_expires_at == "" + def test_subscription_updated_reads_period_end_from_items_array( self, watch_db: WatchDB, @@ -485,6 +555,61 @@ def test_payment_failed_does_not_change_status( e["event_type"] == "invoice.payment_failed" for e in audit ) + def test_payment_failed_audit_row_preserves_status( + self, + watch_db: WatchDB, + free_user_with_stripe_customer: User, + ) -> None: + # Audit row for payment_failed must record both old and new status + # as the SAME value (no actual transition happened, just a signal). + # Analytics queries depend on this shape to count churn precursors + # without confusing them with actual downgrades. + watch_db.update_user( + free_user_with_stripe_customer.id, subscription_status="pro", + ) + event = { + "id": "evt_pf_audit", + "type": "invoice.payment_failed", + "data": {"object": {"customer": "cus_hook_123"}}, + } + billing.handle_webhook_event(event, watch_db) + audit = watch_db.list_subscription_events(free_user_with_stripe_customer.id) + pf_rows = [e for e in audit if e["event_type"] == "invoice.payment_failed"] + assert len(pf_rows) == 1 + # Both old and new are pro — payment_failed doesn't transition state + assert pf_rows[0]["old_status"] == "pro" + assert pf_rows[0]["new_status"] == "pro" + + def test_unknown_event_type_still_recorded_for_forensics( + self, + watch_db: WatchDB, + free_user_with_stripe_customer: User, + ) -> None: + # Operators must be able to look up *every* Stripe event we + # received, even ones our handler doesn't act on, because: + # (1) it proves the endpoint received the delivery (vs Stripe- + # side delivery failure) + # (2) the raw payload is the forensic record if we later need to + # replay or audit + # The "ignore unknown event_type" branch in handle_webhook_event + # must still call save_stripe_event before returning. + event = { + "id": "evt_unknown_type", + "type": "customer.discount.created", # not in our dispatch table + "data": {"object": {"customer": "cus_hook_123"}}, + } + billing.handle_webhook_event(event, watch_db) + # Forensic record: present, with full payload + assert watch_db.has_stripe_event("evt_unknown_type") + row = watch_db._conn.execute( + "SELECT event_type, payload FROM stripe_events WHERE event_id=?", + ("evt_unknown_type",), + ).fetchone() + assert row["event_type"] == "customer.discount.created" + import json + parsed = json.loads(row["payload"]) + assert parsed["id"] == "evt_unknown_type" + def test_duplicate_event_is_skipped( self, watch_db: WatchDB, @@ -747,3 +872,34 @@ def test_create_portal_session_requires_customer_id( monkeypatch.setenv("STRIPE_SECRET_KEY", "sk_test_x") with pytest.raises(ValueError, match="customer_id required"): billing.create_portal_session("") + + +# --------------------------------------------------------------------------- +# CSP defensive coding (regression: production-incident scope) +# --------------------------------------------------------------------------- + + +class TestCSPSupabaseOrigin: + """Surfaced live: a bare-hostname SUPABASE_URL produced an invalid + `connect-src` token, silently blocking Supabase auth. _supabase_csp_origin + must always emit a scheme-qualified origin or empty string.""" + + def test_bare_hostname_gets_https_prepended(self, monkeypatch) -> None: + from pnw_campsites.api import _supabase_csp_origin + monkeypatch.setenv("SUPABASE_URL", "ref.supabase.co") + assert _supabase_csp_origin() == "https://ref.supabase.co" + + def test_https_passthrough(self, monkeypatch) -> None: + from pnw_campsites.api import _supabase_csp_origin + monkeypatch.setenv("SUPABASE_URL", "https://ref.supabase.co") + assert _supabase_csp_origin() == "https://ref.supabase.co" + + def test_unset_returns_empty(self, monkeypatch) -> None: + from pnw_campsites.api import _supabase_csp_origin + monkeypatch.delenv("SUPABASE_URL", raising=False) + assert _supabase_csp_origin() == "" + + def test_whitespace_trimmed(self, monkeypatch) -> None: + from pnw_campsites.api import _supabase_csp_origin + monkeypatch.setenv("SUPABASE_URL", " https://ref.supabase.co ") + assert _supabase_csp_origin() == "https://ref.supabase.co" diff --git a/tests/test_routes_billing.py b/tests/test_routes_billing.py index 5892d19..1c115f2 100644 --- a/tests/test_routes_billing.py +++ b/tests/test_routes_billing.py @@ -255,6 +255,50 @@ def test_handler_crash_returns_500( ) assert resp.status_code == 500 + def test_handler_crash_preserves_claim_row_for_operator_runbook( + self, api_client, monkeypatch, + ) -> None: + # When a handler crashes AFTER save_stripe_event has claimed the + # event_id, the claim row remains in stripe_events even though the + # route returns 500. Stripe's retry will be skipped as a duplicate. + # The operator runbook (documented in billing.handle_webhook_event) + # requires DELETE FROM stripe_events WHERE event_id=? to re-enable + # retry. This test locks in the claim-row preservation behaviour + # so any future refactor that "fixes" it (e.g., by rolling back + # the row on exception) breaks the runbook contract loudly. + user_data, headers = signup_and_auth( + api_client, email="crashtest@example.com", + ) + import pnw_campsites.api as api_module + api_module._watch_db.update_user( + user_data["id"], stripe_customer_id="cus_crash", + ) + + # Inject a real verify_webhook that returns a known event, and + # break _handle_subscription_updated AFTER the claim row gets + # written by handle_webhook_event. + event = { + "id": "evt_crash_runbook", + "type": "customer.subscription.updated", + "data": { + "object": {"customer": "cus_crash", "status": "active"}, + }, + } + with patch( + "pnw_campsites.billing.verify_webhook", return_value=event, + ), patch( + "pnw_campsites.billing._handle_subscription_updated", + side_effect=RuntimeError("DB blip mid-handler"), + ): + resp = api_client.post( + "/api/billing/webhook", + content=b"{}", + headers={"stripe-signature": "t=0,v1=ignored"}, + ) + assert resp.status_code == 500 + # Claim row was inserted before the handler ran → still present + assert api_module._watch_db.has_stripe_event("evt_crash_runbook") + # --------------------------------------------------------------------------- # Watch limit enforcement diff --git a/web/src/__tests__/billing.test.tsx b/web/src/__tests__/billing.test.tsx index 68cf684..3b9528c 100644 --- a/web/src/__tests__/billing.test.tsx +++ b/web/src/__tests__/billing.test.tsx @@ -274,6 +274,75 @@ describe("useBilling", () => { ); }); }); + + test("strips ?billing=success from URL after refresh", async () => { + // Stripe Checkout success redirect lands at /?billing=success. The + // hook must call refresh() (so PRO badge appears) AND clean the URL + // (so a bookmark or share doesn't carry the noise). Without the + // cleanup, the badge would still appear but the URL would persist + // through navigation, polluting analytics and breaking later + // billing=cancelled scenarios that reuse the same param. + const originalHistory = window.history.replaceState; + const replaceStateMock = vi.fn(); + Object.defineProperty(window, "history", { + value: { ...window.history, replaceState: replaceStateMock }, + writable: true, + }); + Object.defineProperty(window, "location", { + value: { + ...window.location, + search: "?billing=success", + pathname: "/", + }, + writable: true, + }); + + vi.doMock("../lib/supabase", () => ({ + supabase: { + auth: { + getSession: vi.fn().mockResolvedValue({ data: { session: null } }), + }, + }, + })); + vi.doMock("../hooks/useAuth", () => ({ + useAuth: () => ({ + user: { id: 1, email: "u@x.com", display_name: "U" }, + loading: false, + }), + })); + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + subscription_status: "pro", + subscription_expires_at: "", + has_stripe_customer: true, + is_pro: true, + watch_limit: null, + planner_session_limit: 20, + configured: true, + }), + }); + + const { BillingProvider } = await import("../hooks/useBilling"); + render( + +
+ , + ); + + await waitFor(() => { + expect(replaceStateMock).toHaveBeenCalled(); + }); + // Newly-replaced URL must not contain the billing=success param + const replaceArgs = replaceStateMock.mock.calls[0]; + expect(replaceArgs[2]).not.toContain("billing=success"); + + // Restore + Object.defineProperty(window.history, "replaceState", { + value: originalHistory, + }); + }); }); // --------------------------------------------------------------------------- diff --git a/web/src/__tests__/useAuth.test.tsx b/web/src/__tests__/useAuth.test.tsx index f8301c9..f67c957 100644 --- a/web/src/__tests__/useAuth.test.tsx +++ b/web/src/__tests__/useAuth.test.tsx @@ -202,4 +202,29 @@ describe("useAuth", () => { ); spy.mockRestore(); }); + + test("persisted session triggers getMe() on mount without waiting for onAuthStateChange", async () => { + // Regression for production bug: supabase-js v2's INITIAL_SESSION + // event can race React effect registration — if the listener + // registers after the SDK has already emitted, returning users get + // stuck on "Loading…" forever because getMe() is never called. + // + // The fix explicitly reads the persisted session via getSession() on + // mount, separately from registering the listener. This test holds + // that contract: a session in localStorage MUST result in getMe() + // being called even if onAuthStateChange never fires. + const { supabase } = await import("../lib/supabase"); + vi.mocked(supabase.auth.getSession).mockResolvedValue({ + data: { session: { access_token: "persisted-token" } as never }, + error: null, + }); + // Listener registered but never invoked — simulates the race + mockGetMe.mockResolvedValue(TEST_USER as never); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => expect(result.current.user).toEqual(TEST_USER)); + expect(mockGetMe).toHaveBeenCalled(); + expect(result.current.loading).toBe(false); + }); }); From e9f83f3c3b62ff8e4060ff98a176be258f0d5566 Mon Sep 17 00:00:00 2001 From: MP2EZ <182439403+MP2EZ@users.noreply.github.com> Date: Thu, 28 May 2026 20:44:30 -0700 Subject: [PATCH 02/36] docs: mark v1.4 SHIPPED + plan v1.41 Maestro E2E + Post-ship Status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.4 Monetization Launch shipped 2026-05-28 (code complete + validated in Stripe test mode end-to-end via Chrome DevTools). Added Post-ship Status section covering the 6-step live-mode activation checklist so the operational handoff is captured alongside the technical state. Five production bugs surfaced during validation are documented in v1.4's "Production bugs surfaced + fixed during validation" expandable note — each maps to a PR# so future-me can trace why specific defensive helpers exist (e.g., _supabase_csp_origin, _current_period_end, getSession() explicit call in useAuth). New v1.41 "Maestro E2E" milestone added between v1.4 and v1.45. Theme: 4 of the 5 production bugs hit during v1.4 validation would have been caught by a single Maestro upgrade-flow smoke test in seconds rather than hours. Shared DSL with the Being mobile app means no second E2E stack to learn. ~2-3 days realistic effort for the 8 features (4 flows + setup + staging + CI). Roadmap HTML regenerated via /roadmap skill: - 35 total milestones (was 34, +v1.41) - 29 shipped (was 28, +v1.4) - 1 BUILT* (v0.95, unchanged) - 1 in progress (v1.33 — code complete but roadmap status pending v1.36) - 4 planned (v1.36, v1.41, v1.45, v2.0) - 83% overall progress Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/ROADMAP.md | 118 +++++++++++++++++++++++++++- docs/roadmap.html | 191 +++++++++++++++++++++++++--------------------- 2 files changed, 220 insertions(+), 89 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 523c16f..4b945eb 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -39,12 +39,13 @@ 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 -------> Maestro E2E — End-to-end browser flows in Maestro (upgrade, watch limit, planner limit). Shared tooling with Being mobile. 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 Maestro E2E coverage (the 5 production bugs hit during v1.4 validation would have been caught by a single upgrade-flow smoke test), 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 +1480,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 +1512,117 @@ 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 Maestro 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 "Maestro E2E" + +### Theme +Stand up end-to-end browser test coverage that mirrors real production failure patterns, using Maestro Web (the same DSL already in use for the Being mobile app). A single upgrade-flow smoke test would have caught 4 of the 5 bugs hit during v1.4 validation in seconds rather than hours. Single tool covers campable.co today AND v1.45's Capacitor-wrapped iOS/Android app later — no separate E2E stack to learn or maintain. + +### Features + +| Feature | Size | Description | +|---------|------|-------------| +| Maestro CLI + Cloud account setup | XS | Reuse the existing Being account if convenient. `.maestro/` directory at repo root with config and shared environment vars. | +| 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 3 watches → 4th attempt → assert UpgradeModal opens with reason=watch_limit. Validates the 402 → modal flow. | +| Flow 3 — Planner limit smoke test | S | Free user → start 3 trip planner sessions → 4th → assert UpgradeModal opens with reason=planner_limit. | +| Flow 4 — Cancel + reactivate | M | Pro user → Manage billing → 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 (preferred — auto-deployed, real DB, isolated) OR a dedicated `staging.campable.co` Fly app with separate DB volume. Tests don't run against `campable.co` production. | +| Email confirmation toggle for test | XS | Supabase project → Authentication → Providers → Email → disable "Confirm email" for the staging project so tests can sign in without inbox round-trip. | +| CI integration | S | Maestro Cloud run on every PR (smoke test only — ~2 min) + nightly run (all flows). Failures surface in PR checks. | + +### Architecture Decisions + +**Maestro over Playwright/Cypress.** Solo dev already running Maestro on Being for mobile flows. Shared DSL, shared cloud account, shared mental model. The maturity gap on web is real (Maestro Web is newer than Playwright) but cognitive consistency wins for small teams. If we ever scale to a multi-engineer testing team, revisit. + +**Staging environment, not production.** Tests like "cancel subscription" can't run against real production without polluting metrics (and burning a test user account every cycle). Use Fly preview deployments per PR — each PR gets its own ephemeral URL, isolated DB, isolated Stripe test keys. After PR merges, the preview is torn down automatically. + +**Test mode Stripe keys only.** E2E tests never touch live-mode Stripe. The `4242 4242 4242 4242` test card flow is fast, deterministic, and free; live-mode tests would be slow, charge real money, and trigger real fraud detection. + +**iframe handling for Stripe Checkout card field.** Maestro Web supports iframe interaction but with slightly different syntax than top-level elements. Plan a ~30 min spike at start of implementation to confirm the card-field flow works before committing to the full suite. + +### Files Changed + +**New:** +- `.maestro/config.yaml` — base config (timeouts, default URL) +- `.maestro/flows/upgrade.yaml` — Flow 1 +- `.maestro/flows/watch-limit.yaml` — Flow 2 +- `.maestro/flows/planner-limit.yaml` — Flow 3 +- `.maestro/flows/cancel-reactivate.yaml` — Flow 4 +- `.github/workflows/maestro.yml` — CI integration + +**Modified:** +- `fly.toml` — preview deploy config (or new `fly.staging.toml` if going the dedicated staging route) + +**External config:** +- Maestro Cloud account (probably already exists for Being) +- Supabase project for staging (could be the same project with confirmation toggled off, OR a separate staging project — recommended for isolation) +- Stripe test-mode keys for staging (same keys as current v1.4 production-test-mode setup are fine) + +### Quality Bar + +- Smoke test (Flow 1) runs in < 90s end-to-end +- All four flows pass on `dev` HEAD +- CI failure includes a screenshot of the failing step +- Each flow uses a fresh randomly-generated test account (no state bleed between runs) +- Maestro Cloud monthly cost stays under $20 (or self-hosted if budget is tighter) + +### Dependencies + +- v1.4 shipped (we're testing v1.4's flows) +- Fly preview deployments enabled (configuration on the Fly side, ~30 min) +- Supabase staging project OR confirmation toggle disabled on prod project for test email domain + +### Key Risks + +| Risk | Mitigation | +|------|------------| +| Maestro Web maturity gap vs Playwright | Run a 30-min spike with the iframe interaction case before committing. If it doesn't work cleanly, fall back to Playwright with the same flow structure — the YAML translates conceptually. | +| Staging deploys add infra cost | Fly preview branches auto-suspend when idle. Maestro Cloud has a generous free tier for solo projects. Total monthly cost target: < $30. | +| Test account email collision | Use `maestro-{random-uuid}@example.com` per run. Optionally clean up via Supabase admin API after test completion. | +| Flow brittleness from Stripe Checkout UI changes | Stripe's Checkout UI is stable across years but does occasionally rev. If a flow breaks, fix that single flow — don't add wrapper layers that "future-proof" against hypothetical changes. | +| 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. These need a manual smoke test the first time a live transaction goes through. | + +### 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.45 "Native Apps" diff --git a/docs/roadmap.html b/docs/roadmap.html index 9a116c7..ca83417 100644 --- a/docs/roadmap.html +++ b/docs/roadmap.html @@ -186,10 +186,15 @@ box-shadow: 0 0 24px rgba(212, 114, 42, 0.12); } - details { - margin: 0; + /* Just-shipped: latest shipped milestone gets a subtle highlight */ + .milestone.shipped.recent .card { + background: linear-gradient(180deg, rgba(45, 106, 79, 0.08), var(--bg-card)); + border-left-color: var(--accent-shipped); + box-shadow: 0 0 16px rgba(45, 106, 79, 0.1); } + details { margin: 0; } + summary { cursor: pointer; list-style: none; @@ -241,10 +246,7 @@ .status-pill.current { background: var(--accent-current); } .status-pill.future { background: var(--accent-future); } - .milestone-name { - font-weight: 600; - font-size: 15px; - } + .milestone-name { font-weight: 600; font-size: 15px; } .milestone-desc { color: var(--text-muted); @@ -255,6 +257,8 @@ .milestone.shipped .milestone-name { color: var(--text-muted); font-weight: 500; } .milestone.shipped .milestone-desc { color: var(--text-dim); font-size: 12px; } + .milestone.shipped.recent .milestone-name { color: var(--text); font-weight: 600; } + .milestone.shipped.recent .milestone-desc { color: var(--text-muted); font-size: 13px; } .card-body { margin-top: 14px; @@ -330,9 +334,7 @@ justify-content: space-between; } - .risks { - margin-top: 14px; - } + .risks { margin-top: 14px; } .risks > summary { font-size: 12px; @@ -341,11 +343,7 @@ padding: 6px 0; } - .risks-list { - list-style: none; - padding: 6px 0 0 0; - margin: 0; - } + .risks-list { list-style: none; padding: 6px 0 0 0; margin: 0; } .risks-list li { font-size: 12px; @@ -374,6 +372,11 @@ line-height: 1.55; } + .status-note.pending { + background: rgba(212, 114, 42, 0.06); + border-left-color: var(--accent-current); + } + .status-note strong { color: var(--text); } .section-heading { @@ -427,16 +430,16 @@

Campable Roadmap

-

v0.2.1 → v2.0 · Generated 2026-05-25

+

v0.2.1 → v2.0 · Generated 2026-05-28

-
34Total Milestones
-
28Shipped
+
35Total Milestones
+
29Shipped
1Built (Not Merged)
1In Progress
4Planned
-
-

82% complete · Current focus: v1.33 Supabase Auth

+
+

83% complete · v1.4 Monetization Launch shipped 2026-05-28 · Live Stripe keys still pending

@@ -467,7 +470,7 @@

Campable Roadmap

-
+
v0.95 Built* @@ -476,20 +479,8 @@

Campable Roadmap

Free/Pro tiers, Stripe billing — built on feature/monetization (NOT merged, stale Mar 2026)

- Status (2026-05-25): Implementation completed on feature/monetization (6 commits, last touched 2026-03-29) and integrated against main through v1.0. The branch was never merged back to dev. Since March 2026, dev has moved 242 commits ahead — most critically v1.33 Supabase Auth, which replaced the custom-cookie user model. Preserved as reference for v1.4 rebuild; will not be merged. + Status: v0.95's full monetization layer was built but never merged. v1.4 (shipped 2026-05-28) rebuilt against current architecture — see v1.4 entry. The feature/monetization branch can now be deleted.
-
Built but unmerged features
-
    -
  • MPayment provider integration · Stripe hosted checkout + customer portal + webhook handler
  • -
  • SSubscription schema + entitlements · subscription_status, subscription_events audit table, webhook event dedup
  • -
  • SWatch limit enforcement · HTTP 402 + upgrade URL; 5-min polling for Pro
  • -
  • STrip planner gating · 3 free / 20 Pro sessions per month
  • -
  • MPricing page, upgrade modal, billing settings · All UI components (predate v1.27/v1.29 design system)
  • -
  • MDowngrade + grandfather logic · 30-day grandfather window; watches paused not deleted
  • -
  • SWebhook security + audit trail · HMAC raw-byte signature verification, idempotency via stripe_events
  • -
-
-
0 of 7 shipped to prodbuilt but unmerged
@@ -537,23 +528,71 @@

Campable Roadmap

Replace custom auth with Supabase, Bearer tokens, auto-provisioning. Auth in Supabase, profile in SQLite keyed by supabase_id.

+
+ Note: Supabase auth code (auth.py, useAuth.ts) shipped in production by 2026-05-28 as part of v1.4 deployment chain. Roadmap status still -----> because the v1.36 OAuth provider wiring on top of v1.33 hasn't shipped yet. +
Features
    -
  • SSupabase project setup · us-west region, redirect URLs, secrets
  • -
  • MBackend JWT swap · PyJWT validation, SUPABASE_JWT_SECRET, ~0.5ms local decode
  • -
  • SUser auto-provisioning · supabase_id column, zero FK migration on watches/trips
  • -
  • MRemove custom auth routes · Drop signup/login/logout, remove bcrypt
  • -
  • MFrontend Supabase client · @supabase/supabase-js, keep AuthContextValue interface
  • -
  • MBearer token pattern · authFetch() wrapper, Authorization header
  • -
  • SAnonymous session migration · Migrate campnw_session cookies to user accounts
  • -
  • SCSP + Account deletion updates · Supabase connect-src, admin API user delete
  • +
  • SSupabase project setup · us-west region, redirect URLs, secrets
  • +
  • MBackend JWT swap · PyJWT via JWKS (ES256), ~0.5ms local decode
  • +
  • SUser auto-provisioning · supabase_id column, zero FK migration on watches/trips
  • +
  • MRemove custom auth routes · Dropped signup/login/logout, removed bcrypt
  • +
  • MFrontend Supabase client · @supabase/supabase-js, kept AuthContextValue interface
  • +
  • MBearer token pattern · authFetch() wrapper, Authorization header
  • +
  • SAnonymous session migration · Migrate campnw_session cookies to user accounts
  • +
  • SCSP + Account deletion updates · Supabase connect-src (defensive https:// prefix added 2026-05-28), admin API user delete
-
-
In progress0 of 8 shipped
+
+
Code completeroadmap status pending v1.36
+
+ +
+
+ +
v1.34ShippedWeather Context

Typical temps + precipitation on search results via Visual Crossing · 2026-05-16

+ +
v1.35ShippedSource Photos

Campground photos from RIDB/RA + SVG postcard placeholder · 2026-05-16

+ +
+
+
+ + v1.4 + Shipped + Monetization Launch + +

Pro tier gate, Stripe Checkout/Portal, webhook handler. Test mode validated 2026-05-28. Live keys + Stripe Customer Portal config pending.

+
+
+ Live mode activation pending. Currently running on Stripe test-mode keys. To accept real money: +
1. Replace STRIPE_SECRET_KEY with sk_live_... (Dashboard → live mode → API keys) +
2. Create live-mode webhook endpoint at https://campable.co/api/billing/webhook for the 4 event types, copy whsec_... into STRIPE_WEBHOOK_SECRET +
3. Create live-mode price for "Campable Pro" $5/mo, copy price_live_... into STRIPE_PRO_PRICE_ID +
4. Activate Stripe account: business verification (LLC + EIN), bank account for payouts, tax decision (Stripe Tax at $0.50/tx OR self-managed) +
5. Configure Customer Portal in live mode (cancel allowed, payment method update allowed) +
6. Optional: ToS link in footer (some Stripe reviews flag missing terms) +
+
Features (all shipped)
+
    +
  • MFreemium gate activation · Free: 3 watches + 3 planner sessions + 15-min polling. Pro: unlimited watches + 20 planner sessions + 5-min polling.
  • +
  • SPricing page · /pricing — Free vs Pro cards, comparison table, FAQ. WCAG 2.1 AA, dark + light themes.
  • +
  • SUpgradeModal + ProBadge · 3 reason variants (watch_limit, planner_limit, voluntary). PRO header badge for Pro users.
  • +
  • MStripe Checkout + Portal routes · POST /api/billing/checkout, /portal, /webhook (signature-verified), GET /status
  • +
  • LWebhook handler · checkout.session.completed, customer.subscription.updated (with cancel_at_period_end + items[0].current_period_end), customer.subscription.deleted, invoice.payment_failed. Idempotent via stripe_events.event_id UNIQUE.
  • +
  • S5-minute Pro polling · APScheduler tier-filtered job; Pro watches polled at 5min, free at 15min, no overlap
  • +
  • SConversion analytics · PostHog funnel: pricing_page_viewed, upgrade_clicked, upgrade_modal_shown (with reason)
  • +
  • Sv0.95 reconciliation · feature/monetization branch documented as built-not-merged; v1.4 rebuilt against current arch
  • +
+
+
Shipped 2026-05-281202 tests passing
- Key Risks + Production bugs surfaced + fixed during validation
    -
  • Supabase availability becomes a dependency for login (not for ongoing sessions). Anonymous mode is unaffected.
  • +
  • VITE_PUBLIC_SUPABASE_* not forwarded to build step → production bundle baked in localhost fallback → signup "Failed to fetch" (PR #25/#26)
  • +
  • SUPABASE_URL Fly secret missing https:// prefix → CSP connect-src malformed → blocked. Defensive _supabase_csp_origin() helper added (PR #35)
  • +
  • Modal drawer width media query missing max-width override on narrow viewports — covered header buttons (PR #27/#28, then #29/#30 for cascade specificity)
  • +
  • useAuth INITIAL_SESSION race condition → returning users stuck on "Loading…" forever. Fix calls getSession() on mount explicitly (PR #31/#32)
  • +
  • Stripe API version 2026-03-25.dahlia moved current_period_end from subscription root to items.data[0]. Helper checks both (PR #33/#34)
@@ -561,10 +600,6 @@

Campable Roadmap

-
v1.34ShippedWeather Context

Typical temps + precipitation on search results via Visual Crossing · 2026-05-16

- -
v1.35ShippedSource Photos

Campground photos from RIDB/RA + SVG postcard placeholder · 2026-05-16 (7 of 8 features; URL verification cron deferred, PostHog alert in place)

-
Planned
@@ -580,22 +615,14 @@

Campable Roadmap

Features
  • SGitHub OAuth provider · Free, no paid account, ~5 min setup — can ship immediately
  • -
  • SGoogle OAuth provider · Cloud Console setup; blocked on LLC
  • -
  • SApple OAuth provider · Services ID + private key; blocked on LLC + $99/yr
  • +
  • SGoogle OAuth provider · Cloud Console setup; LLC ready now, just needs the provider wiring
  • +
  • SApple OAuth provider · Services ID + private key; needs Apple Developer account active
  • SOAuth buttons in AuthModal · Above email/password form, signInWithOAuth call
  • SApple display name capture · Apple only sends name on first sign-in
  • SEmail-linking UX · Auto-link OAuth to existing email/password accounts
-
0 of 6 shippedGitHub unblocked; Google/Apple gated on accounts
-
- Key Risks -
    -
  • Google/Apple accounts not ready — ship GitHub OAuth first, others independent
  • -
  • Google consent screen testing mode (100-user cap) — submit for verification before launch
  • -
  • Apple only sends name once — capture in auto-provisioning, fallback to profile settings
  • -
-
+
0 of 6 shippedGitHub unblocked; Apple gated on dev account
@@ -605,30 +632,33 @@

Campable Roadmap

- v1.4 + v1.41 Planned - Monetization Launch + Maestro E2E -

Pro tier gate, payment, freemium conversion flows. Rebuilt against current architecture; feature/monetization branch as reference.

+

End-to-end browser flows in Maestro Web. Shared DSL with Being mobile app. A single upgrade-flow smoke test would have caught 4 of the 5 production bugs hit during v1.4 validation.

-
- Implementation Approach: v0.95's monetization layer was built on feature/monetization (Mar 2026) but never merged. Since then v1.33 Supabase Auth and v1.27/v1.29 design system work have made the branch obsolete. v1.4 will rebuild against current architecture using the branch as reference: lift billing.py + adapt; rewrite routes against routes/ pattern; reuse schema names + grandfather logic; rebuild frontend against current tokens.css; tests first. -
Features
    -
  • MFreemium gate activation · Free: search/discovery/profiles. Pro: watches (>3), alerts, trip planner, sharing
  • -
  • MLanding page · SEO-optimized homepage, multi-source value prop, free→pro ladder
  • -
  • SUpgrade flows from SEO pages · "Check Live Availability" / "Set up Alerts" CTAs
  • -
  • SConversion analytics · PostHog funnel: SEO → search → watch → upgrade
  • -
  • SPricing page · Clear free vs pro comparison, accessible both themes
  • +
  • XSMaestro CLI + Cloud setup · Reuse existing Being account; .maestro/ directory with config + env
  • +
  • MFlow 1 — Upgrade smoke test · Anonymous → signup → /pricing → Upgrade → Stripe Checkout (4242) → assert PRO badge. Single flow catches CSP, env var, modal-width, useAuth race bugs.
  • +
  • SFlow 2 — Watch limit smoke test · 3 watches OK → 4th → assert UpgradeModal opens with reason=watch_limit
  • +
  • SFlow 3 — Planner limit smoke test · 3 planner sessions OK → 4th → assert UpgradeModal opens with reason=planner_limit
  • +
  • MFlow 4 — Cancel + reactivate · Pro → Manage billing → Cancel → assert expires_at countdown → Reactivate → assert countdown removed. Webhook → DB → UI loop validation.
  • +
  • SStaging URL strategy · Fly preview branches per PR (auto-deployed, isolated DB) OR dedicated staging.campable.co. Never against production.
  • +
  • XSEmail confirmation toggle · Supabase staging project → confirmation off so tests sign in without inbox round-trip
  • +
  • SCI integration · Maestro Cloud on every PR (smoke test, ~2min) + nightly (full suite). Failures surface in PR checks.
-
0 of 5 shippedblocked by v1.36 + v1.3
+
0 of 8 features~2-3 days realistic effort
Key Risks
    -
  • Premature monetization if v1.3 hasn't generated meaningful organic traffic — gates could hurt growth
  • -
  • Auth must be stable (v1.33/v1.36) before tying subscription state to user accounts
  • +
  • Maestro Web maturity gap vs Playwright — run a 30-min iframe-interaction spike before committing
  • +
  • Staging infra cost — Fly preview branches auto-suspend; Maestro Cloud has solo-project tier. Target: < $30/mo total
  • +
  • Test account email collision — use maestro-{uuid}@example.com per run; optional Supabase admin cleanup
  • +
  • Flow brittleness from Stripe Checkout UI revs — fix individual flows when they break, don't pre-emptively abstract
  • +
  • Live-mode bugs missed by test-mode flows — manual smoke test the first time a live transaction goes through (one-time)
@@ -665,17 +695,6 @@

Campable Roadmap

0 of 14 shipped~4-6 weeks to first TestFlight
-
- Key Risks -
    -
  • Web Push (VAPID/SW) doesn't work in WKWebView — APNs/FCM swap is v1.0 critical path, not follow-on
  • -
  • First TestFlight review takes 2-7 days; rejections add another cycle — front-load Apple scrutiny areas
  • -
  • Map tiles unusable offline — decide v1: online-only with messaging or MapLibre + MBTiles bundle
  • -
  • iOS WKWebView purges localStorage under pressure — migrate critical state to @capacitor/preferences
  • -
  • Apple 4.2 "repackaged website" rejection — bundled registry + native push + geo + share + account deletion needed to clear
  • -
  • In-app purchases NOT in v1.45 scope — subscription billing stays web-only; native IAP is v1.5+ decision
  • -
-
@@ -710,7 +729,7 @@

Campable Roadmap

- Generated 2026-05-25 · Source: docs/ROADMAP.md · Regenerate with /roadmap + Generated 2026-05-28 · Source: docs/ROADMAP.md · Regenerate with /roadmap
From 1cd555a77e8a386ffa0104c14860134ce1862ef9 Mon Sep 17 00:00:00 2001 From: Max Pengilly <182439403+MP2EZ@users.noreply.github.com> Date: Thu, 28 May 2026 21:06:40 -0700 Subject: [PATCH 03/36] feat(v1.42): plan Site Polish + Legal Footing, ship About page (#36) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plans v1.42 as the natural follow-on to v1.4 — adds the site pages deferred during monetization launch that v1.45 (Apple App Store) will require anyway. Slot between v1.41 (Maestro E2E) and v1.45 in the timeline. Roadmap entry covers all 8 features (About, Privacy, Terms, footer, contact, Stripe profile config, Customer Portal links, Apple URL prep) with architecture decisions on generator-driven legal text and deferred cookie banner. Ships About page (/about) as the first slice. Matches Pricing page voice — honest, direct, no marketing puffery. Six sections cover what Campable does, why it exists, who built it, how it's funded, where data comes from, and what's coming. Roughly 60-second read targeting fence- sitters on /pricing. Files - docs/ROADMAP.md: v1.42 timeline summary line + full milestone entry - docs/roadmap.html: regenerated with v1.42 visible (36 milestones, 29 shipped, 81% complete; About marked 1/8 in progress) - web/src/pages/About.tsx: new component, ~120 lines content - web/src/App.tsx: lazy-loaded /about route - web/src/App.css: .about-page styles (narrower 720px container than pricing's grid layout — text-heavy pages read better at ~65ch) Verification - TypeScript clean - 203 frontend tests pass - Production build clean (About chunk lazy-loaded, ~2 kB gzipped est.) Co-authored-by: Claude Opus 4.7 (1M context) --- docs/ROADMAP.md | 91 +++++++++++++++++++++++++++- docs/roadmap.html | 46 ++++++++++++-- web/src/App.css | 70 ++++++++++++++++++++++ web/src/App.tsx | 2 + web/src/pages/About.tsx | 129 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 333 insertions(+), 5 deletions(-) create mode 100644 web/src/pages/About.tsx diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4b945eb..b519d67 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -41,11 +41,12 @@ v1.35 [SHIPPED] Source Photos — Campground photos from RIDB/RA + SVG v1.36 -------> OAuth Login — Google, Apple, + GitHub sign-in (Google/Apple blocked on LLC/developer accounts) v1.4 [SHIPPED] Monetization Launch — Pro tier gate, Stripe Checkout/Portal, webhook handler, 1202 tests (test mode validated; live keys pending) v1.41 -------> Maestro E2E — End-to-end browser flows in Maestro (upgrade, watch limit, planner limit). Shared tooling with Being mobile. +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 (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 Maestro E2E coverage (the 5 production bugs hit during v1.4 validation would have been caught by a single upgrade-flow smoke test), 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 Maestro E2E coverage (the 5 production bugs hit during v1.4 validation would have been caught by a single upgrade-flow smoke test), 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. --- @@ -1625,6 +1626,94 @@ That's the gap v1.41 fills. Five real bugs from v1.4 validation map directly to --- +## 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 | 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 | Termly-generated, customized for actual data flow: Supabase (auth), PostHog (analytics), Stripe (billing), Mapbox (drive-time geocoding), Visual Crossing (weather). Honest disclosure of what's collected, retained, and shared. GDPR/CCPA-aware language. | +| Terms of Service (/terms) | S | Termly-generated, customized for $5/mo subscription + refund policy. Liability limit, governing jurisdiction (Washington), right to terminate abusive accounts. Embed refund policy explicitly per Stripe's preference. | +| Footer component | S | New `