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

174 lines
36 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.
# Warm-Hive PR7 — Auth (Clerk, themed · screen 13) · Billing (Stripe, themed · screen 14) — Build Plan
> Source design: `docs/design_handoff_waggle_app/SCREENS.md §13` (Auth) + `§14` (Billing) + `design-files/screens/{auth,billing}.html`. Roadmap: `BUILD-PLAN.md §6` (PR7 = screens 13·14). Strategic gate: `DESIGN_POV.md §4` (BYO-vs-metered) + `BUILD-PLAN.md §7.5`. Branch: TBD off main @ `3764bc13` (PR1PR6 all shipped). Status: **FOUNDER-RATIFIED 2026-06-18 — D1=Option A (BYO-key + flat subscription); scope=PR7a Billing first; Clerk=optional sign-in (b), key forthcoming. Building PR7a now; PR7b deferred to next arc (needs Clerk key).** D5D17 proceed as recommended.
> Recon: 7-reader grounded recon (`pr7-recon/01..07`), every claim verified vs live code with file:line. Corrections + drift in §8 (honesty log).
> **The single most important truth in this plan:** unlike PR3PR6, PR7 renders *identity and money*. Real auth and real payments **cannot be faked** — every fabrication trap (a logged-in user that isn't real, invented invoices, hardcoded payment methods, fake "next charge" dates, a custom card form that doesn't tokenize) is gated off in §5. **PR7 is the highest fabrication-risk PR of the redesign.**
---
## 0. STRATEGIC GATE — BYO-key vs Waggle-metered (settle this FIRST, before any feature code)
`DESIGN_POV §4` (`DESIGN_POV.md:62-70`) flagged "who pays for inference (BYO-key vs Waggle-metered)" as the decision that "quietly reshapes Billing, Onboarding, and Usage" and "must be settled before Billing goes live" (`DESIGN_POV.md:89-90`; `BUILD-PLAN.md:165-166`). It was deferred through PR5 (where D1 ratified BYO-key for the model gate) and now becomes the literal precondition for PR7.
**The de-facto current commitment is Option A — and it is already shipped, wired, and tested end-to-end:**
- **BYO-key is the only inference-payment path that exists.** The shared `ModelGate` copy is literally *"Bring your own key — it's stored encrypted in your Vault and never leaves your machine"* (`ModelGate.tsx:186`), live-validated then written to Vault (`ModelGate.tsx:94-99`); onboarding hard-gates Continue on a working model (`ModelGateStep.tsx:49`). Waggle never holds an inference key or pays a provider on the user's behalf in any shipped path.
- **Stripe is flat `mode:'subscription'`** on both the local sidecar (`checkout.ts:40`, `line_items` qty 1, fixed price, PRO/TEAMS only) and `apps/www` (`checkout/route.ts:170`). Two products (Pro $19, Teams $49/seat), monthly+annual.
- **There is ZERO inference-metering plumbing.** No `createUsageRecord` / `billing_meter` / `reportUsage` / credits ledger anywhere in `packages/server/src` (grep → 0; only a code comment at `local/index.ts:1980`). The only "usage" surface is `TelemetryApp` ("Usage & cost") reading the **estimate-only** `/api/cost/summary` with an **advisory** 80%/exceeded budget warning that **enforces nothing** (`cost.ts:162-169`) — a dashboard, not a quota.
| | **Option A — Ratify BYO-key + flat subscription** | **Option B — Pivot to Waggle-metered** |
|---|---|---|
| Build cost | **Near-zero new backend.** PR7 Billing = theming over the working subscription flow + minor wiring (annual toggle, deep-link, success route). | **Multi-month strategic arc.** New per-request metering, durable usage ledger, hard quota enforcement on the inference/agent-loop path, Stripe metered prices + new webhook events, a credits/balance surface, a managed key pool (the `managedModelPool` flag has no inference-path impl today). |
| Positioning | Consistent with the shipped local-first "your key never leaves your machine" promise. | **Contradicts** that promise; adds inference COGS + abuse surface; inverts the onboarding model gate. |
| Blast radius | PR7 theming only. | Billing + Onboarding (gate inverts) + Usage (full `TelemetryApp` rebuild) + core inference path + Stripe webhook + product copy. |
**Recommendation: ratify Option A.** The codebase already committed end-to-end; Option B is a product pivot PR7 must not silently absorb. The Plans copy "Memory is free forever. You only pay for scale — no feature-count games." (`billing.html:130-131`) *leans* metered but is honestly satisfiable under A (Pro/Teams unlock scale: workspaces, marketplace, sync). **This is a founder strategic call, not a build choice — it must be answered before §3 / any code.**
---
## 1. The design contract
- **§13 Auth** (`auth.html`): split grid (1.05fr brand panel / 1fr form, brand panel hidden <820px, `auth.html:20-22,73,89-152`). **4 states***Sign in* (Google+Apple SSO + email/pw + "Forgot?"), *Sign up* (honey local-note "You don't need this to start." + Name/Email/Password "At least 10 characters"), *Verify* (6-box OTP, auto-advance + backspace-to-prev, `auth.html:163-169`), *SSO/enterprise* (work-email field + neutral "SAML, SCIM provisioning, audit logs available on Teams and KVARK · Talk to sales →"). Load-bearing trust copy: *"An account is optional — Waggle runs fully local without one"* + *"Your memory stays yours; sign-in only adds sync"* (`auth.html:98-99`). Design note `SCREENS.md:277`: "Build with **Clerk** components themed to the tokens." The top `.controls` segmented switcher (`auth.html:78-87`) is **concept-harness scaffolding for previewing states — NOT product UI**.
- **§14 Billing** (`billing.html`): segmented **Plans / Checkout / Success / Manage** (`billing.html:117-122`). Plans: H1 "Upgrade your hive.", Monthly|Annual 20% toggle, 3 cards (Solo $0 "Current" disabled · Pro $19/mo honey "Most popular" → "Choose Pro" · Teams $49/seat → "Choose Teams"). Checkout: a *mock* in-app card form + order summary. Success: "You're Pro." + a receipt block. Manage: current plan + payment method + invoices (Paid+PDF) + change/cancel. Governing instruction `SCREENS.md:294`: **"Use Stripe Checkout/Customer Portal where possible; theme to tokens."** The card form and invoice table are **fidelity mockups of Stripe's hosted surfaces, not a spec to hand-build.**
---
## 2. Current state — **grounded, the two-stack split is the master fact** (verified vs live, 2026-06-18)
**THE master fact (read before anything):** there are **two** server stacks and PR7 builds in `apps/web` → the **LOCAL sidecar** (`packages/server/src/local/index.ts`), which is **config.json-tier and has NO Clerk auth**. The rich Clerk-linked Stripe flow + real Clerk UI live **only** in `apps/www` (Next.js, PR8) + the cloud entry `packages/server/src/index.ts`. **Do not conflate them.** (`recon 01 §0`, `recon 02 §0`, `recon 05 §1-3`, `recon 03 §0`.)
| Layer | Reality today (file:line) | State |
|---|---|---|
| **Local "auth"** | Per-process random bearer token `wsSessionToken = crypto.randomBytes(32)` minted at sidecar boot (`local/index.ts:1409`), served auth-exempt+same-origin via `GET /api/auth/session-token` (`local/index.ts:2047-2051`), enforced by **token equality, no user lookup** (`security-middleware.ts:340-377`). Adapter attaches `Authorization: Bearer` + 401→refresh→retry for process rotation (`adapter.ts:298-337,444-470`). **This IS the design's "account is optional, runs fully local."** | **REAL (not a login)** |
| **Clerk in `apps/web`** | **ZERO.** grep `@clerk` in `apps/web/package.json` → none; `ClerkProvider`/`SignIn`/`useSignIn` across `apps/web/src` → 0 files. No `/auth` route. Clean slate. | **ABSENT** |
| **Clerk elsewhere (REAL, not PR7's surface)** | Cloud server verifies Clerk JWTs (`plugins/auth.ts:3,21,31-48` verifyToken→`users` table keyed by `clerkId`); `apps/www` has full themed Clerk UI (`<SignIn/>` catch-all, `ClerkProvider` + Hive appearance map, `apps/www/app/layout.tsx:6-93`). Local sidecar references Clerk only as `isTeamMode = !!process.env.CLERK_SECRET_KEY` to toggle a session timeout (`security-middleware.ts:307-309`). | **REAL (cloud/www only)** |
| **Desktop "identity"** | Free-text **name** from IdentityLayer (onboarding), surfaced as `HomeBriefing.userName` (`home.ts:261-270`) + sidebar row via `adapter.getIdentity()` degrading to "Account"/"W" when blank (`AppShell.tsx:98-104,321`). **Identity, NOT auth** — must never render as "signed in." | **REAL (identity ≠ account)** |
| **Stripe backend (local sidecar)** | `stripeRoutes` registered (`local/index.ts:128,2161`); all gate on `STRIPE_SECRET_KEY`**503 `STRIPE_NOT_CONFIGURED`** when absent (`stripe/index.ts:25-43`). `create-checkout-session` REAL (hosted Checkout, `mode:subscription`, PRO/TEAMS, period-aware, returns `{url}`, `checkout.ts:16`). `create-portal-session` REAL (hosted Customer Portal, `requireTier('PRO')`, needs `config.json.stripe_customer_id` else 400 `NO_STRIPE_CUSTOMER`, `portal.ts:15,32`). `sync` REAL + payment-gated (`sync.ts:46-49`), returns only `{tier,customerId}` (`sync.ts:83`). Webhook **flips tiers** (payment-gated, signature-verified, idempotent, TOCTOU-serialized; 17/17 tests green per CLAUDE.md §10 E-10; `webhook.ts:114-158`). | **REAL** |
| **No invoice / payment-method / sub-detail routes** | grep `invoice`/`paymentMethod`/`invoices.list`/`paymentMethods` in `packages/server/src`**0 product hits**. | **MUST-BUILD or defer to Portal** |
| **Billing FE** | Fully wired: `adapter.createCheckoutSession/createPortalSession/syncStripeCheckout/getTier` (`adapter.ts:2658-2685`) + `useBilling` hook orchestrates the happy path incl. `?session_id=` auto-sync (`useBilling.ts:104-115`) + the `tierResolved` honesty guard (`useBilling.ts:16-46`). Settings → **"Plan" tab** is already the honest Manage surface: real tier badge, upgrade buttons → `startCheckout`, "Manage Subscription" → `openPortal` (`SettingsApp.tsx:511-655`, `:621-635`). | **REAL** |
| **Routing** | All 28 screens are children of one `<Route path="/" element={<AppShell/>}>` (`App.tsx:52-110`). No `/auth`, `/billing`, `/payment-success` routes (grep: 0). `SettingsApp` inits `activeTab='models'` with **no `?tab=` reader** (`SettingsApp.tsx:53`) → `/settings?tab=billing` silently opens Models. Breadcrumb label sourced only from `dock-tiers` via `matchNavRoute` (`AppShell.tsx:224-229`). | mixed |
| **Price env contract** | Dual: 4-var (`STRIPE_PRICE_PRO_MONTHLY/_ANNUAL/_TEAMS_*`) + legacy single-var, resolved by `tierFromPriceId`/`priceIdForTier` (`stripe/index.ts:71-100`). Live+test Stripe accounts hold the prices per CLAUDE.md §10 M7. | **REAL (EXTERNAL-DEP at runtime)** |
**Net:** **Screen 14 (Billing) is ~90% real** — a themed re-skin of the existing `useBilling`→Stripe-hosted flow + four small builds (annual toggle wiring, `?tab=` deep-link, `/payment-success` route, gated Success/Manage detail). **Screen 13 (Auth) is overwhelmingly net-new + EXTERNAL-DEP + decision-gated** — no Clerk SDK, no login UI, no real user session anywhere the desktop can reach.
### 2.5 Recommended sub-PR split (by risk shape — the core structural recommendation)
Auth and Billing have **opposite risk shapes**: Billing is reskin-over-real with a localized blast radius and no new dependency; Auth is a net-new pre-shell route + an EXTERNAL Clerk-key dependency + an unproven Tauri-WebView OAuth question. Coupling them in one PR would block the de-risked Billing work behind the founder's Clerk-architecture decision and key provisioning. **Recommend a 2-way split, ordered by value/risk:**
| Sub-PR | Screen | Scope theme | Why this grouping | Effort |
|---|---|---|---|---|
| **PR7a — Billing themed over existing Stripe** | **14 Billing** | Reskin the Settings → Plan tab (and/or a standalone `/billing`) to the §14 4-state layout over the *already-real* `useBilling`→hosted-Checkout/Portal flow. Adds: annual-toggle wiring, `?tab=` deep-link, `/payment-success` route. **No new dependency.** | **Lower risk, ships standalone, gated only on the §0 BYO/metered call** (which is a copy decision under Option A, not a code blocker — the flow supports either). Hosted Checkout/Portal means **zero fabricated billing data**. | **SM** |
| **PR7b — Auth (Clerk) pre-shell route** | **13 Auth** | New top-level **sibling route outside AppShell** + (if Option b) `@clerk/clerk-react` provider themed to warm tokens, with a hard **no-key → local-first accountless** fallback. | **Higher risk + EXTERNAL-DEP-blocked** (Clerk publishable key) + an architectural decision (cloud-only vs optional-sign-in vs full-gate) + an unproven Tauri-WebView OAuth spike. Isolating it keeps the Clerk dependency + the pre-shell routing risk off Billing. | **ML** (or **S** if cloud-only/handoff) |
**Sequencing:** PR7a first (de-risked, no dependency, near-zero new backend, ships the upgrade funnel). PR7b second (carries the Clerk dependency + the architecture call). Each is independently revertible. **If the founder defers the Clerk-architecture decision or the key, PR7a still ships alone** — Billing is not blocked by Auth.
---
## 3. Decisions — **NEED FOUNDER RATIFICATION before feature code** (recommend-and-proceed unless you object; D1/D2/D5 are strategic)
| # | Decision | Recommended | Why |
|---|---|---|---|
| **D1** | **§0 BYO-key vs Waggle-metered** (the strategic gate) | **Ratify Option A — BYO-key + flat subscription** | The codebase already committed end-to-end (`ModelGate.tsx:186`, `checkout.ts:40`, zero metering plumbing). Option B is a multi-month pivot that contradicts the shipped "your key never leaves your machine" promise. Founder strategic call; everything else assumes A. |
| **D2** | **Clerk architecture for the desktop** (cloud-only vs optional-sign-in vs full-gate) | **Option (b): optional Clerk in the SPA, accountless default; sign-in unlocks sync/Teams/billing. REJECT full-gate.** | The only option consistent with the design copy ("account is optional", `auth.html:98`), the accountless sidecar (`local/index.ts:2047-2052`, `settings.ts:309-331`), the existing `useBilling` tier flow, and the prior ratified "Tauri Clerk = Phase 2 fast-follow, NOT Day 0" (brief 2026-05-03:225). Full-gate (c) breaks accountless boot — do not build. Founder strategic call (governs the whole-SPA provider wrapping in `main.tsx`). |
| **D3** | **Sub-PR split** (§2.5) | **Ship the 2-way split: PR7a (Billing) → PR7b (Auth)** | Opposite risk shapes; coupling blocks de-risked Billing behind the Clerk decision + key. Billing has no new dependency; Auth is EXTERNAL-DEP + architecture-gated. Independently revertible. Founder structural call. |
| **D4** | **Auth surface scope** — browser/cloud-first vs in-WebView Tauri sign-in now | **Browser/cloud-first; treat in-WebView Tauri sign-in as an explicit later deep-link/OAuth spike** | Clerk hosted OAuth/redirect inside a Tauri WebView is **unproven** and the prior brief deferred it (brief:225); the `apps/www` browser pattern is proven. Determines whether PR7b must also solve Tauri deep-link OAuth (large, risky) or reuse the proven browser provider (small). |
| **D5** | **Checkout UI** — custom in-app card form vs hosted Stripe Checkout | **Hosted Stripe Checkout redirect (reuse `createCheckoutSession`); theme the "Checkout" segment as an order-summary that hands off** | `SCREENS.md:294` mandates "use Stripe Checkout where possible"; the backend only emits a hosted URL (`checkout.ts:39-51`); `apps/web` has **zero** Stripe.js/PaymentElement (grep 0). A real PAN field raises PCI scope SAQ-A → SAQ-A-EP for zero functional gain. The `billing.html` card form is a mockup of Stripe's page, not a spec. **Both a fabrication trap and a PCI trap — do not hand-build.** |
| **D6** | **Manage state** — in-app invoice/payment-method/cancel vs hosted Customer Portal | **Hosted Customer Portal launchpad (reuse `createPortalSession`); themed current-plan header in-app, all mutations + invoices + payment method via Portal** | Portal natively covers payment-method update, invoice PDFs, plan change, cancel. No invoice/payment-method/sub-detail route exists (grep 0). `SettingsApp.tsx:621-635` already does exactly this. Building in-app Stripe write-APIs is scope+fabrication risk we don't need. (If the founder *explicitly* wants in-app rendering: build new sidecar routes against `stripe.invoices.list`/`paymentMethods.list` and render **strictly from live data** — never placeholders.) |
| **D7** | **Billing registration** — standalone `/billing` vs Settings "Plan" tab | **Keep the Settings → Plan tab as primary (it's fully wired to real Stripe); reskin it to the §14 layout + add a `?tab=` reader. Add a standalone `/billing` (PR6 wrapper pattern, AppShell child) only if design wants the full-screen funnel outside Settings chrome — reusing `useBilling` verbatim** | The Plan tab is real and tier-honest today. A `?tab=billing` deep-link inherits the "Settings" breadcrumb automatically (`dock-tiers.ts:108`). Standalone is optional and must add its own `dock-tiers` route+label entry (don't repeat PR6's label-less surfaces). |
| **D8** | **Monthly/Annual 20% toggle** — honor at checkout vs display-only | **Build it: thread `billingPeriod` through `adapter.createCheckoutSession` + `useBilling.startCheckout`** | Backend `priceIdForTier(tier, 'annual')` already resolves the annual price (`stripe/index.ts:91-100`) but the adapter drops the arg (`adapter.ts:2667`), so it always charges monthly. A toggle that visibly changes price but charges monthly is a **trust/billing bug**. Small FE change; needs `STRIPE_PRICE_*_ANNUAL` envs set (EXTERNAL-DEP). |
| **D9** | **Success state** — render the receipt block vs confirmation-only | **Confirmation off the synced tier only ("You're Pro"); GATE OFF the receipt rows; drive any trial line from real `trialDaysRemaining`** | `sync` returns only `{tier,customerId}` (`sync.ts:83`) — the receipt #, "Trial ends Jun 28", "Then $19", "Emailed →" have **no data source** and would be fabricated. The post-redirect `/payment-success` URL is real (`checkout.ts:42`) but needs a route (D11). |
| **D10** | **Plan-card prices** — hardcode copy vs machine-readable source | **Hardcode the display copy ($0/$19/$49-seat, $15/$39 annual) from `tiers.ts:7-12` in ONE constant, treat as copy not fact; never invent per-seat math beyond what `tiers.ts` documents** | Only `stripePriceId` is a real field in `TIER_CAPABILITIES`; the dollar amounts are doc-comment copy. The *charged* amount/tax must come from Stripe's hosted page, never asserted by our UI. Prices match `billing.html` exactly today. |
| **D11** | **`/payment-success` (+ `/payment-cancelled`) route** | **Add `/payment-success` as an AppShell child (user is back inside the app post-checkout) using the PR6 wrapper pattern; reuse `useBilling`'s `?session_id=` auto-sync** | `checkout.ts:42` sets `success_url=/payment-success?session_id=…` but no such route exists (grep 0); today `?session_id=` is only read on whatever page is mounted. Small MUST-BUILD. |
| **D12** | **`?tab=` deep-link reader in `SettingsApp`** | **Add a small `?tab=` initializer (read once on mount, snap `activeTab`); retarget ⌘K "Upgrade to Pro"/"Settings" + `UpgradeModal`/`TrialExpiredModal` fallbacks to `/settings?tab=billing`** | `SettingsApp` hardcodes `activeTab='models'` with no reader (`SettingsApp.tsx:53`); the existing `routes.ts:52` backup deep-link is already broken the same way. Without it every upgrade entry lands on Models, not Plan. |
| **D13** | **OTP fidelity** (PR7b) — themed prebuilt Clerk vs bespoke 6-box widget | **Themed prebuilt `<SignIn/>`/`<SignUp/>` for v1 (accept Clerk's built-in code step); upgrade to `useSignIn` custom flow only if review demands the exact 48×56 honey OTP boxes** | `SCREENS.md:277` literally says "Build with Clerk components themed to the tokens." Prebuilt = small; custom flow = a whole hand-built sign-in/verify state machine with its own error handling + a11y. |
| **D14** | **Clerk appearance** (PR7b) — shadcn theme vs full manual map | **shadcn theme (`@clerk/themes` shadcn) auto-reading the warm shadcn vars PR1 repointed (`index.css:20,29,46`) + a thin `variables` override for honey + Hanken; copy the `as const` gotcha avoidance from `apps/www/layout.tsx:31-34`** | `components.json` exists, so theming is mostly automatic + token-driven. A full manual hex map duplicates ~60 lines/component and drifts from the token system. Copy the *pattern*, not the *cooler `apps/www` hex*. |
| **D15** | **SSO/enterprise panel** (PR7b) — live SAML vs sales CTA | **Keep it a custom "Talk to sales → Teams/KVARK" CTA; do NOT implement live SAML/SCIM** | The design panel (`auth.html:148`) is a sales CTA, not a live form; SAML/SCIM/audit are Teams/KVARK/Clerk-Enterprise features. Matches the KVARK funnel. |
| **D16** | **EXTERNAL-DEP provisioning** | **Founder provides `VITE_CLERK_PUBLISHABLE_KEY` (reuse the existing `apps/www` instance key) + confirms Google/Apple social connections in the Clerk dashboard; confirms the sidecar runtime env carries `STRIPE_SECRET_KEY` + the 4 price IDs + `STRIPE_WEBHOOK_SECRET`** | Without the Clerk key, PR7b degrades to the accountless local-first state (honest). Without the Stripe envs, all `/api/stripe/*` return 503 and Billing must render an honest "not configured" disabled state. See §6. |
| **D17** | **Warm-token sweep** | **In every sub-PR, sweep the touched files** — Auth/Billing must use `--honey`/`--surface`/`--line-strong`/`--r` from `waggle.css` (already landed by PR1, `BUILD-PLAN §3.1`); honey button-fg `#1a1407` = PR1's `--primary-foreground` | Both screens are heavily token-dependent; one pass keeps the "single honey accent" honest. No new tokens needed. |
---
## 4. Architecture
### PR7a (Billing — reskin over REAL Stripe; DO NOT recreate `useBilling`, the adapter Stripe layer, or the server `stripe/*` routes)
- **Reskin** `SettingsApp.tsx:511-655` (Plan tab) to the §14 segmented **Plans / Checkout / Success / Manage** layout, warm tokens (D17). Reuse the existing tier card + `useBilling` + `tierResolved` honesty guard verbatim.
- **Plans:** 3 cards from `tiers.ts:7-12` copy (D10) + Monthly/Annual toggle (D8); current-plan marker from `useBilling().tier` honoring `tierResolved`; "Choose Pro/Teams" → `startCheckout(tier, period)`.
- **Checkout segment:** themed order-summary that redirects to **hosted Stripe Checkout** (D5) — no in-app PAN field.
- **Success:** confirmation off synced tier (D9); receipt block gated off.
- **Manage:** themed current-plan header + single "Manage via Stripe" → **hosted Customer Portal** (D6); no in-app invoice/payment-method rows.
- **Wiring (the small builds):** thread `billingPeriod` through `adapter.createCheckoutSession` (`adapter.ts:2667`) + `useBilling.startCheckout` (D8); add `?tab=` reader to `SettingsApp` (D12); add `/payment-success` AppShell child route via the PR6 wrapper pattern (D11); retarget ⌘K + modal fallbacks (D12). **Optional** standalone `/billing` (D7) follows the PR6 `BenchmarkRoute.tsx:1-11` pattern (AppShell child).
- **Backend:** none under Option A (D1) + hosted Checkout/Portal (D5/D6). Only if the founder picks in-app Manage rendering do new `invoices.list`/`paymentMethods.list` sidecar routes land → `tsc -p packages/server`.
### PR7b (Auth — net-new pre-shell route + optional Clerk; the `clerk-setup`/`clerk-react-patterns`/`clerk-custom-ui`/`clerk-billing` skills apply)
- **`/auth` is the ONE pre-shell route** — register as a **top-level sibling OUTSIDE `<Route path="/" element={<AppShell/>}>`** (`App.tsx:52`), no Sidebar/StatusBar/ChatHost/boot gate. `ThemeProvider` already wraps `<BrowserRouter>` (`App.tsx:53`) so warm tokens apply outside the shell. Use a plain `AppErrorBoundary` (`onClose → window.location.reload()`), **NOT `SurfaceBoundary`** (its `onClose → /home` assumes an authenticated home, `SurfaceBoundary.tsx:13`). No ⌘K entry (pre-login). No breadcrumb needed (renders outside AppShell, so StatusBar never mounts).
- **Custom layout (MUST-BUILD, no backend):** split brand panel + pitch + trust lines + local-first banner + enterprise→sales CTA (D15). All copy verbatim in `recon 03 §2/§4`.
- **Clerk (Option b, D2):** add `@clerk/clerk-react` + `@clerk/themes`; wrap `<App/>` in `main.tsx:18` with a **no-key guard** — if `import.meta.env.VITE_CLERK_PUBLISHABLE_KEY` is undefined, render children WITHOUT `ClerkProvider` and show the accountless local-first state (never crash, never fabricate a user). Themed via the shadcn appearance approach (D14). Prebuilt `<SignIn/>`/`<SignUp/>` themed (D13); `<UserButton/>`/`useUser()` may re-feed the sidebar `userName` row (currently `null`→"Account").
- **`getToken()` seam:** `useAuth().getToken()` returns the Clerk JWT in exactly the `Bearer` shape the adapter (`adapter.ts:445`) + cloud `plugins/auth.ts:31` already consume — an *optional* sign-in can swap the device token for a user JWT **only when the user opts into cloud/Teams**, leaving the accountless local path untouched. Do NOT make Clerk the API authorizer on the local path (regresses local-first).
---
## 5. No-fabrication contract — every place PR7 could invent identity or money (gated off)
PR7 renders identity and money. Each item below has **no honest data source today** and **must be gated off, never invented** (carry the PR3/PR3.5 `tierResolved`/`userName=null` precedent):
| # | Fabrication trap | Why it's fake | Gate (the honest behavior) |
|---|---|---|---|
| **F1** | **A logged-in identity that isn't real** (name/email/avatar, "Signed in as…") | The desktop has no Clerk session; "identity" is a memory-derived IdentityLayer name (`AppShell.tsx:98-104`). | With no `VITE_CLERK_PUBLISHABLE_KEY`, render the **accountless local-first** state; the sidebar `userName=null`→"Account"/"W" fallback is the accepted honest UI (BUILD-PLAN §9). Only populate from a **real** `useUser()`. |
| **F2** | **Fake invoices / PDF receipts** (Manage: "Invoice · Paid · PDF", `billing.html`/`SCREENS.md:292`) | No invoice route exists (grep 0). | Route to the hosted **Customer Portal** (`openPortal`); never render invented invoice rows or fake PDF links. No customer → empty/portal state, not stub rows. |
| **F3** | **Fake payment method ("VISA ···4242")** (`SCREENS.md:291`) | The `4242` is literally Stripe's **test PAN**, design filler; no payment-method route. | Render only from live `stripe.paymentMethods.list` (if D6 in-app path) else **Portal-only**; never hardcode a masked card. |
| **F4** | **Fake "next charge" / renewal / billing-cycle date** ("renews Jul 14", `SCREENS.md:291-292`) | Not returned by `sync` (`sync.ts:83`) or `getTier`. | Derive from a real subscription fetch or **omit**; trial dates only via real `trialDaysRemaining`/`trialStartedAt`. |
| **F5** | **Itemized Success receipt** ("$19 charged", receipt #, "Emailed →", `billing.html`) | `sync` returns only `{tier,customerId}`. | Generic "You're Pro" confirmation off the synced tier; receipt rows gated off (D9). |
| **F6** | **Custom in-app card form** (email/card 4242/expiry/CVC) | Backend only emits a hosted URL; no Stripe.js in `apps/web` (grep 0). A real-looking PAN that doesn't tokenize is fake AND a PCI-scope trap (SAQ-A → SAQ-A-EP). | **Never collect raw PAN in-app**; redirect to hosted Stripe Checkout (D5). |
| **F7** | **Tier shown as FREE before resolution** | `useBilling` defaults to `'FREE'` as a placeholder, not a fact. | Honor `tierResolved` (`useBilling.ts:16-46`) — render the unresolved state, not the FREE upgrade grid, until a real `getTier()` round-trip succeeds. |
| **F8** | **A working checkout when Stripe is unconfigured** | All `/api/stripe/*` 503 without `STRIPE_SECRET_KEY` (`index.ts:25-43`). | Render an honest "not configured" disabled state, not a clickable fake "Subscribe". |
| **F9** | **Fake annual price** (client-side `$19 × 0.8`) | The charged amount must match Stripe. | The 20% toggle must resolve through the real annual price var (`priceIdForTier(tier,'annual')`, D8), not a cosmetic client number; the hosted page renders the true amount/tax. |
| **F10** | **Fake SSO success** ("Signed in with Google") + **fake OTP verification** | The `auth.html` demo advances on any input (`auth.html:138,164`); SSO buttons "succeed" with no provider. | SSO/OTP must perform a **real Clerk redirect/verification**; with no Clerk wired, disable / route to `apps/www` / show the local-first path — never simulate a session. |
| **F11** | **SAML/SCIM rendered as a live form** | Enterprise panel names SAML/SCIM/audit (`auth.html:148`) — Teams/KVARK/Clerk-Enterprise features. | Keep a **sales CTA** (D15); do not render a SAML form that does nothing. |
| **F12** | **Usage/credits balance** ("$N remaining", "X of Y tokens") | Only relevant if Option B; no ledger exists, `/api/cost/summary` is **estimate-only** + **advisory** (`cost.ts:162-169` enforces nothing). | Under Option A this surface isn't built; the estimate dashboard must not be dressed up as a metered balance or imply requests are capped. |
| **F13** | **Hardcoded demo identity** (`mara@egzakta.com` / "Mara Kovač", `auth.html:113,127,136`; `billing.html` name-on-card) | Pure mock placeholders. | Never pre-fill or display the demo user; no real account email exists pre-Clerk. |
---
## 6. External dependencies — founder must supply (EXTERNAL-DEP blockers)
| Dep | Needed by | Blocker behavior if absent (the honest fallback) |
|---|---|---|
| **`VITE_CLERK_PUBLISHABLE_KEY`** (reuse existing `apps/www` instance key) | PR7b Clerk SPA sign-in | No key → `/auth` degrades to the **accountless local-first** state ("continue without an account → Home"); never a fake identity (F1/F10). Desktop default path needs **no key**. |
| **Google/Apple social connections** enabled in the Clerk dashboard | PR7b SSO buttons | Absent → SSO buttons disabled / "coming soon" / route to `apps/www`; never fake a session (F10). |
| **`STRIPE_SECRET_KEY` + 4 price IDs (`STRIPE_PRICE_{PRO,TEAMS}_{MONTHLY,ANNUAL}`) + `STRIPE_WEBHOOK_SECRET`** in the sidecar runtime env | PR7a checkout/portal/sync/webhook | Absent → all `/api/stripe/*` return **503 `STRIPE_NOT_CONFIGURED`**; Billing renders an honest "not configured" disabled state (F8). Live+test accounts already hold the prices (CLAUDE.md §10 M7) — the secret + IDs must be present at runtime. |
| **`config.json.stripe_customer_id`** (written only after a real paid checkout/webhook, `webhook.ts:60`) | PR7a Manage → Portal | Absent → `create-portal-session` returns **400 `NO_STRIPE_CUSTOMER`** (`portal.ts:32`); Manage must show the pre-subscription state, not a broken portal jump. |
| **§0 BYO-vs-metered decision (D1)** | Plans copy + whether any Usage/metering surface is implied | Unsettled → do not write the Plans/Usage copy that commits to metered; the flow supports either but the positioning must be chosen. |
| **Clerk architecture decision (D2)** + **Tauri-WebView OAuth spike (D4)** | PR7b shape | Unsettled → PR7b cannot start; PR7a still ships independently. |
---
## 7. Phased plan (TDD; commit per phase; FE `tsc -p apps/web/tsconfig.app.json` + vitest each)
**PR7a — Billing** (gated on D1 copy decision; no new dependency)
- **A1 — Wiring prerequisites.** `?tab=` reader in `SettingsApp` (D12); thread `billingPeriod` through adapter + `useBilling.startCheckout` (D8); add `/payment-success` AppShell child route (D11); retarget ⌘K + `UpgradeModal`/`TrialExpiredModal` fallbacks (D12). Unit tests (deep-link snaps to Plan; checkout sends period; success route auto-syncs on `?session_id=`).
- **A2 — Plan tab reskin.** §14 Plans/Checkout/Success/Manage segmented layout over `useBilling`; warm tokens (D17); 3 cards + annual toggle (D10/D8); Checkout = hosted-redirect summary (D5); Success = confirmation only, receipt gated (D9/F5); Manage = current-plan header + Portal launchpad (D6/F2/F3/F4); honor `tierResolved` (F7) + 503 "not configured" state (F8). Extend `SettingsApp` tests (renders unresolved state; gated receipt/invoice/payment-method; honey "Most popular").
- **(optional) A3 — Standalone `/billing`** (D7) only if design needs the full-screen funnel — PR6 wrapper pattern, reuse `useBilling`, add a `dock-tiers` route+label entry.
**PR7b — Auth** (gated on D2 architecture + D16 Clerk key; can defer/ship cloud-only)
- **B1 — Pre-shell `/auth` route + custom layout.** Sibling route outside AppShell (`AppErrorBoundary`, not `SurfaceBoundary`); split brand panel + pitch + trust lines + local-first banner + enterprise sales CTA (D15); all copy verbatim. Unit test (renders outside shell; no fabricated identity with no key — F1/F13).
- **B2 — Clerk provider + themed components** (Option b). `@clerk/clerk-react` + `main.tsx` wrap with no-key guard; shadcn appearance + warm override (D14); prebuilt themed `<SignIn/>`/`<SignUp/>` (D13); SSO/OTP perform real Clerk flows or degrade honestly (F10/F11). Optional `getToken()`→Bearer cloud-sync seam (do not make Clerk the local API authorizer). Tests (no-key → accountless; real `useUser()` only).
**Each sub-PR ends with:** adversarial review (correctness / security / honest-identity-and-money / design-fidelity) + live smoke (dark + light, 0 console errors).
---
## 8. Honesty log — every drift the recon found (verified vs live)
- **The two-stack split is the master correction.** PR7 builds in `apps/web` → the **local sidecar** (config.json tier, **no Clerk**). The rich Clerk-linked Stripe flow + real Clerk UI are **only** in `apps/www` + the cloud server — PR8 territory, not reachable from the desktop. Conflating them would falsely assume Clerk identity + lazy-customer billing are "already available." (`recon 01 §0`, `recon 02 §0`, `recon 03 §0`, `recon 05 §1-3`.)
- **"Auth" today is a per-process machine bearer token, not a login.** `wsSessionToken` (`local/index.ts:1409`) enforced by token equality (`security-middleware.ts:340-377`); the only "session lifecycle" is a silent 401→refresh for process rotation (`adapter.ts:316-337`). There is no signin/signup/logout anywhere in `apps/web`. `LoginBriefing.tsx` is the **overnight-work overlay**, not auth.
- **Desktop "identity" ≠ account.** IdentityLayer name (`AppShell.tsx:98-104`) is a memory record typed in onboarding — it must **never** render as "signed in" (F1).
- **Billing is ~90% real and tier-honest already.** `useBilling` + adapter + `stripe/*` all wired; the Settings Plan tab already does FREE/TRIAL→`startCheckout` and PRO/TEAMS→`openPortal` with the `tierResolved` guard. Screen 14 is a reskin, not a rebuild — the biggest risk is mistaking the real flow for net-new.
- **The `billing.html` card form + invoice table + "VISA ···4242" + receipt rows are fidelity MOCKUPS of Stripe's hosted surfaces**, not a spec. `SCREENS.md:294` says "use Stripe Checkout/Customer Portal where possible." A custom PAN form is both fabrication (F6) and a PCI-scope jump.
- **The annual toggle is a real FE build gap, not a styling gap.** Backend `priceIdForTier(tier,'annual')` resolves the annual price (`stripe/index.ts:91-100`) but the adapter drops `billingPeriod` (`adapter.ts:2667`) → the design's 20% toggle would visibly change the price label while charging monthly (a billing bug) unless wired (D8/F9).
- **`/settings?tab=billing` silently opens Models.** `SettingsApp` hardcodes `activeTab='models'` with no `?tab=` reader (`SettingsApp.tsx:53`); the existing `routes.ts:52` backup deep-link is already broken the same way. Upgrade buttons currently land on Models, not Plan (D12).
- **`/payment-success` is a dangling success_url.** `checkout.ts:42` redirects to a route that doesn't exist (grep 0); today `?session_id=` is only read on whatever page happens to be mounted (D11).
- **DESIGN_POV said the design "supports either but commits to neither" (`DESIGN_POV.md:70`) — but the IMPLEMENTATION has since committed to Option A** (BYO-key + flat subscription) end-to-end. The §0 gate is now reconciling copy with shipped reality, not choosing from scratch.
- **The `auth.html` top `.controls` segmented switcher is concept-harness scaffolding** for previewing the 4 states — NOT product UI. In the real build, state is route/Clerk-flow-driven.
- **`@clerk/react` is in `node_modules` only as a transitive dep of `apps/www`'s `@clerk/nextjs`** — NOT an `apps/web` dependency. PR7b must add `@clerk/clerk-react` explicitly. The correct skill is `clerk-react-patterns` (RR6 SPA), **NOT** `clerk-react-router-patterns` (RR7 framework mode — does not apply, `recon 05 §10`).
- **`apps/www`'s themed Clerk uses the OLD cooler Hive hex** (`#08090c`/`#e5a000`, `apps/www/layout.tsx:38-44`); copy the *pattern*, not the hex — `apps/web` is warm (`#14110b`/`#e9a52c`, `index.css:20,29`). Copy the `as const` gotcha avoidance (`layout.tsx:31-34`).
- **No `KvarkNudge` component exists in `apps/web/src`** (grep 0; CLAUDE.md §9 references it but it isn't in the web app) — the Enterprise CTA is the inline `kvark.ai` link (`SettingsApp.tsx:644-651`); the Auth enterprise panel (D15) reuses that funnel as a sales CTA.
</content>
</invoke>