174 lines
36 KiB
Markdown
174 lines
36 KiB
Markdown
# 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` (PR1–PR6 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).** D5–D17 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 PR3–PR6, 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**. | **S–M** |
|
||
| **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. | **M–L** (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>
|