20 KiB
PR7 Recon — 06 · Routing & Surfaces (Auth /auth + Billing /billing)
Topic: where
/authand/billingroutes + entries live, reusing the PR1–PR6 shell patterns. RECON ONLY — no product code touched. Every claim citesfile:line. Verified againstorigin/main @ 3764bc13(PR1–PR6 all shipped).
TL;DR (the two registration shapes)
-
/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);/authmust NOT. It belongs as a sibling<Route>at the top level, outside thepath="/"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. -
/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 realuseBillinghook → 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/settingsalways opens on the Models tab and has no?tab=reader — see the breadcrumb/deep-link gap below). A standalone themed/billingroute 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
@/routesbarrel (App.tsx:12-39); the catch-all*must stay last (App.tsx:99comment "ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL"). - PR6a added the two ⌘K-only static surfaces directly here:
benchmarks(App.tsx:97) andplatform(App.tsx:98). - No
/auth,/billing,/login,/sign-in,/payment-success,/payment-cancelledroutes exist (verified by grep — zero hits inApp.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:
/** 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:
routes/<Name>Route.tsx— thin wrapper that renders the App component inside<SurfaceBoundary appName="…">(SurfaceBoundary.tsx:10-17wraps inAppErrorBoundary,onClose→navigate('/home')).routes/index.tsbarrel export (routes/index.ts:56-59— PR6 lines).<Route path="…" element={<…Route/>}/>inApp.tsxunder the AppShell layout (App.tsx:96-98— the PR6a block).
PR7 BILLING (if a standalone
/billingis wanted) follows this exact 3-step pattern — aBillingRoutewrapper → barrel → child route under AppShell. PR7 AUTH does NOT — see §4: it is a sibling route, not an AppShell child, andSurfaceBoundary'sonClose → /homeis 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)
<Routes>
<Route path="/auth" element={<AuthRoute />} /> {/* NEW — sibling, pre-shell */}
<Route path="/" element={<AppShell />}>
…existing 28 children…
</Route>
</Routes>
AuthRouteis a standalone full-screen component — it MUST NOT useSurfaceBoundary(itsonClose → navigate('/home')assumes an authenticated home,SurfaceBoundary.tsx:13). Wrap in a plainAppErrorBoundaryif any, withonClose → window.location.reload()(the same patternApp.tsx:61uses at the root).- Theme still applies:
data-themeis set pre-paint inmain.tsx(per BUILD-PLAN §4) and<ThemeProvider>wraps<BrowserRouter>atApp.tsx:53, so/authinherits 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-reactprovider (neither present — zero@clerkimports inapps/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 whengetIdentity()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 atSettingsApp.tsx:511-655(activeTab === 'billing'). useBillinghook —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=falseuntil a realgetTier()succeeds; the default 'FREE' is NEVER shown as fact (useBilling.ts:13-22, 38-43; rendered unresolved state atSettingsApp.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.tsreturns503 STRIPE_NOT_CONFIGUREDwhen noSTRIPE_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-successroute (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 ⌘Kupgrade/settingsentries 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). ReuseuseBillingverbatim; 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:
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:
- Add a dock-tiers entry (with
route+label) for the billing surface somatchNavRouteresolves it — same fix PR6 should have applied to benchmarks/platform. But billing lives under/settingstoday (Settings already has a dock entrydock-tiers.ts:108, label "Settings"), so a?tab=billingdeep-link inherits the "Settings" breadcrumb already — acceptable. A standalone/billingwould need its own entry. /authneeds NO breadcrumb — it renders outside AppShell (§4), soAppShell.tsx's StatusBar never mounts for it. The gap is irrelevant for auth by construction.
Net: the breadcrumb gap is an AppShell-internal concern.
/authsidesteps it (no shell)./billingshould either ride the existing "Settings" entry (deep-link path) or, if standalone, add adock-tiers.tsroute+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)
- 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_KEYis configured. (adapter.ts:295-308,local/index.ts:2047,AppShell.tsx:98-109accepted "Account" fallback.) - 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). - Fake payment method ("VISA ···4242"). That
…4242string 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. - 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. - 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). - 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.8cosmetic 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 |