Files
waggle-os/docs/redesign-warm-hive/pr7-recon/06-routing-surfaces.md
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

321 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PR7 Recon — 06 · Routing & Surfaces (Auth /auth + Billing /billing)
> Topic: where `/auth` and `/billing` routes + entries live, reusing the PR1PR6 shell
> patterns. RECON ONLY — no product code touched. Every claim cites `file:line`.
> Verified against `origin/main @ 3764bc13` (PR1PR6 all shipped).
---
## TL;DR (the two registration shapes)
1. **`/auth` (screen 13) is SPECIAL — it is the ONE pre-shell, full-screen route.** Every
prior warm-Hive screen mounts INSIDE the `<AppShell>` layout route (`App.tsx:63-101`);
`/auth` must NOT. It belongs as a **sibling `<Route>` at the top level, outside the
`path="/"` AppShell element** — no Sidebar, no StatusBar, no ChatHost, no boot gate. It
is the only screen in the whole redesign that breaks the "child-of-AppShell" rule.
2. **`/billing` (screen 14) is NOT a new top-level route at all.** A complete Stripe billing
surface ALREADY EXISTS as **Settings → "Plan" tab** (`SettingsApp.tsx:511-655`), wired to
the real `useBilling` hook → real adapter Stripe calls → real server routes. PR7's billing
work is **(a) reskin that existing tab to the warm tokens + the SCREENS §14 4-state layout,
and (b) make it deep-linkable** (today `/settings` always opens on the Models tab and has
**no `?tab=` reader** — see the breadcrumb/deep-link gap below). A standalone themed
`/billing` route is OPTIONAL and only justified if the design wants the full-screen
Plans/Checkout/Success/Manage flow outside the Settings chrome.
---
## 1. The route table today (`apps/web/src/App.tsx`)
`App.tsx:52-110` — a SINGLE layout route owns everything:
```
<Route path="/" element={<AppShell />}>
<Route index element={<IndexRedirect />} />
…all 28 child routes (home, workspaces, memory, …, benchmarks, platform)…
<Route path="*" element={<NotFound />} /> // App.tsx:100
</Route>
```
- **Every** screen is a child of `<AppShell>` (`App.tsx:63`). There is currently **no
top-level route outside the shell** at all.
- Imports come from the `@/routes` barrel (`App.tsx:12-39`); the catch-all `*` must stay last
(`App.tsx:99` comment "ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL").
- PR6a added the two ⌘K-only static surfaces directly here: `benchmarks` (`App.tsx:97`) and
`platform` (`App.tsx:98`).
- **No `/auth`, `/billing`, `/login`, `/sign-in`, `/payment-success`, `/payment-cancelled`
routes exist** (verified by grep — zero hits in `App.tsx` / `routes/index.ts`).
---
## 2. The PR6 route-wrapper pattern (to copy)
A PR6 ⌘K-only surface is 3 small pieces. `BenchmarkRoute.tsx:1-11` is the template:
```tsx
/** PR6a route wrapper — `/benchmarks` → BenchmarkApp (static, ⌘K-only surface). */
import BenchmarkApp from '@/components/os/apps/BenchmarkApp';
import SurfaceBoundary from './SurfaceBoundary';
const BenchmarkRoute = () => (
<SurfaceBoundary appName="Benchmarks"><BenchmarkApp /></SurfaceBoundary>
);
export default BenchmarkRoute;
```
The three pieces for any in-shell surface:
1. **`routes/<Name>Route.tsx`** — thin wrapper that renders the App component inside
`<SurfaceBoundary appName="…">` (`SurfaceBoundary.tsx:10-17` wraps in `AppErrorBoundary`,
`onClose``navigate('/home')`).
2. **`routes/index.ts` barrel export** (`routes/index.ts:56-59` — PR6 lines).
3. **`<Route path="…" element={<…Route/>}/>` in `App.tsx`** under the AppShell layout
(`App.tsx:96-98` — the PR6a block).
> **PR7 BILLING (if a standalone `/billing` is wanted)** follows this exact 3-step pattern
> — a `BillingRoute` wrapper → barrel → child route under AppShell. **PR7 AUTH does NOT** —
> see §4: it is a sibling route, not an AppShell child, and `SurfaceBoundary`'s
> `onClose → /home` is wrong for a pre-login screen.
---
## 3. Command-catalog entries (`apps/web/src/lib/command-catalog.ts`)
The ⌘K catalog is built by `buildCommandCatalog()` (`command-catalog.ts:59-118`), grouped
**Jump to / Do / Power tools** (+ Pro "★ Pinned"). Entry shape `CatalogCommand`
(`command-catalog.ts:22-40`): `{ id, group, name, subtitle, icon, to?, action?, meta?,
minBillingRank?, pinned? }`. Each routes to a REAL `to:` or fires `action:'spawn'`.
**Billing is already represented in the catalog — but points at the wrong place:**
- `command-catalog.ts:75``Settings · "models · failover · permissions · plan"``to:"/settings"`
- `command-catalog.ts:76`**`"Upgrade to Pro"` · `"plans · billing · invoices"``to:"/settings"`**
Both land on `/settings` (which opens on the **Models** tab, NOT Plan — see §6 gap). PR7
should retarget these to whatever the billing entry point becomes (`/settings?tab=billing`
once a `?tab=` reader exists, or a new `/billing`).
**Auth has NO catalog entry and should NOT get one** — sign-in is a pre-login full-screen
route reached by redirect, not a ⌘K jump from inside the authenticated shell.
The catalog's tier-gating mechanism to reuse: `gate()` (`command-catalog.ts:99-100`) filters
on `minBillingRank` (FREE 0 / TRIAL 1 / PRO 2 / TEAMS 3 / ENT 4, `command-catalog.ts:36`); Pro
"★ Pinned" floats `pinned:true` items (`command-catalog.ts:104-111`). Catalog is consumed in
`AppShell.tsx:272-279` (`buildCommandCatalog` + `handleCatalogSelect`).
---
## 4. `/auth` — the pre-shell, full-screen registration (THE special case)
### Why it can't be an AppShell child
`<AppShell>` (`AppShell.tsx:436-466`) wraps everything in `<ShellProvider>`
`<ShellLayout>` (`AppShell.tsx:68`), which renders the BootScreen gate, the Sidebar
(`AppShell.tsx:314-323`), StatusBar (`AppShell.tsx:303-309`), ChatHost, all overlays, and the
onboarding takeover (`AppShell.tsx:285-296`). A sign-in screen must show **none of that**.
Mounting `/auth` as a child of `path="/"` would draw the whole authenticated chrome behind
the login form.
### Recommended shape (sibling route, outside the shell)
```tsx
<Routes>
<Route path="/auth" element={<AuthRoute />} /> {/* NEW — sibling, pre-shell */}
<Route path="/" element={<AppShell />}>
existing 28 children
</Route>
</Routes>
```
- `AuthRoute` is a **standalone full-screen component** — it MUST NOT use `SurfaceBoundary`
(its `onClose → navigate('/home')` assumes an authenticated home, `SurfaceBoundary.tsx:13`).
Wrap in a plain `AppErrorBoundary` if any, with `onClose → window.location.reload()` (the
same pattern `App.tsx:61` uses at the root).
- Theme still applies: `data-theme` is set pre-paint in `main.tsx` (per BUILD-PLAN §4) and
`<ThemeProvider>` wraps `<BrowserRouter>` at `App.tsx:53`, so `/auth` inherits warm tokens
even though it's outside AppShell. Good — no extra wiring needed for theming.
### Honesty gate (CRITICAL — auth is the #1 fabrication risk)
There is **no real user-identity auth in the product today.** What exists is a **same-origin
local sidecar session-token** (`adapter.ts:295-308` `fetchSessionToken`, refreshed at
`adapter.ts:316-331`; server route `local/index.ts:2047` `/api/auth/session-token`, auth-exempt
`security-middleware.ts:238`). That is a *dev/desktop bootstrap Bearer*, NOT a logged-in human.
The only "Clerk-gated" surface is the **CLOUD** server (`local/routes/agents.ts:12` comment:
"`/api/agents/*` CRUD exists only on the Clerk-gated CLOUD server"), which the local app does
not run.
So `/auth` is **EXTERNAL-DEP / MUST-BUILD**: real Clerk components need a `CLERK_PUBLISHABLE_KEY`
+ the `@clerk/clerk-react` provider (neither present — zero `@clerk` imports in `apps/web/src`).
**Until that wiring is real, the auth screen must NOT show a fake signed-in identity, a fake
name/avatar, or pretend a session exists.** The design's own framing helps here: SCREENS §13
(`SCREENS.md:271-278`) says "An account is optional — Waggle runs fully local without one" and
"Continue routes to Home." A PR7-honest auth screen can render the themed Clerk UI but, with no
key configured, must degrade to the local-first "continue without an account → Home" path
rather than inventing a logged-in user. (The sidebar user row already degrades to "Account" +
"W" avatar when `getIdentity()` returns no name — `AppShell.tsx:98-109`, PR1 LOW #2 — so the
"no real identity" state is already an accepted, non-fabricated UI.)
The `clerk-setup` / `clerk-react-patterns` / `clerk-billing` skills are available for the build PR.
---
## 5. `/billing` — reuse the EXISTING Settings "Plan" tab (do not rebuild from zero)
### What is already REAL (verified, fully wired)
- **Settings "Plan" tab** — `SettingsApp.tsx:41` (`{ id:'billing', label:'Plan', icon:DollarSign }`);
renders at `SettingsApp.tsx:511-655` (`activeTab === 'billing'`).
- **`useBilling` hook** — `hooks/useBilling.ts:24-124`: `startCheckout('PRO'|'TEAMS')`
(`useBilling.ts:69-81`), `openPortal()` (`useBilling.ts:84-96`), `syncAfterCheckout`
(`useBilling.ts:49-66`), auto-detects `?session_id=` on return (`useBilling.ts:104-115`).
P1b honesty already baked in: `tierResolved=false` until a real `getTier()` succeeds; the
default 'FREE' is NEVER shown as fact (`useBilling.ts:13-22, 38-43`; rendered unresolved
state at `SettingsApp.tsx:525-536`).
- **Adapter Stripe layer** — `adapter.ts:2658-2680`: `syncStripeCheckout`,
`createCheckoutSession`, `createPortalSession`.
- **Server Stripe routes (REAL)** — `packages/server/src/stripe/{checkout,portal,sync,webhook}.ts`.
`checkout.ts` returns **`503 STRIPE_NOT_CONFIGURED`** when no `STRIPE_SECRET_KEY`
(`checkout.ts:19-20`); success/cancel URLs are `/payment-success?session_id=…` and
`/payment-cancelled` (`checkout.ts:42-43`). Webhook handler + 4 price-var resolution shipped
per CLAUDE.md §10 (E-10).
- **CoverageCompassCard** value-prop card already renders above the tier card
(`SettingsApp.tsx:520`).
### What is a build GAP for the SCREENS §14 design (`SCREENS.md:282-295`)
| §14 design element | Status | Evidence |
|---|---|---|
| Plans state (3 cards, Pro popular) | DERIVABLE — upgrade buttons exist | `SettingsApp.tsx:579-619` |
| **Monthly / annual toggle (20%)** | **MUST-BUILD on FE** — server already accepts `billingPeriod` (`checkout.ts:14,29`) but adapter `createCheckoutSession(tier)` does **not pass it** (`adapter.ts:2667`); `useBilling.startCheckout` has no period arg (`useBilling.ts:69`) | grep: zero `billingPeriod`/`annual`/`monthly` in adapter+useBilling |
| Checkout state (card form) | EXTERNAL-DEP — use **Stripe Checkout** (hosted), per SCREENS §14 "Use Stripe Checkout/Customer Portal where possible" (`SCREENS.md:294`) | `checkout.ts` creates hosted `session.url` |
| Success state | **MUST-BUILD route**`success_url` points at `/payment-success` which **does not exist** as a route (grep: 0 hits in App.tsx); today only `?session_id=` is read by `useBilling.ts:104-115` on whatever page is mounted | `checkout.ts:42` vs App.tsx |
| Manage state (portal, invoices, PDF) | REAL — "Manage Subscription" → `openPortal()` → Stripe Customer Portal (`SettingsApp.tsx:621-635`, `useBilling.ts:84-96`). **Invoices/PDF are inside Stripe's portal, not our UI** — do NOT render fake invoice rows in-app. |
### Recommended registration for billing
- **Primary:** keep billing as the **Settings → Plan tab**, reskinned to warm tokens + the
§14 segmented 4-state layout. Add the **`/payment-success`** route (and optionally
`/payment-cancelled`) — these CAN be AppShell children using the PR6 wrapper pattern (the
user is back inside the app post-checkout), OR a tiny standalone confirmation. Wire ⌘K
`upgrade`/`settings` entries to deep-link the Plan tab.
- **Optional standalone `/billing`:** only if design wants the full Plans/Checkout/Success/Manage
flow outside Settings chrome. If so, follow the §2 PR6 wrapper pattern (AppShell child —
billing IS post-login, unlike auth). Reuse `useBilling` verbatim; do not duplicate Stripe calls.
### BYO-vs-metered (BLOCKER — founder decision before billing ships)
BUILD-PLAN §7 item 5 (`BUILD-PLAN.md:165-166`) and DESIGN_POV §4 (`DESIGN_POV.md:62-70`) flag
**who pays for inference (BYO-key vs Waggle-metered)** as the decision that "quietly reshapes
Billing, Onboarding, and Usage" and "should be settled before Billing goes live"
(`DESIGN_POV.md:89-90`). This is a **decision gate for PR7**, not a code question — the current
billing tab supports either ("supports either but commits to neither", `DESIGN_POV.md:70`).
---
## 6. The breadcrumb / `matchNavRoute` label gap (must NOT repeat for /billing)
### How the breadcrumb label is derived
`AppShell.tsx:224-229`:
```ts
const labelEntries = flattenAppEntries(getDockForTier('power', billingTier)); // 224
const activeRoute = matchNavRoute(location.pathname, labelEntries.map(e=>e.route)); // 225
const surfaceLabel = labelEntries.find(e => e.route === activeRoute)?.label ?? null; // 229
```
`surfaceLabel` is passed to `<StatusBar focusedWindowLabel={surfaceLabel}>`
(`AppShell.tsx:304`). `matchNavRoute` (`routes.ts:146-154`) is a longest-prefix match against
the **dock-tiers route table only**.
### The gap (this is the handoff "P3 / PR6 ⌘K-only routes show a fuzzy fallback label" note)
`dock-tiers.ts` `POWER_CONFIG` (`dock-tiers.ts:64-118`) does **NOT contain `/benchmarks` or
`/platform`** (confirmed by grep — zero hits in dock-tiers.ts). So for those routes
`matchNavRoute` returns `null``surfaceLabel = null` → **the StatusBar breadcrumb simply
does not render** (it is gated `{focusedWindowLabel && (…)}` at `StatusBar.tsx:84`). The
PR6 ⌘K-only surfaces therefore show **no breadcrumb at all** (not literally a wrong/fuzzy
string — the label is null and the breadcrumb chip is hidden). Either way the surface is
unlabeled in the status bar.
### Requirement for PR7
**`/billing` (and `/payment-success`, and conceptually `/auth`) MUST get a breadcrumb label
so they don't repeat the unlabeled-surface gap.** The label map (`labelEntries`) is sourced
ONLY from `getDockForTier(...)` — i.e. from `dock-tiers.ts`. Two options:
1. **Add a dock-tiers entry** (with `route` + `label`) for the billing surface so
`matchNavRoute` resolves it — same fix PR6 should have applied to benchmarks/platform. But
billing lives under `/settings` today (Settings already has a dock entry `dock-tiers.ts:108`,
label "Settings"), so a `?tab=billing` deep-link inherits the "Settings" breadcrumb already
— acceptable. A standalone `/billing` would need its own entry.
2. **`/auth` needs NO breadcrumb** — it renders outside AppShell (§4), so `AppShell.tsx`'s
StatusBar never mounts for it. The gap is irrelevant for auth by construction.
> Net: the breadcrumb gap is an AppShell-internal concern. `/auth` sidesteps it (no shell).
> `/billing` should either ride the existing "Settings" entry (deep-link path) or, if
> standalone, add a `dock-tiers.ts` route+label entry — do **not** ship it label-less.
### Deep-link gap that BLOCKS the "/settings?tab=billing" approach (verified)
`SettingsApp` initializes `activeTab` to **`'models'`** (`SettingsApp.tsx:53`) and has **NO
`?tab=` / `useSearchParams` reader** (grep: zero `tab=`/`useSearchParams`/`searchParams`/
`initialTab` in `SettingsApp.tsx`). So today `/settings?tab=billing` and even the existing
`APP_ROUTES.backup = '/settings?tab=backup'` (`routes.ts:52`) **silently open on Models, not the
requested tab.** For PR7 to deep-link billing from ⌘K / Upgrade buttons, SettingsApp needs a
small **`?tab=` initializer** (read once on mount, snap `activeTab`). This is a real, small
MUST-BUILD — without it the catalog "Upgrade to Pro" / UpgradeModal-fallback `navigate('/settings')`
(`AppShell.tsx:411-413, 422`) lands a user on Models, not Plan.
---
## 7. Existing upgrade entry points (where "Upgrade"/"Manage plan" link today)
| Surface | Action | Target | Evidence |
|---|---|---|---|
| Settings → Plan tab, FREE/TRIAL | Pro / Teams cards | `billing.startCheckout('PRO'\|'TEAMS')` → Stripe Checkout | `SettingsApp.tsx:583-598` |
| Settings → Plan tab, PRO | "Upgrade to Teams" | `startCheckout('TEAMS')` | `SettingsApp.tsx:604-619` |
| Settings → Plan tab, PRO/TEAMS | **"Manage Subscription"** | `billing.openPortal()` → Stripe Customer Portal | `SettingsApp.tsx:621-635` |
| Settings → Plan tab, non-ENT | Enterprise CTA | external link `https://www.kvark.ai` | `SettingsApp.tsx:638-652` |
| `UpgradeModal` overlay | `onUpgrade(tier)` | `adapter.createCheckoutSession(...)`, **fallback `navigate('/settings')`** | `AppShell.tsx:404-415` |
| `TrialExpiredModal` overlay | `onUpgrade(tier)` | `adapter.createCheckoutSession(...)`, fallback `navigate('/settings')` | `AppShell.tsx:417-424` |
| ⌘K catalog | "Upgrade to Pro" / "Settings" | `to:"/settings"` | `command-catalog.ts:75-76` |
| Sidebar user row | tier label "Trial · 9d" / "Pro" | (display only; row → Settings) | `AppShell.tsx:264-269, 321-322` |
All upgrade paths converge on Stripe checkout (real) or land on `/settings` (which mis-opens
on Models per §6). The "Manage plan" affordance is the existing **"Manage Subscription"**
button → Stripe Customer Portal. There is **no `KvarkNudge` component in `apps/web/src`**
(grep: 0 hits; CLAUDE.md §9 references it but it is not in the web app today — Enterprise CTA
is the inline kvark.ai link at `SettingsApp.tsx:644-651`).
---
## 8. Fabrication risks for PR7 (gate-off candidates — HONESTY CONTRACT)
1. **A logged-in identity that isn't real.** No Clerk/user-auth exists; the session-token is a
local dev Bearer. The auth screen must not render a fake signed-in user/name/avatar or
claim a session. Degrade to the design's local-first "continue without an account" path
when no `CLERK_PUBLISHABLE_KEY` is configured. (`adapter.ts:295-308`, `local/index.ts:2047`,
`AppShell.tsx:98-109` accepted "Account" fallback.)
2. **Fake invoices / PDFs.** SCREENS §14 lists "invoices (Paid + PDF)". Those live inside the
**Stripe Customer Portal**, not our UI. Do NOT render invented invoice rows or fake
"Download PDF" links in-app — route to `openPortal()` (`useBilling.ts:84-96`).
3. **Fake payment method ("VISA ···4242").** That `…4242` string in SCREENS §14 is design
filler. Real card-on-file data lives in Stripe's portal. Do not display a hardcoded masked
card in the Manage state.
4. **Fake "next charge" / usage / due-today numbers.** SCREENS §14 Checkout shows "Due today
$19 / won't be charged until …". Those must come from the real Stripe session, not be
string-literal'd. Prefer hosted Stripe Checkout (`checkout.ts:39-50`) which renders the real
amounts itself.
5. **Presenting tier as fact before it resolves.** Already guarded by `tierResolved`
(`useBilling.ts:13-22`); PR7 must preserve that — never show "Free plan" as fact while
unresolved (`SettingsApp.tsx:525-536`).
6. **A fake monthly/annual price.** The 20% annual toggle is a build gap (§5); when added it
must resolve through a real annual price var (server `priceIdForTier(tier, 'annual')`,
`checkout.ts:29`) — not a client-side `$19 × 0.8` cosmetic number that doesn't match what
Stripe charges.
---
## 9. File index (everything PR7 routing touches)
| Concern | File:line |
|---|---|
| Route table (add `/auth` sibling, optional `/billing` child, `/payment-success`) | `apps/web/src/App.tsx:52-110` |
| Route-wrapper pattern to copy | `apps/web/src/routes/BenchmarkRoute.tsx:1-11`; `routes/SurfaceBoundary.tsx:10-17` |
| Barrel | `apps/web/src/routes/index.ts:33-59` |
| AppId→URL table (`/auth`,`/billing` are NOT here yet; `backup` deep-link precedent) | `apps/web/src/lib/routes.ts:25-53, 70-80`; `matchNavRoute` `routes.ts:146-154` |
| ⌘K catalog (retarget upgrade entry) | `apps/web/src/lib/command-catalog.ts:62-97` |
| Shell breadcrumb derivation + label-source gap | `apps/web/src/components/os/AppShell.tsx:224-229, 304`; `StatusBar.tsx:84-93` |
| Dock label table (no benchmarks/platform/billing entries) | `apps/web/src/lib/dock-tiers.ts:64-118` |
| Existing Billing surface (REUSE) | `apps/web/src/components/os/apps/SettingsApp.tsx:36-47, 511-655` |
| Billing hook (REUSE) | `apps/web/src/hooks/useBilling.ts:24-124` |
| Adapter Stripe + session-token | `apps/web/src/lib/adapter.ts:2658-2680, 295-331` |
| Server Stripe routes | `packages/server/src/stripe/{checkout,portal,sync,webhook}.ts` (checkout `checkout.ts:13-58`) |
| Server local session-token (only "auth" today) | `packages/server/src/local/index.ts:2043-2047`; `security-middleware.ts:238` |
| SCREENS specs | `docs/design_handoff_waggle_app/SCREENS.md:269-295` |
| BYO-vs-metered decision gate | `docs/design_handoff_waggle_app/DESIGN_POV.md:62-70, 89-90`; `BUILD-PLAN.md:165-166` |