This commit is contained in:
116
docs/redesign-warm-hive/pr7-recon/01-stripe-backend.md
Normal file
116
docs/redesign-warm-hive/pr7-recon/01-stripe-backend.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# PR7 Recon · 01 — Stripe / Billing BACKEND state
|
||||
|
||||
**Scope:** What is REAL vs MUST-BUILD for the 4 billing screen states (Plans / Checkout / Success / Manage) of **screen 14 (Billing)** — plus the auth-identity context that screen 13 (Auth) and "Manage" lean on.
|
||||
**Method:** read + grep only, no product code changed. Every claim cites `file:line`.
|
||||
**Target surface:** PR7 builds in **`apps/web`** (the React desktop cockpit) → talks to the **local Fastify sidecar** (`packages/server/src/local/index.ts`). This is a DIFFERENT server entry from the cloud/`apps/www` stack, and that distinction is the single most important fact below.
|
||||
|
||||
---
|
||||
|
||||
## 0. The two-stack split (read this first)
|
||||
|
||||
There are **two** server entry points and **two** Stripe/auth integrations. They must not be conflated:
|
||||
|
||||
| | **Local sidecar** (PR7 target) | **Cloud server / `apps/www`** (PR8 / not PR7) |
|
||||
|---|---|---|
|
||||
| Entry | `packages/server/src/local/index.ts` | cloud: `packages/server/src/index.ts`; landing: `apps/www/app/**` |
|
||||
| Tier source | `config.json` on disk (`assert-tier.ts:21-32`) | Clerk `publicMetadata` + cloud DB |
|
||||
| Auth | **none** — no Clerk plugin registered in `local/` (grep: 0 hits for `plugins/auth` in `src/local`) | Clerk: `packages/server/src/plugins/auth.ts` (only imported by `src/index.ts`); `apps/www` uses `@clerk/nextjs` |
|
||||
| Stripe routes | `packages/server/src/stripe/**` (checkout/portal/sync/webhook) | `apps/www/app/api/stripe/checkout/route.ts` + `app/api/webhooks/stripe/route.ts` |
|
||||
| Stripe ↔ identity link | customer id stored in **`config.json` `stripe_customer_id`** (`webhook.ts:60`, `portal.ts:28`) | customer id stored in **Clerk `publicMetadata.stripeCustomerId`** (`apps/www/.../checkout/route.ts:94-97`) |
|
||||
|
||||
`apps/web` consumes the **local sidecar** (adapter base = local URL; `apps/web/src/lib/adapter.ts` calls `/api/stripe/*`). So **PR7's backend is the `packages/server/src/stripe/**` set, NOT the richer `apps/www` Clerk-linked flow.** The `apps/www` flow (full Clerk identity + lazy Stripe Customer + lookup_key price resolution) is PR8 territory and should not be assumed available in the desktop cockpit.
|
||||
|
||||
---
|
||||
|
||||
## 1. Stripe backend inventory (local sidecar — the PR7 surface)
|
||||
|
||||
All routes registered via `stripeRoutes` at `packages/server/src/local/index.ts:128,2161`. All gate on `STRIPE_SECRET_KEY`; absent → **503 `STRIPE_NOT_CONFIGURED`** (`index.ts:25-43`, each route).
|
||||
|
||||
| Route | File:line | What it does | REAL? |
|
||||
|---|---|---|---|
|
||||
| `POST /api/stripe/create-checkout-session` | `checkout.ts:16` | Hosted Stripe Checkout session (`mode:'subscription'`), PRO/TEAMS only, period-aware price, returns `{url}` | **REAL** |
|
||||
| `POST /api/stripe/create-portal-session` | `portal.ts:15` | Stripe **Customer Portal** session, `requireTier('PRO')` gated, needs `config.json.stripe_customer_id`, returns `{url}` | **REAL** |
|
||||
| `POST /api/stripe/sync` | `sync.ts:21` | Poll-confirm after redirect (desktop behind NAT), retrieves session, **payment-gated** (`status==='complete' && paid`, `sync.ts:46-49`), writes tier to config | **REAL** |
|
||||
| `POST /api/stripe/webhook` | `webhook.ts:74` | Signature-verified lifecycle handler — **flips tier in config.json** | **REAL** |
|
||||
| `GET /api/tier` | `settings.ts:322` | Authoritative tier read (+ `trialDaysRemaining`, `trialExpired`) | **REAL** |
|
||||
| `PATCH /api/tier` | `settings.ts:363` | Dev/testing tier override ("will be replaced by Stripe webhook") | REAL (dev) |
|
||||
| `POST /api/tier/start-trial` | `settings.ts:401` | Atomic 15-day trial start | REAL |
|
||||
| **invoice list** | — | **does not exist** (grep `invoice`/`invoices.list` in `packages/server` → 0 product hits; only a chat-keyword at `chat-helpers.ts:15`) | **MUST-BUILD** |
|
||||
| **payment-method read/update** | — | **does not exist** (grep `paymentMethod`/`payment_method` → 0 hits) | **MUST-BUILD (or defer to Portal)** |
|
||||
|
||||
**Adapter methods already on the FE** (`apps/web/src/lib/adapter.ts`): `createCheckoutSession` (2667), `createPortalSession` (2675), `syncStripeCheckout` (2659), `getTier` (2682). The **`useBilling` hook already orchestrates the whole happy path** — `apps/web/src/hooks/useBilling.ts`: `startCheckout` opens the URL (69-81), `openPortal` opens the portal URL (84-96), `syncAfterCheckout` confirms (49-66), and it **auto-detects `?session_id=` on mount** to run sync (104-115). It also carries the honesty primitive `tierResolved` (16-21): until a real `getTier()` round-trip succeeds, the default `'FREE'` is a **placeholder, not a fact** — billing surfaces must render an unresolved state, not the FREE card.
|
||||
|
||||
### Webhook DOES flip tiers (REAL)
|
||||
`webhook.ts:114-158` handles three events, all writing tier to `config.json` via `updateUserTier` (52-62):
|
||||
- `checkout.session.completed` → grants tier **only if `payment_status` is `paid`/`no_payment_required`** (122) — unpaid sessions never grant.
|
||||
- `customer.subscription.updated` → re-resolves tier from price id via `tierFromPriceId` (139).
|
||||
- `customer.subscription.deleted` → **downgrades to FREE** (150).
|
||||
Plus real hardening: raw-body signature verification (88-95), idempotency via `.stripe-processed-events.json` + a serialized critical section against TOCTOU double-processing (40-45, 102-165), atomic temp-file writes (25-29). CLAUDE.md §10 (E-10) confirms 17/17 webhook tests green.
|
||||
|
||||
---
|
||||
|
||||
## 2. Price wiring / env contract (REAL, documented)
|
||||
|
||||
`tierFromPriceId` (`index.ts:71-87`) and `priceIdForTier` (`index.ts:91-100`) resolve a **dual env contract**:
|
||||
- **4-var** (matches `apps/www`): `STRIPE_PRICE_PRO_MONTHLY` / `_PRO_ANNUAL` / `_TEAMS_MONTHLY` / `_TEAMS_ANNUAL`.
|
||||
- **legacy single-var** fallback: `STRIPE_PRICE_PRO` / `_TEAMS` / `_BASIC`(→PRO).
|
||||
- final fallback: `TIER_CAPABILITIES[tier].stripePriceId` (`tiers.ts:122,143`, read from `STRIPE_PRICE_PRO`/`_TEAMS` env).
|
||||
|
||||
CLAUDE.md §10 (M7) records both **test (`acct_1SzHlbC0mmjh4oEM`) and live (`CNCrMQy1f7`)** Stripe accounts hold 2 products × 2 prices with lookup_keys `pro_monthly`/`pro_annual`/`teams_monthly`/`teams_annual`; live price IDs in `docs/launch/drafts/2026-05-12-apps-www-deployment-readiness.md`. **The price IDs/secret are EXTERNAL-DEP** (must be present in the sidecar's env at runtime). Pricing on the screen (Solo $0 / Pro $19 / Teams $49-seat, `SCREENS.md:294-295`) is **DERIVABLE** from `tiers.ts` doc-comment (`tiers.ts:7-12`) — but the literal dollar amounts are NOT machine-readable fields in `TIER_CAPABILITIES`; only `stripePriceId` is. So plan-card prices are static copy unless cross-checked against the Stripe dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 3. Per-state verdict (screen 14 · Billing)
|
||||
|
||||
Screen spec: `SCREENS.md:282-295`. Note the explicit instruction: **"Use Stripe Checkout/Customer Portal where possible; theme to tokens."**
|
||||
|
||||
| State | Backend verdict | Evidence / what's needed |
|
||||
|---|---|---|
|
||||
| **Plans** (monthly/annual toggle, 3 cards) | **REAL (read) + DERIVABLE (copy)** | tier from `GET /api/tier` (`settings.ts:322`); current-plan highlight from `useBilling.tier` + `tierResolved`; prices are static copy derivable from `tiers.ts:7-12`. Upgrade buttons call existing `createCheckoutSession`. No new backend. |
|
||||
| **Checkout** (custom card form: email, card 4242, expiry/CVC, country) | **MUST-NOT-BUILD as custom; redirect to HOSTED Checkout (REAL)** | The screen mock shows a **custom card form** (`SCREENS.md:287-289`), but the backend only produces a **hosted Stripe Checkout URL** (`checkout.ts:39-51`). There is **no card-tokenization / PaymentElement / Stripe.js** anywhere in `apps/web` (grep `CardElement`/`PaymentElement`/`4242` → 0 hits). **PCI/scope flag below.** Recommended: the "Checkout" segment is a themed **summary/preview that hands off to hosted Checkout**, not a real PAN field. |
|
||||
| **Success** ("You're Pro", receipt, Manage) | **REAL** | Already wired: `useBilling` auto-runs `syncStripeCheckout` on `?session_id=` (`useBilling.ts:104-115`); sync is payment-gated (`sync.ts:46-49`). The post-redirect `/payment-success` URL is set at `checkout.ts:42`. "Receipt" line-items are **NOT returned by sync** (`sync.ts:83` returns only `{tier, customerId}`) → a real receipt would need a new fetch or Portal link → **MUST-BUILD or show generic confirmation**. |
|
||||
| **Manage** (current plan, switch annual, payment method ···4242, next charge, **invoices PDF**, change/cancel) | **PARTLY REAL via Portal; the in-app detail is MUST-BUILD** | `createPortalSession` (`portal.ts:15`) gives a one-click jump to Stripe's **hosted Customer Portal**, which natively does payment-method update, invoice PDFs, plan change, cancel. **BUT** rendering those *inside* the cockpit (the "VISA ···4242", "next charge", invoice list with Paid+PDF in the mock, `SCREENS.md:291-292`) requires routes that **do not exist**: no invoice-list, no payment-method read, no subscription-detail route on the local sidecar. Also Portal requires `config.json.stripe_customer_id` to already be set (only written after a real paid checkout/webhook, `webhook.ts:60`) else **400 `NO_STRIPE_CUSTOMER`** (`portal.ts:32-34`). |
|
||||
|
||||
---
|
||||
|
||||
## 4. Auth context for screen 13 (and what "Manage" identity rests on)
|
||||
|
||||
Screen 13 spec (`SCREENS.md:269-278`): "Build with **Clerk** components themed to the tokens," with honest "account is optional / local-first" framing.
|
||||
|
||||
- **In `apps/web` (PR7 target): Clerk is NOT present.** grep `@clerk`/`ClerkProvider`/`useUser`/`signIn` in `apps/web` → **0 files**. The local sidecar registers **no auth plugin** (grep `plugins/auth` in `src/local` → 0). So **the desktop has no real logged-in identity** — tier lives in `config.json`, not behind a session.
|
||||
- **Clerk IS wired, but only in the OTHER stack:** root dep `@clerk/fastify` (`package.json:45`) is consumed by `packages/server/src/plugins/auth.ts` (verifyToken + auto-provision) and `ws/gateway.ts:3` — both reachable **only from the cloud entry `src/index.ts`**, not the desktop sidecar. `apps/www` has the full Next.js Clerk surface (`sign-in`, `sign-up`, `account/page.tsx` using `<UserProfile>`, `@clerk/nextjs ^7.3.0` + `@clerk/themes ^2.4.57` in `apps/www/package.json:15-16`).
|
||||
- **Therefore screen 13 in the desktop is EXTERNAL-DEP + design decision, not a wiring task.** Either (a) embed Clerk in `apps/web` for the first time (new provider, new keys, new session model — large, and contradicts "local-first / account optional"), or (b) make screen 13 a **themed informational/SSO-handoff** screen that links to `apps/www` Clerk and keeps the desktop accountless. The honesty contract leans hard toward (b): **do not render a logged-in identity (name/email/avatar) the desktop does not actually have.** (PR1 already flagged the `userName={null}` sidebar row, BUILD-PLAN.md:189.)
|
||||
|
||||
---
|
||||
|
||||
## 5. FABRICATION RISKS — must be gated off, never invented
|
||||
|
||||
1. **Invoices list (Manage).** No invoice route exists. A static "Invoice #1234 · Paid · PDF" list would be **fabricated billing history**. Gate: only show invoices if a real route is built against `stripe.invoices.list(customer)`; otherwise **link out to the hosted Portal** for invoices. (`SCREENS.md:292`)
|
||||
2. **Payment method "VISA ···4242".** No payment-method route. The mock's "···4242" is literally Stripe's test PAN. Hardcoding it = **fake payment method**. Gate: render only from a real `paymentMethods.list`, else Portal-only. (`SCREENS.md:291`)
|
||||
3. **"Next charge" / billing-cycle date.** Not returned by any local route (`sync.ts:83`, `getTier`). Inventing a date = fabrication. Gate: derive from a real subscription fetch or omit.
|
||||
4. **Custom card form (4242 PAN field).** A real-looking PAN/CVC field that doesn't tokenize would be both fake AND a PCI-scope trap (see §6). Gate: never collect raw PAN in-app; hand off to hosted Checkout.
|
||||
5. **Logged-in identity on Auth / sidebar (screen 13).** Desktop has no Clerk session. Showing a real name/email/avatar = fabricated identity. Gate: keep accountless or SSO-handoff; the `tierResolved`-style "unresolved" pattern (`useBilling.ts:16-21`) is the precedent.
|
||||
6. **Tier shown as FREE before resolution.** `useBilling` already guards this with `tierResolved` (`useBilling.ts:36-46`) — the Plans "current plan" badge must honor it, not assume FREE.
|
||||
7. **Receipt on Success.** `syncStripeCheckout` returns no receipt/amount (`sync.ts:83`). A "$19 charged" receipt line would be invented. Gate: generic "You're Pro" confirmation, or Portal link, until a real receipt fetch exists.
|
||||
|
||||
---
|
||||
|
||||
## 6. Custom card form vs hosted — PCI / scope implication (FLAG)
|
||||
|
||||
The screen mock shows a **custom card form** (email, card 4242, expiry/CVC, name, country — `SCREENS.md:287-289`), but the **backend only emits a hosted Stripe Checkout URL** (`checkout.ts:39-51`) and the screen note itself says **"Use Stripe Checkout/Customer Portal where possible"** (`SCREENS.md:294`).
|
||||
|
||||
**Implication:** A real custom PAN form means the card number touches the app's DOM → **PCI-DSS scope jumps from the trivial SAQ-A (hosted/redirect) to SAQ-A-EP or higher**, and would require Stripe.js Elements / PaymentElement client-side tokenization (none exists in `apps/web` today — grep confirms 0). For a Tauri desktop binary this is a material compliance + security burden for zero functional gain over the already-built hosted flow.
|
||||
|
||||
**Recommendation (for the build-plan decision):** treat the Checkout-segment card UI as a **themed visual preview/order-summary that redirects to hosted Stripe Checkout** (reuse `createCheckoutSession`), and treat "Manage" detail (payment method, invoices, cancel) as a **themed launchpad to the hosted Customer Portal** (reuse `createPortalSession`). Build new local routes (invoice-list / subscription-detail) ONLY if founder wants those rendered in-app — and even then, render strictly from live Stripe data, never placeholders.
|
||||
|
||||
---
|
||||
|
||||
## 7. One-line summary per question asked
|
||||
|
||||
- **create-checkout-session route?** YES — REAL (`checkout.ts:16`).
|
||||
- **customer-portal route?** YES — REAL (`portal.ts:15`), but needs `stripe_customer_id` in config first.
|
||||
- **invoice-list route?** NO — MUST-BUILD (or defer to hosted Portal).
|
||||
- **Does the webhook flip tiers?** YES — REAL, payment-gated, idempotent (`webhook.ts:114-158`).
|
||||
- **What does "Manage" need?** Either reuse the hosted Portal (REAL today) OR build 3 new local routes: invoice-list, payment-method, subscription-detail (none exist).
|
||||
- **Hosted vs custom?** Hosted is the intended + already-built path; the mock's custom 4242 form is a PCI-scope trap — flag and prefer hosted.
|
||||
- **Auth screen 13 backend?** Desktop has NO Clerk/identity (EXTERNAL-DEP + design call); Clerk only lives in the cloud server + `apps/www`.
|
||||
129
docs/redesign-warm-hive/pr7-recon/02-auth-session.md
Normal file
129
docs/redesign-warm-hive/pr7-recon/02-auth-session.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# PR7 Recon · 02 — Current auth / session / identity model
|
||||
|
||||
> Recon-only. No product code touched. Every claim cites `file:line`.
|
||||
> Scope: the substrate **PR7 Auth (screen 13, Clerk) and Billing (screen 14, Stripe)** must reconcile with.
|
||||
> Honesty contract (PR3–PR6): every place PR7 could fabricate a logged-in identity, fake invoices/usage/payment methods is flagged for gate-off.
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR — the architecture tension, stated plainly
|
||||
|
||||
Waggle today has **TWO unrelated "auth" systems**, and PR7's screen 13 belongs to neither cleanly:
|
||||
|
||||
1. **The local sidecar (what `apps/web` actually talks to)** authenticates with a **per-process random bearer token** — NOT a user login. There is **no account, no email, no password, no Clerk** on this path. "Who the user is" = a free-text **name** typed into onboarding (IdentityLayer), and "what tier" = a `tier` string in `config.json`. The local app runs **fully without any account** — this is literally true today, which matches the design's "an account is optional — Waggle runs fully local without one."
|
||||
2. **A separate cloud/team Fastify server** (`packages/server/src/{plugins,routes,services,db}/`, distinct from `packages/server/src/local/`) **does** use **Clerk** (`@clerk/fastify`) with a real `users` DB table keyed by `clerkId`. This is the TEAMS/cloud-sync path — it is gated by `CLERK_SECRET_KEY` and is **not wired into the desktop `apps/web` UI at all**.
|
||||
|
||||
**Implication for PR7 Auth (screen 13):** there is **no existing login/signup UI in `apps/web`** and no client-side Clerk dependency. A "Sign in with Clerk" screen is a **MUST-BUILD net-new surface + an EXTERNAL-DEP** (Clerk publishable key + a decision about which server validates the session). The honest framing the design already calls for ("account optional, local-first") is not just copy — it is the actual current architecture, and PR7 must not regress it into a hard auth wall.
|
||||
|
||||
**Implication for PR7 Billing (screen 14):** the **entire Stripe flow already exists and is wired client→server** (checkout, portal, sync, tier read, trial). Billing is mostly **REAL/DERIVABLE** — the work is a themed UI over hooks that already work. The fabrication risk is the design's mocked **invoices / payment-method / "VISA ···4242"** content, which is NOT in any current API.
|
||||
|
||||
---
|
||||
|
||||
## 1. How the app authenticates TODAY (no account)
|
||||
|
||||
### 1.1 The token is a per-process secret, minted at sidecar boot — not a credential
|
||||
- `packages/server/src/local/index.ts:1409` — `wsSessionToken: crypto.randomBytes(32).toString('hex')` is generated once when the sidecar's agent state is created. It is **process-lifetime**, tied to nothing about a user.
|
||||
- `packages/server/src/local/index.ts:2039–2041` — registered into the security middleware as `sessionToken: server.agentState.wsSessionToken`.
|
||||
- `packages/server/src/local/index.ts:2047–2051` — `GET /api/auth/session-token` returns `{ token: wsSessionToken }`, **auth-exempt** but **same-origin gated** via `isLocalRequest(request)` (cross-origin → 403). This is the bootstrap: the webview reads the token once, then sends it as a Bearer on every other call.
|
||||
|
||||
### 1.2 Server enforcement = "is this the current process's token?", nothing about identity
|
||||
- `packages/server/src/local/security-middleware.ts:238` — `AUTH_EXEMPT_PATHS = ['/health', '/api/auth/session-token']`.
|
||||
- `:340–377` — bearer check: any non-exempt `/api/*` request must carry `Authorization: Bearer <sessionToken>` or it 401s with `MISSING_TOKEN`/`INVALID_TOKEN`. There is **no user lookup** — token equality is the whole check.
|
||||
- `:296–298` — D1: localhost is **no longer trusted by default** (`WAGGLE_TRUST_LOCALHOST=1` is the escape hatch) — because the desktop coexists with browsers/other local apps; the bearer token is what prevents any local process from driving the API.
|
||||
- `:343–353` — non-API GETs (the SPA shell + static assets) load token-less (chicken-and-egg bootstrap); every `/api/*` and non-GET stays gated.
|
||||
- `:249–254`, `:361–366` — SSE streams accept the same token via `?token=` (EventSource can't set headers). Still the same per-process token.
|
||||
|
||||
### 1.3 The client side: attach token, refresh on 401, never a login
|
||||
- `apps/web/src/boot-connect.ts:16–18` — `adapter.connect()` fires as `main.tsx`'s first import, arming the deferral gate before any component fetch.
|
||||
- `apps/web/src/lib/adapter.ts:298–308` — `fetchSessionToken()` GETs `/api/auth/session-token` on connect and stores `this.authToken` (best-effort).
|
||||
- `apps/web/src/lib/adapter.ts:444–446` — every non-exempt request attaches `Authorization: Bearer ${issuedToken}`.
|
||||
- `apps/web/src/lib/adapter.ts:316–337, 459–470` — on a 401 the adapter does ONE silent token refresh + retry (the token rotates every sidecar restart). **This is the only "session lifecycle" that exists** — it is process-rotation recovery, not user re-auth.
|
||||
- `apps/web/src/lib/adapter.ts:49` — client mirror of `AUTH_EXEMPT_PATHS`.
|
||||
|
||||
**There is no signin/signup/logout anywhere on this path.** (grep for `SignIn|SignUp|useAuth|LoginPage` across `apps/web/src` returns only `AppShell.tsx` (a tier label) and a test file — see §4.)
|
||||
|
||||
---
|
||||
|
||||
## 2. What currently consumes "who is the user"
|
||||
|
||||
| Consumer | Today's source | File:line | For PR7 |
|
||||
|---|---|---|---|
|
||||
| **Display name** (Home greeting, sidebar user row) | `IdentityLayer.name` — a free-text name typed in onboarding, stored per-mind in SQLite | `home.ts:261–270` (briefing `userName`); `AppShell.tsx:98,102,321`; `identity.ts:62–100` | A Clerk identity would *supersede* this name, but the IdentityLayer name is **not** an account |
|
||||
| **`HomeBriefing.userName`** (BUILD-PLAN §9 / PR1 LOW #2) | Same IdentityLayer name; optional, omitted when blank | `home.ts:406,411`; `adapter.getIdentity()` `adapter.ts:1090–1103` | This is the "user identity surface" the BUILD-PLAN points at — it is **identity, not auth** |
|
||||
| **Tier** (everything gated) | `config.json` `tier` field (single local user) | `assert-tier.ts:21–39` `readTierFromDataDir`; `settings.ts:309–333` `GET /api/tier` | Tier is **device-local**, not account-bound — Billing/Auth reconciliation point (§5) |
|
||||
| **Trial** | `config.json` `trialStartedAt`; effective tier downgrades TRIAL→FREE on expiry | `settings.ts:392–432`; `tiers.ts:191–204` | `startTrial` already exists client+server |
|
||||
| **Stripe customer** | `config.json` `stripe_customer_id` (written by webhook/sync) | `webhook.ts:52–62`; portal reads it | The billing "who" — a Stripe customer id, again device-local, **not** a Clerk user |
|
||||
| **Team identity (cloud only)** | Clerk `clerkId` → internal `users.id` UUID | `plugins/auth.ts:24–52`; `services/user-service.ts:12–58` | The ONLY place a real account identity exists today — and it's **not in `apps/web`** |
|
||||
|
||||
**Key reconciliation fact:** tier and Stripe customer live in **`config.json` on the local device, keyed to a single anonymous local user** (`assert-tier.ts:21`, `webhook.ts:52`). They are **not** keyed to a Clerk user id. If PR7 introduces a real Clerk login, the product must decide whether tier/billing stay device-local (today's model) or migrate to account-bound (the cloud server's model). This is unresolved and is the core architectural decision PR7 surfaces.
|
||||
|
||||
---
|
||||
|
||||
## 3. The Clerk substrate that DOES exist (cloud/team server — not apps/web)
|
||||
|
||||
- `package.json:45` — `@clerk/fastify": ">=3.1.16 <4"` (root dep).
|
||||
- `apps/www/package.json:15–16` — `@clerk/nextjs": "^7.3.0"` + `@clerk/themes": "^2.4.57"` — but `apps/www` is the **landing page (Next.js), which is PR8**, not PR7's `apps/web` screen.
|
||||
- `apps/web/package.json` — **NO Clerk dependency** (verified, grep returns nothing).
|
||||
- `packages/server/src/plugins/auth.ts:3,21,31` — `createClerkClient` + `verifyToken`; auto-provisions an internal user from Clerk JWT claims on first auth (`:37–48`).
|
||||
- `packages/server/src/services/user-service.ts` — `users` table CRUD keyed by `clerkId`; `upsertFromClerk`.
|
||||
- `packages/server/src/local/security-middleware.ts:307–309` — **the local sidecar already references Clerk indirectly**: `const isTeamMode = !!process.env.CLERK_SECRET_KEY;` enables the 30-min session-inactivity timeout **only in team mode**. So "Clerk present" is already the team/cloud signal even on the local server, but it currently only toggles a timeout — it never establishes a logged-in user on the local path.
|
||||
|
||||
**Where a real Clerk identity slots in (PR7 Auth screen 13):** the design says "Build with Clerk components themed to the tokens" (`SCREENS.md:277`). For `apps/web` that means **adding `@clerk/clerk-react` (or `@clerk/clerk-js`) net-new**, mounting `<SignIn/>/<SignUp/>` themed to the warm tokens, and then deciding what the Clerk session *does*: (a) cosmetic/optional account that seeds the IdentityLayer name + (for Teams) unlocks cloud sync, or (b) a real session token the cloud server validates. Today nothing in `apps/web` consumes a Clerk session, so **(a) is the lower-risk, local-first-preserving path** and matches the design's "account is optional" framing.
|
||||
|
||||
---
|
||||
|
||||
## 4. Existing UI surfaces (what's there vs. what PR7 must build)
|
||||
|
||||
- **No Auth/Login/SignUp/Billing page exists in `apps/web`.** Glob `**/*{Auth,Login,SignIn,SignUp,Billing}*.tsx` → only `overlays/LoginBriefing.tsx`, which is the **"while you slept" overnight-work briefing overlay (SCREENS §05 hero), NOT authentication** (named "login" only because it shows on app open). Do not mistake it for an auth screen.
|
||||
- `apps/web/src/components/os/AppShell.tsx:263–269` — `tierLabel` ("Trial · 9d" / "Pro") for the sidebar user row.
|
||||
- `apps/web/src/components/os/AppShell.tsx:98–104,321` — `userName` from `adapter.getIdentity()`, degrades to "Account" when unconfigured. **This is the "user-identity surface lands (PR3)" hook the BUILD-PLAN PR1 LOW #2 deferred to** — it is fed by IdentityLayer, and PR7 Auth could optionally re-feed it from a Clerk profile.
|
||||
- `apps/web/src/components/os/overlays/UpgradeModal.tsx` (imported `AppShell.tsx:37`) + `LockedFeature.tsx` — existing tier-gate upgrade prompts; `AppShell.tsx:411,422` call `adapter.createCheckoutSession(...)` directly. So an upgrade entry point already exists; PR7 Billing screen 14 is the **dedicated Plans/Checkout/Success/Manage surface** these can route into.
|
||||
- `apps/web/src/test/p1b-authgate-surfaces.test.tsx` — tests the *adapter* auth gate (token/401), not a login UI.
|
||||
|
||||
---
|
||||
|
||||
## 5. Billing substrate — already REAL end-to-end (screen 14 is mostly a themed re-skin)
|
||||
|
||||
**Client (`apps/web`):**
|
||||
- `apps/web/src/hooks/useBilling.ts` — full hook: `refreshTier` (`:34`), `syncAfterCheckout` (`:49`), `startCheckout` (`:69` → opens Stripe URL in new tab), `openPortal` (`:84`), auto-detects `?session_id=` post-checkout redirect (`:104–115`). Critically `tierResolved` (`:18`) means the UI must **not** present the default `'FREE'` as fact until a real `getTier()` round-trip succeeds — an existing honesty guard PR7 must honor.
|
||||
- `apps/web/src/lib/adapter.ts:2658–2683` — `syncStripeCheckout`, `createCheckoutSession('PRO'|'TEAMS')`, `createPortalSession`, `getTier`; `:2742+` `startTrial`.
|
||||
|
||||
**Server (`packages/server/src/stripe/` + `local/routes/settings.ts`):**
|
||||
- `checkout.ts:13–57` — `POST /api/stripe/create-checkout-session` → **Stripe-HOSTED Checkout** redirect (`session.url`), success→`/payment-success?session_id=…`, cancel→`/payment-cancelled` (`:42–43`). Matches design "Use Stripe Checkout/Customer Portal where possible" (`SCREENS.md:294`).
|
||||
- `portal.ts` — `POST /api/stripe/create-portal-session` (Customer Portal) — covers the design's "Manage" state (payment method update / invoices / cancel) **for free**, no custom UI needed.
|
||||
- `webhook.ts:64–169` — `checkout.session.completed` / `customer.subscription.updated` / `customer.subscription.deleted` → writes `tier` (+ `stripe_customer_id`) into `config.json`. Idempotent + serialized (`:40–45,105–165`). Only grants tier when `payment_status==='paid'|'no_payment_required'` (`:120–124`).
|
||||
- `index.ts:71–100` — `tierFromPriceId` / `priceIdForTier` resolve the 4-var (`STRIPE_PRICE_PRO_MONTHLY/_ANNUAL`, `…TEAMS…`) + legacy contracts.
|
||||
- `settings.ts:321–333` — `GET /api/tier` authoritative tier (+ trial days remaining).
|
||||
- **All `/api/stripe/*` routes 503 `STRIPE_NOT_CONFIGURED` when `STRIPE_SECRET_KEY` is unset** (`index.ts:25–43`, `checkout.ts:17–20`) — so on a dev/local machine without keys, Billing must render an honest "not configured" state, not a fake checkout.
|
||||
|
||||
**Design-vs-build for screen 14:** the design's "Checkout: 2-col card form (card 4242…, expiry/CVC)" (`SCREENS.md:287–289`) is **NOT how the current backend works** — checkout is a hosted redirect, there is no card-form endpoint and no card data ever touches Waggle. PR7 should ship the **Plans** state (real, from `getTier` + tier table) + **Success** (real, from `syncAfterCheckout`) + **Manage** (real, via Customer Portal), and either (a) drop the inline card form in favor of hosted Checkout, or (b) build Stripe Elements net-new (larger scope, more PCI surface). Recommend (a) — it matches the existing wiring and the design's own "where possible" caveat.
|
||||
|
||||
---
|
||||
|
||||
## 6. Fabrication risks for PR7 (must gate off — never invent)
|
||||
|
||||
1. **A logged-in identity that isn't real.** The local app has no account. The sidebar/Home already degrade `userName` to "Account" when IdentityLayer is blank (`AppShell.tsx:98–104`). PR7 Auth must NOT show a fabricated "Signed in as …" when no Clerk session exists — show the optional/local-first state.
|
||||
2. **Fake invoices / receipts.** No invoice API exists anywhere (`webhook.ts` writes only tier + customer id; no invoice list endpoint). The design's "invoices (Paid + PDF)" (`SCREENS.md:292`) has **no data source** — either omit, or surface them only via the **Stripe Customer Portal** (which renders real invoices), never as in-app mock rows.
|
||||
3. **Fake payment method ("VISA ···4242").** No payment-method API in the sidecar. Must come from the Customer Portal or be omitted — never hardcoded.
|
||||
4. **Fake usage numbers on Billing.** Tier is real (`getTier`); any "X of Y used" must come from a real source or be omitted.
|
||||
5. **Presenting default `FREE` as the user's plan.** `useBilling.tierResolved` (`useBilling.ts:18`) exists precisely to prevent this — PR7 Billing must render the unresolved state while `!tierResolved`, not the FREE card.
|
||||
6. **A working checkout when Stripe is unconfigured.** All `/api/stripe/*` 503 without `STRIPE_SECRET_KEY` — PR7 must render an honest disabled/"not configured" state, not a clickable fake "Subscribe".
|
||||
7. **Auth gating local-first features behind a login.** The whole product runs token-only with no account today (§1). PR7 Auth must stay **optional** — wiring it as a mandatory gate would regress the local-first contract the design explicitly states.
|
||||
|
||||
---
|
||||
|
||||
## 7. REAL / DERIVABLE / MUST-BUILD / EXTERNAL-DEP summary
|
||||
|
||||
- **REAL** — Local bearer-token session + 401-refresh; per-process token; `GET /api/auth/session-token`; tier in `config.json` + `GET /api/tier` + trial; **entire Stripe checkout/portal/sync/webhook flow** + `useBilling` hook; IdentityLayer name → `userName`; existing `UpgradeModal`/`LockedFeature` upgrade entry points.
|
||||
- **DERIVABLE** — Billing **Plans** card grid (from tier table + `getTier`); **Success** state (from `syncAfterCheckout`); **Manage** (delegate to Stripe Customer Portal). Sidebar "Signed in / Account" display from `getIdentity()`.
|
||||
- **MUST-BUILD** — Themed Auth screen 13 UI (no login UI exists in `apps/web`); themed Billing screen 14 surface (no dedicated billing page exists). Decision logic for "what a Clerk session does on the local path."
|
||||
- **EXTERNAL-DEP** — **Clerk** for `apps/web` (publishable key + new `@clerk/clerk-react` dep; root has only `@clerk/fastify`, `apps/www` has `@clerk/nextjs`); **Stripe** keys (`STRIPE_SECRET_KEY` + price-id env vars + `STRIPE_WEBHOOK_SECRET`) — without them all billing routes 503.
|
||||
|
||||
---
|
||||
|
||||
## 8. Open decisions PR7 must resolve (carry to the build plan)
|
||||
|
||||
1. **Does a Clerk login replace, supplement, or stay independent of the local token?** Today nothing in `apps/web` validates a Clerk session; the local token is what authorizes the API. Recommend: Clerk stays **optional/cosmetic + cloud-sync trigger**, local token remains the API authorizer — preserves local-first, lowest blast radius.
|
||||
2. **Tier/billing keying: device-local (`config.json`, today) vs. account-bound (cloud `users` table)?** This is DESIGN_POV §4 (BUILD-PLAN §7 item 5, flagged as the PR7 blocker — "who pays for inference, BYO-key vs Waggle-metered"). Unresolved; founder decision.
|
||||
3. **Checkout UI: hosted Stripe Checkout (matches current wiring) vs. inline Stripe Elements card form (matches design mock, larger scope)?** Recommend hosted — the backend already only supports it and the design says "where possible."
|
||||
4. **Which server validates a Clerk session if used — the local sidecar (would need `@clerk/fastify` wired into `local/`, currently only `isTeamMode` toggle) or the separate cloud server (`packages/server/src/plugins/auth.ts`, not reachable from `apps/web`)?**
|
||||
283
docs/redesign-warm-hive/pr7-recon/03-screen-auth-design.md
Normal file
283
docs/redesign-warm-hive/pr7-recon/03-screen-auth-design.md
Normal file
@@ -0,0 +1,283 @@
|
||||
# PR7 Recon · Screen 13 — Auth (Clerk, themed) · DESIGN spec
|
||||
|
||||
> RECON ONLY. No product code touched. Every claim cites `file:line`.
|
||||
> Scope: faithful component-level breakdown of the Auth screen design so a build
|
||||
> agent can implement it against the warm token system, PLUS the REAL-vs-BUILD
|
||||
> reality of auth in this monorepo (which is the load-bearing surprise here).
|
||||
|
||||
Sources read in full:
|
||||
- `docs/design_handoff_waggle_app/design-files/screens/auth.html` (181 lines)
|
||||
- `docs/design_handoff_waggle_app/SCREENS.md` §13 (lines 269-278)
|
||||
- `docs/design_handoff_waggle_app/design-files/styles/waggle.css` (155 lines, token grounding)
|
||||
- `docs/redesign-warm-hive/BUILD-PLAN.md` §6 (PR7 row) + §7.5 (the BYO/metered gate)
|
||||
- Codebase auth reality: `packages/server/src/plugins/auth.ts`, `…/local/security-middleware.ts`,
|
||||
`…/services/user-service.ts`, `apps/web/src/components/os/AppShell.tsx`,
|
||||
`apps/web/src/lib/adapter.ts`, `apps/www/app/sign-in/[[...sign-in]]/page.tsx`,
|
||||
`apps/www/app/api/stripe/checkout/route.ts`
|
||||
|
||||
---
|
||||
|
||||
## 0. The headline (read this before building)
|
||||
|
||||
**The desktop app (`apps/web`) — PR7's build target — has NO authenticated user
|
||||
identity and NO Clerk React SDK today.** Auth screen 13 is therefore overwhelmingly
|
||||
**EXTERNAL-DEP + decision-gated**, not a re-skin of something already wired.
|
||||
|
||||
Two distinct server modes coexist; the design's "Clerk" assumption only matches ONE
|
||||
of them, and it's NOT the one the desktop talks to:
|
||||
|
||||
| Mode | Auth mechanism | Has a real user account? | Where the design's screen would live |
|
||||
|---|---|---|---|
|
||||
| **Local sidecar** (what the Tauri desktop / `apps/web` talks to) | per-process **machine bearer token** via `GET /api/auth/session-token`, exchanged so loopback callers can't drive the API. NOT a login. (`packages/server/src/local/security-middleware.ts:235-377`, `:238`) | **No.** "Identity" is the local IdentityLayer name the user types in onboarding (`adapter.getIdentity()` → `/api/identity`, `IdentityResponse.name`), a memory record, not an account. (`apps/web/src/lib/adapter.ts:1090-1095`; `tauri-bindings.ts:133-145`) | n/a today — there is no `/auth` route in `apps/web` (grep for `'/auth'`/`appId.*auth` → **No matches**) |
|
||||
| **Cloud / Team server** (`packages/server/src/index.ts` + `plugins/auth.ts`) | **real Clerk** — `verifyToken()`, `clerkClient.users.getUser()`, Drizzle `users` table, auto-provision on first auth (`packages/server/src/plugins/auth.ts:3,19-52`; `services/user-service.ts:30-58`) | Yes (Clerk user → internal UUID) | n/a in `apps/web` either |
|
||||
| **`apps/www`** (Next.js landing) | **real Clerk UI**, hosted `<SignIn/>`/`<SignUp/>` catch-all pages, themed via `<ClerkProvider>` (`apps/www/app/sign-in/[[...sign-in]]/page.tsx:1,17-23`); `@clerk/nextjs@^7.3.0` + `@clerk/themes@^2.4.57` (`apps/www/package.json:15-16`) | Yes | **This is the only place Clerk's themeable React UI already exists.** |
|
||||
|
||||
So the build decision PR7 must surface: **does screen 13 ship as a real auth flow in
|
||||
the desktop at all, or is desktop auth always optional/local and "sign in for sync"
|
||||
links out to the `apps/www` Clerk flow?** The design copy itself ("account is optional —
|
||||
Waggle runs fully local without one") leans toward the latter. See §6 Decisions.
|
||||
|
||||
---
|
||||
|
||||
## 1. Exact layout (split: brand-panel left + form right)
|
||||
|
||||
From `auth.html`:
|
||||
|
||||
- **Top control bar** (`.controls`, `auth.html:78-87`) — concept-harness chrome: a label
|
||||
`Auth · Clerk · state` (`:79`), a 4-way segmented state switcher
|
||||
`Sign in / Sign up / Verify / SSO` (`:80-85`), and a theme toggle button (`:86`).
|
||||
**This bar is concept scaffolding for previewing states — NOT product UI.** In the
|
||||
real build the "state" is route/Clerk-flow-driven, not a manual segmented control.
|
||||
- **Split grid** (`.split`, `:89`; CSS `:20`) — `grid-template-columns: 1.05fr 1fr`
|
||||
(brand panel slightly wider than the form).
|
||||
- **Left brand panel** (`.brandside`, `:90-101`; CSS `:22-33`):
|
||||
- 56px padding, `linear-gradient(160deg, var(--bg-2), var(--bg))`, right border
|
||||
`--line-soft`, full-bleed honeycomb texture `.comb` masked by a radial gradient
|
||||
at 30%/30% (CSS `:23`).
|
||||
- Three vertical zones via `justify-content:space-between`: **brand lockup** (hex "W"
|
||||
mark + "Waggle" wordmark, `:92`), **pitch** (h2 + p, `:93-96`), **trust lines**
|
||||
(`:97-100`).
|
||||
- **Hidden below 820px** — `@media (max-width:820px){ .brandside{display:none} }`
|
||||
(CSS `:73`). Mobile = form only.
|
||||
- **Right form panel** (`.formside`, `:103-152`; CSS `:36-37`): centered, `max-width:380px`
|
||||
card, scrollable. Holds the four state views (`.view`, only one `.on` at a time, CSS `:72`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Every state — verbatim copy + behavior
|
||||
|
||||
### 2a. Sign in (`data-view="signin"`, `auth.html:106-117`) — default
|
||||
- Heading **"Welcome back"**; sub **"New to Waggle? Create an account"** (link → signup) (`:107`).
|
||||
- **SSO block** (`.sso`, `:108-111`): two buttons — **"Continue with Google"** (mono "G"
|
||||
badge) and **"Continue with Apple"** (mono "⌥" badge).
|
||||
- Divider **"or"** (`.divider`, `:112`).
|
||||
- **Email** field, demo value `mara@egzakta.com` (`:113`).
|
||||
- **Password** field with a `lrow` header: label + **"Forgot?"** link (→ verify view in
|
||||
the demo; in product → Clerk reset) (`:114`).
|
||||
- Primary **"Sign in"** button (`data-go="home"` → routes to Home) (`:115`).
|
||||
- Fineprint: **"By continuing you agree to the Terms & Privacy Policy."** (`:116`).
|
||||
|
||||
### 2b. Sign up (`data-view="signup"`, `:120-132`) — carries the local-first note
|
||||
- Heading **"Create your hive"**; sub **"Already have an account? Sign in"** (`:121`).
|
||||
- **`.localnote` honey banner** (`:122`; CSS `:67-70`) — THE load-bearing trust copy:
|
||||
> **"You don't need this to start."** Waggle works locally right away — create an
|
||||
> account only when you want sync or a team.
|
||||
- **SSO block**: single **"Sign up with Google"** (`:124`).
|
||||
- Divider **"or"** (`:126`).
|
||||
- Fields: **Name** (demo `Mara Kovač`), **Email** (placeholder `you@company.com`),
|
||||
**Password** (placeholder **"At least 10 characters"**) (`:127-129`).
|
||||
- Primary **"Create account"** (→ verify) (`:130`).
|
||||
- Fineprint: **"We'll send a code to verify your email."** (`:131`).
|
||||
|
||||
> Password rule "At least 10 characters" (`:129`) is design copy. Real minimum is
|
||||
> Clerk-policy-driven — do NOT hardcode "10" in validation; mirror whatever the Clerk
|
||||
> instance enforces, or omit the count.
|
||||
|
||||
### 2c. Verify — 6-box OTP (`data-view="verify"`, `:135-140`)
|
||||
- Heading **"Check your email"**; sub **"We sent a 6-digit code to mara@egzakta.com"**
|
||||
(the email is bolded in `--text-2`) (`:136`).
|
||||
- `.otp` row of **6 single-char inputs** (`:137`; CSS `:60-64`): 48×56px, mono 22px,
|
||||
`maxlength=1`, `inputmode="numeric"`; a `.filled` class flips border + text to honey
|
||||
on a non-empty box.
|
||||
- **Auto-advance / backspace nav** (the explicit design requirement), in the demo script
|
||||
(`:163-169`): `input` event focuses the next box when filled; `keydown` Backspace on an
|
||||
empty box focuses the previous box. The demo pre-fills boxes 0-2 with `[2,4,9]`.
|
||||
- Primary **"Verify & continue"** (→ Home) (`:138`).
|
||||
- **"Didn't get it? Resend code · Use a different email"** (→ back to sign in) (`:139`).
|
||||
|
||||
### 2d. SSO / enterprise (`data-view="sso"`, `:143-150`)
|
||||
- Heading **"Single sign-on"**; sub **"Use your organization's identity provider."** (`:144`).
|
||||
- Field **"Work email or organization"** (placeholder `you@company.com`) (`:145`).
|
||||
- Primary **"Continue with SSO"** (`:146`).
|
||||
- Divider **"enterprise"** (`:147`).
|
||||
- **Muted `.localnote`** (neutral, not honey — `background:var(--bg-2)`) (`:148`), verbatim:
|
||||
> SAML, SCIM provisioning, and audit logs are available on **Teams** and **KVARK**.
|
||||
> **Talk to sales →**
|
||||
- Back link **"← Back to sign in"** (`:149`).
|
||||
|
||||
### 2e. Brand-panel pitch + trust copy (verbatim, `:93-100`)
|
||||
- h2: **"Your work follows you, *everywhere.*"** ("everywhere." in honey via `em`, CSS `:29`).
|
||||
- p: **"Sign in to sync your hive across devices, collaborate with a team, and pick up any
|
||||
project exactly where you left off — on any machine."**
|
||||
- Trust line 1 (shield icon): **"An account is optional — Waggle runs fully local without one"**
|
||||
- Trust line 2 (arrow icon): **"Your memory stays yours; sign-in only adds sync"**
|
||||
|
||||
---
|
||||
|
||||
## 3. Warm tokens + primitives used (for faithful build)
|
||||
|
||||
All from `waggle.css` (dark `:9-61`, light `:63-101`). The auth HTML uses these named
|
||||
tokens directly:
|
||||
|
||||
| Primitive | Tokens (from `auth.html` `<style>` + `waggle.css`) |
|
||||
|---|---|
|
||||
| Brand-panel bg | `linear-gradient(160deg, --bg-2, --bg)`; border `--line-soft` (`auth.html:22`) |
|
||||
| Honeycomb texture | `.comb` data-URI honey @ 5% stroke, radial mask (CSS `:119-122`) |
|
||||
| Hex mark | `.hex` clip-path (`waggle.css:117`) + `linear-gradient(150deg,--honey-bright,--honey-deep)` + `--honey-glow` (`auth.html:25`) |
|
||||
| SSO buttons | `--surface` bg, `--line-strong` border; hover → `--honey-line` + `--surface-2` (`auth.html:44-45`) |
|
||||
| Divider | flex rule, `--line-soft` lines, mono `--text-dim` label (`auth.html:47-48`) |
|
||||
| Inputs | `--surface` bg, `--line` border, `--r:11px`; focus → `--honey-line` + `--honey-glow` (`auth.html:52-53`) |
|
||||
| OTP boxes | mono, `--r:12px`; `.filled` → `--honey-line` + honey text (`auth.html:62-64`) |
|
||||
| Primary submit | `--honey` bg, text `#1a1407`; hover → `--honey-bright` (`auth.html:56-57`) |
|
||||
| Honey trust banner | `.localnote` → `--honey-wash` bg + `--honey-line` border (`auth.html:68`) |
|
||||
| Neutral enterprise banner | `.localnote` overridden to `--bg-2` + `--line-soft` (`auth.html:148`) |
|
||||
| Links / accents | `--honey`; fineprint `--text-dim` (`auth.html:41,55,58`) |
|
||||
| Focus ring (global) | `:focus-visible{outline:2px solid --honey}` (`waggle.css:125`) |
|
||||
| Fonts | `--sans` Hanken Grotesk, `--mono` JetBrains Mono (`waggle.css:51-53`) |
|
||||
|
||||
In the real build these map to the **PR1-landed warm tokens** in `apps/web`
|
||||
(`BUILD-PLAN.md §3.1`, already shipped per MEMORY.md — `index.css` carries the verbatim
|
||||
`waggle.css` names + the shadcn HSL recolor). So **no new tokens are needed** — the build
|
||||
re-skins Clerk/custom components against the already-present token set. Honey "#1a1407"
|
||||
button-foreground is the same `--primary-foreground` PR1 set (`BUILD-PLAN.md:67`).
|
||||
|
||||
`#1a1407` (honey-button text) and `data-theme` theming are app-global; the screen
|
||||
inherits dark default + the warm-paper light variant for free.
|
||||
|
||||
---
|
||||
|
||||
## 4. Clerk's themeable components vs custom
|
||||
|
||||
Per SCREENS.md §13: **"Build with Clerk components themed to the tokens."** Mapping the
|
||||
design's pieces to what Clerk's `appearance` API covers:
|
||||
|
||||
| Design piece | Clerk coverage | Notes |
|
||||
|---|---|---|
|
||||
| Sign in (Google/Apple SSO + email/pw) | **`<SignIn/>`** | Social buttons, email/pw, "Forgot?" reset are first-class. Theme via `appearance.variables` (`colorPrimary` ← `--honey`, `colorBackground` ← `--surface`, etc.) + `elements` overrides. `@clerk/themes` already a www dep (`apps/www/package.json:16`). |
|
||||
| Sign up + local-first note | **`<SignUp/>`** + **custom** | The form is Clerk; the **honey `.localnote` "you don't need this to start"** banner is custom chrome placed above/around `<SignUp/>`. |
|
||||
| Verify 6-box OTP (auto-advance/backspace) | **Clerk built-in** | Clerk's email-code step renders its own OTP input with auto-advance. Re-skinning to the exact 48×56 honey boxes needs `elements.otpCodeField*` overrides (or Clerk Elements / a fully custom flow if pixel-parity is required). |
|
||||
| SSO / enterprise (SAML/SCIM → Teams/KVARK) | **partial Clerk + custom** | Clerk Enterprise SSO exists but is a paid Clerk feature + per-org config. The design's panel is mostly a **custom "Talk to sales" CTA** (KVARK funnel), not a live SAML form. Safe build: custom panel, link to sales. |
|
||||
| Left brand panel + pitch + trust lines | **fully custom** | Pure layout chrome around the Clerk `<SignIn/>`/`<SignUp/>` card. |
|
||||
|
||||
**To match the warm design, two integration styles are possible:**
|
||||
1. **Themed Clerk prebuilt** (`<SignIn appearance={…}/>`) wrapped in the custom split
|
||||
layout — fastest, matches SCREENS.md's instruction, but OTP/element pixel-parity is
|
||||
limited to what `appearance.elements` exposes.
|
||||
2. **Clerk Elements / headless** (`useSignIn`, `useSignUp`) feeding the design's exact
|
||||
custom inputs/OTP/buttons — full visual control, more code, the only way to get the
|
||||
exact 48×56 honey OTP boxes + custom SSO buttons.
|
||||
|
||||
The brand panel, dividers, local-first banner, and enterprise→sales CTA are **custom in
|
||||
either case**.
|
||||
|
||||
---
|
||||
|
||||
## 5. Honesty contract — where Auth could fabricate (MUST be gated off)
|
||||
|
||||
Auth is the single highest-risk screen for fabrication because the desktop has no real
|
||||
account. Each of these must be **real or absent — never invented**:
|
||||
|
||||
1. **A logged-in identity that isn't real.** The demo hardcodes `mara@egzakta.com` /
|
||||
`Mara Kovač` (`auth.html:113,127,136`). A build MUST NOT pre-fill or display a fake
|
||||
signed-in user. The desktop's only "identity" is the local IdentityLayer name
|
||||
(`adapter.getIdentity()`, `apps/web/src/lib/adapter.ts:1090`), which is **not** an
|
||||
authenticated account and must never be rendered as "signed in".
|
||||
2. **Fake SSO success.** Google/Apple/SSO buttons that "succeed" without a real Clerk
|
||||
(or any) provider configured are fabrication. If Clerk isn't wired in the desktop,
|
||||
these buttons must be honestly disabled / "coming soon" / route to `apps/www`, not
|
||||
fake a session. (No Clerk publishable key path exists in `apps/web` today.)
|
||||
3. **Fake OTP verification.** The demo's "Verify & continue" advances on any input
|
||||
(`auth.html:138,164`). Real verify must check a real code via Clerk; otherwise the
|
||||
verify state must not claim to have verified anything.
|
||||
4. **"Continue → Home" as a real auth boundary.** In the demo all submits just navigate
|
||||
to Home (`auth.html:171-172`). The desktop already has a **real structural auth gate**
|
||||
(the session-token `ensureReady()` contract, `adapter.authgate.test.ts`) — but that
|
||||
gates the *local sidecar*, not a user login. The Auth screen must not imply a login
|
||||
happened when only the local app opened.
|
||||
5. **SAML/SCIM as live.** The enterprise panel names SAML/SCIM/audit logs
|
||||
(`auth.html:148`). These are Teams/KVARK/Clerk-Enterprise features — the panel is a
|
||||
**sales CTA**, and must stay one unless those are genuinely provisioned. Do not render
|
||||
a SAML form that does nothing.
|
||||
|
||||
**Gate-off rule:** if Clerk is not configured for the desktop, the entire authenticated
|
||||
path (SSO, email/pw, OTP, SSO/org) should degrade to the honest local-first framing the
|
||||
design itself already provides ("an account is optional — Waggle runs fully local") and
|
||||
a single "Sign in for sync →" link to the real flow, rather than a non-functional
|
||||
look-alike.
|
||||
|
||||
---
|
||||
|
||||
## 6. Decisions a build agent must get answered first
|
||||
|
||||
1. **Does desktop auth ship at all, or link out?**
|
||||
- Options: (a) full Clerk in `apps/web` (add `@clerk/clerk-react` + publishable key +
|
||||
a `/auth` route — none exist today); (b) desktop stays local-only, "Sign in for sync"
|
||||
deep-links to the existing `apps/www` Clerk flow (`apps/www/app/sign-in/...`); (c)
|
||||
embed/redirect to `apps/www` in a webview.
|
||||
- Recommendation: **(b)** for first ship — matches "account is optional", reuses the
|
||||
real, already-themed `apps/www` Clerk surface, and avoids standing up a second Clerk
|
||||
React integration + token bridge into the local sidecar (which currently authenticates
|
||||
with a *machine* token, not a *user* token).
|
||||
- Blast radius: large if (a) — new dep, new route, new token-exchange between Clerk
|
||||
user-JWT and the local bearer; small if (b)/(c).
|
||||
|
||||
2. **BYO-key vs Waggle-metered (DESIGN_POV §4 / BUILD-PLAN §7.5 #5).** Explicitly flagged
|
||||
as **blocking Billing/PR7** (`DESIGN_POV.md:62-70`, `:88-90`; `BUILD-PLAN.md:165-166`).
|
||||
It reshapes whether "sign in" is even required to use models (BYO = local key, no
|
||||
account needed; metered = account + payment up front). **Settle before building 13/14.**
|
||||
- Blast radius: shapes Auth (is sign-in required for inference?), Onboarding model gate,
|
||||
Billing, Usage. Founder decision, not a build choice.
|
||||
|
||||
3. **OTP fidelity: themed Clerk prebuilt vs Clerk Elements/headless.** Pixel-exact 48×56
|
||||
honey OTP boxes need headless; "good enough" needs only `appearance` overrides.
|
||||
- Recommendation: themed prebuilt first (ships SCREENS.md's instruction), upgrade to
|
||||
Elements only if review demands the exact boxes. Blast radius: small/local.
|
||||
|
||||
4. **SSO/enterprise panel = sales CTA only (no live SAML).** Recommendation: keep it a
|
||||
custom "Talk to sales → KVARK/Teams" panel; do not implement live SAML in PR7.
|
||||
Blast radius: small.
|
||||
|
||||
---
|
||||
|
||||
## 7. REAL vs DERIVABLE vs MUST-BUILD vs EXTERNAL-DEP (screen 13)
|
||||
|
||||
| Feature | Status | Evidence / note |
|
||||
|---|---|---|
|
||||
| Warm tokens + primitives the screen needs | **REAL** | PR1 landed verbatim `waggle.css` tokens in `apps/web` (`BUILD-PLAN.md §3.1`, MEMORY.md PR1). No new tokens. |
|
||||
| Split brand panel, pitch, trust lines, dividers, local-first banner, enterprise CTA | **MUST-BUILD** (custom chrome, low risk) | Pure layout/copy; no backend. All copy verbatim in §2/§4 above. |
|
||||
| Clerk `<SignIn/>`/`<SignUp/>`/OTP UI in `apps/web` | **EXTERNAL-DEP** | No `@clerk/clerk-react` in `apps/web`; no publishable key; no `/auth` route (grep: no matches). Clerk React UI exists ONLY in `apps/www` (`apps/www/app/sign-in/[[...sign-in]]/page.tsx`). |
|
||||
| Real user account / login / session | **EXTERNAL-DEP** | Real Clerk auth lives in cloud/team server (`packages/server/src/plugins/auth.ts:3,31-48`) + `users` table (`services/user-service.ts`). Desktop sidecar auth is a **machine bearer token**, not a user (`local/security-middleware.ts:235-377`). |
|
||||
| Local "identity" (name) for the user row | **REAL but NOT an account** | `adapter.getIdentity()` → `/api/identity` → IdentityLayer name (`adapter.ts:1090`; `tauri-bindings.ts:133-145`); used in `AppShell.tsx:98-108`, degrades to "Account". Must NOT be shown as "signed in". |
|
||||
| SSO with Google/Apple | **EXTERNAL-DEP** | Clerk social providers; need Clerk + OAuth app config. Not wired in desktop. |
|
||||
| SAML / SCIM (enterprise) | **EXTERNAL-DEP** (Teams/KVARK/Clerk-Enterprise) | Design panel is a sales CTA, not a live form (`auth.html:148`). |
|
||||
| "Continue → Home" navigation | **DERIVABLE** | Routes to `/home`; desktop already has the structural sidecar auth gate (`adapter.authgate.test.ts`) but that is not a user login. |
|
||||
| Theme toggle / segmented state switcher (top bar) | **N/A — concept scaffolding** | `.controls` is harness chrome for previewing states (`auth.html:78-87`), not product UI. |
|
||||
|
||||
---
|
||||
|
||||
## 8. One-paragraph build brief (for the implementer)
|
||||
|
||||
Build screen 13 as a **custom warm split layout** (left brand panel: hex "W" + honeycomb
|
||||
`.comb` + verbatim pitch/trust copy from §2e; right: a centered `max-width:380px` card)
|
||||
in `apps/web`, against the **already-present PR1 warm tokens** (no new tokens). The form
|
||||
itself is **EXTERNAL-DEP on Clerk**, which is wired only in `apps/www` today — so the
|
||||
**first, honest ship is local-first**: the desktop stays usable without an account
|
||||
(reuse the design's own "you don't need this to start" `.localnote`), and a single
|
||||
**"Sign in for sync →"** links to the real, already-themed `apps/www` Clerk flow rather
|
||||
than a non-functional Clerk look-alike in the desktop. If founder confirms full in-app
|
||||
Clerk (decision §6.1a), add `@clerk/clerk-react` + a publishable key + a `/auth` route +
|
||||
a user-JWT→local-sidecar token bridge, and theme `<SignIn/>`/`<SignUp/>`/OTP via
|
||||
`appearance` (Elements only if pixel-exact OTP boxes are required). **Never** render a
|
||||
fabricated signed-in identity, fake SSO/OTP success, or a dead SAML form (§5). The
|
||||
**BYO-vs-metered decision (§6.2) blocks this screen and Billing** and must be settled
|
||||
first.
|
||||
271
docs/redesign-warm-hive/pr7-recon/04-screen-billing-design.md
Normal file
271
docs/redesign-warm-hive/pr7-recon/04-screen-billing-design.md
Normal file
@@ -0,0 +1,271 @@
|
||||
# PR7 Recon — Screen 14: Billing (Stripe) DESIGN spec
|
||||
|
||||
> RECON ONLY. No product code touched. Topic owner: the Billing screen DESIGN spec
|
||||
> (`billing.html`) + SCREENS.md §14, cross-checked against the REAL Stripe wiring in the
|
||||
> monorepo so PR7 knows what is wired vs what must be gated/faked.
|
||||
>
|
||||
> Sources read in full:
|
||||
> - `docs/design_handoff_waggle_app/design-files/screens/billing.html` (247 lines)
|
||||
> - `docs/design_handoff_waggle_app/SCREENS.md` §14 (lines 282–295)
|
||||
> - `docs/redesign-warm-hive/BUILD-PLAN.md` (§6 roadmap, §7.5 BYO-vs-metered flag)
|
||||
> - Backend Stripe: `packages/server/src/stripe/{index,checkout,portal,webhook,sync}.ts`
|
||||
> - Tier system: `packages/shared/src/tiers.ts`
|
||||
> - Current billing UI: `apps/web/src/hooks/useBilling.ts`,
|
||||
> `apps/web/src/components/os/overlays/UpgradeModal.tsx`,
|
||||
> `apps/web/src/components/os/apps/SettingsApp.tsx` (Billing tab, ~L516–650)
|
||||
> - Tier route: `packages/server/src/local/routes/settings.ts` (`GET /api/tier`, L321–341)
|
||||
> - Identity name: `packages/server/src/local/routes/home.ts` (L258–271, IdentityLayer)
|
||||
> - Landing pricing: `apps/www/app/_components/Pricing.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR for the PR7 builder
|
||||
|
||||
The design is a **4-state segmented Stripe billing flow**: Plans, Checkout, Success, Manage
|
||||
(`billing.html:117–122`). The monorepo already has a **fully wired, real Stripe backend** for
|
||||
*Plans → checkout-redirect → sync → tier* and a **hosted Customer Portal** for *Manage*. But the
|
||||
design's Checkout, Success, and Manage states render a **custom card form, a fabricated receipt,
|
||||
a fabricated payment method, and a fabricated 3-line invoice list** — **none of which have a data
|
||||
source in this codebase, and none of which we should hand-build** (PCI + fabrication risk).
|
||||
|
||||
**The governing instruction is already in the design itself** (`SCREENS.md:294`):
|
||||
> "Use Stripe Checkout/Customer Portal where possible; theme to tokens."
|
||||
|
||||
So PR7's faithful-but-honest interpretation: **build the Plans state for real** (it maps 1:1 to the
|
||||
existing checkout route), and **treat Checkout/Success/Manage's in-app chrome as Stripe-hosted
|
||||
redirects**, not as locally-rendered card forms / invoice tables. The custom card form in the HTML
|
||||
is a **mockup of what Stripe Checkout shows** — we must not reimplement it.
|
||||
|
||||
**Blocking dependency (already flagged):** BUILD-PLAN §7 open-decision #5 — DESIGN_POV §4
|
||||
"who pays for inference (BYO-key vs Waggle-metered)" — must be decided before PR7. The Plans copy
|
||||
("you only pay for scale — no feature-count games") leans metered/scale framing; the product today
|
||||
is BYO-key (Settings model keys). This is a **copy + product-positioning fork**, not just a screen.
|
||||
|
||||
---
|
||||
|
||||
## 1. State 1 — PLANS (`billing.html:127–159`) — **REAL, ship it**
|
||||
|
||||
### Verbatim copy
|
||||
- Segmented control labels: `Plans` / `Checkout` / `Success` / `Manage` (`:118–121`); top eyebrow
|
||||
`Billing · Stripe · state` (`:116`).
|
||||
- Header H1: **"Upgrade your _hive._"** (`:130`).
|
||||
- Subhead: **"Memory is free forever. You only pay for scale — no feature-count games."** (`:131`).
|
||||
- Cycle toggle: **`Monthly`** | **`Annual −20%`** (`:132`); the `−20%` is a `.save` span in
|
||||
`--healthy` green.
|
||||
|
||||
### The 3 cards (verbatim)
|
||||
| Card | Tag | Price (mo) | Price (yr) | Unit suffix | Tagline | Feature list | CTA |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **Solo** | `Current` (work-blue `.curtag`, `:136`) | **$0** | $0 | `/ forever` | "For individuals exploring an AI workspace." | Personal memory graph · All major LLMs + local · Local-first by default | **"Your plan"** (disabled, `:141`) |
|
||||
| **Pro** | **"Most popular"** (honey `.pop`, `:144`) `.feat` honey border+glow | **$19** | **$15** | `/ month` → `/ mo · billed yearly` | "For power users compounding across projects." | Everything in Solo · Sync across devices · Marketplace skills & connectors · Self-evolving skills | **"Choose Pro"** (honey, `data-go="checkout"`) |
|
||||
| **Teams** | — | **$49** | **$39** | `/ seat / mo` → `/ seat · yearly` | "Shared memory without losing privacy." | Everything in Pro · Shared team memory · WaggleDance multi-agent · SSO & role-based access | **"Choose Teams"** (ghost) |
|
||||
|
||||
- Annual prices come from `data-yr` attributes; the cycle toggle JS swaps `pp` innerHTML and special-cases Teams' suffix (`:243`). So **$15 Pro / $39 Teams annual are the design's stated annual-equivalent monthly numbers** (−20% of $19→$15.20 rounded to $15; −20% of $49→$39.20 rounded to $39).
|
||||
- "Most popular" badge is **honey** (`--honey` bg, `#1a1407` text, `:38`); "Current" badge is **work-blue** (`--work`, `:39`).
|
||||
|
||||
### REAL backing
|
||||
- **Prices match `tiers.ts:7–12` exactly**: FREE $0 / PRO $19/mo / TEAMS $49/seat. ✅ No drift.
|
||||
- **"Choose Pro/Teams" → real route**: `adapter.createCheckoutSession('PRO'|'TEAMS')`
|
||||
(`adapter.ts:2667`) → `POST /api/stripe/create-checkout-session` (`checkout.ts:13–56`) →
|
||||
`stripe.checkout.sessions.create({mode:'subscription', allow_promotion_codes:true, ...})` →
|
||||
returns hosted `session.url`. **This already works** when `STRIPE_SECRET_KEY` + a price ID env are set.
|
||||
- **Monthly/Annual toggle is REAL-capable but currently NOT wired in-app.** The backend
|
||||
`priceIdForTier(tier, billingPeriod)` (`index.ts:91–100`) already resolves
|
||||
`STRIPE_PRICE_PRO_MONTHLY/_ANNUAL` + `STRIPE_PRICE_TEAMS_MONTHLY/_ANNUAL`. But
|
||||
`adapter.createCheckoutSession(tier)` (`adapter.ts:2667`) sends **no `billingPeriod`** → always
|
||||
monthly. **MUST-BUILD (small):** thread `billingPeriod` through the adapter + hook to honor the
|
||||
toggle. `apps/www/app/_components/Pricing.tsx:9,95,165` already has the monthly/annual toggle pattern to copy.
|
||||
- "Solo = Current / Your plan (disabled)" — the current-plan marker is REAL: `useBilling().tier`
|
||||
+ `tierResolved` (`useBilling.ts:13–37`); SettingsApp already renders a tier badge from this
|
||||
(`SettingsApp.tsx:537–561`).
|
||||
|
||||
**Verdict: Plans is REAL/DERIVABLE — the highest-value, lowest-risk part of PR7.**
|
||||
|
||||
---
|
||||
|
||||
## 2. State 2 — CHECKOUT (`billing.html:161–185`) — **DO NOT hand-build the card form**
|
||||
|
||||
### Verbatim copy (left "Payment details" panel, `:163–175`)
|
||||
- H2 **"Payment details"**.
|
||||
- Email field, value `mara@egzakta.com` (**fabricated identity**).
|
||||
- "Card information" → `1234 1234 1234 1234` placeholder, value `4242 4242 4242 4242` (**fake Stripe test card**), `VISA` brand chip.
|
||||
- Expiry `MM / YY` value `08 / 28`; CVC value `•••`.
|
||||
- "Name on card" value `Mara Kovač` (**fabricated**).
|
||||
- "Country" value `Germany` (**fabricated**).
|
||||
- Secure line (`:172`): lock icon + **"Encrypted & secure. We never store your card — Stripe does."**
|
||||
- Pay button (`:173`): **"Pay $19.00 / month"** → `data-go="success"`.
|
||||
- Footer (`:174`): **"Powered by _Stripe_ · cancel anytime"**.
|
||||
|
||||
### Verbatim copy (right "Order summary" panel, `:176–184`)
|
||||
- H2 **"Order summary"**.
|
||||
- Plan row: hex "W" mark + **"Waggle Pro"** / **"Monthly · renews Jul 14"** (`:178`).
|
||||
- Line: **"Pro plan" — "$19.00"** (`:179`).
|
||||
- Promo row: input placeholder **"Promo code"** + **"Apply"** button (`:180`).
|
||||
- Line: **"Tax (est.)" — "$0.00"** (`:181`).
|
||||
- Total line: **"Due today" — "$19.00"** (`:182`).
|
||||
- Guarantee (`:183`): **"14-day free trial · you won't be charged until Jun 28"**.
|
||||
|
||||
### Reality check — THIS IS THE CORE TENSION
|
||||
- **There is NO custom-card-form backend, and there must not be one.** The real flow is a
|
||||
**redirect to Stripe-hosted Checkout**: `checkout.ts` returns `session.url`, and
|
||||
`useBilling.startCheckout()` does `window.open(url, '_blank')` (`useBilling.ts:69–81`). The card
|
||||
fields, brand detection, promo `Apply`, and live tax are **all Stripe's hosted page**, not ours.
|
||||
- **`SCREENS.md:294` explicitly says "Use Stripe Checkout/Customer Portal where possible."** So the
|
||||
HTML's 2-col card form is a **visual mock of Stripe Checkout** — the honest PR7 build is:
|
||||
*"Choose Pro" → spinner/redirect → Stripe-hosted Checkout (themeable via Stripe's Branding
|
||||
settings, NOT our DOM).* We do **not** collect card/email/name/country in-app (PCI scope + the
|
||||
fields have no API to POST to).
|
||||
- **Promo codes ARE real** end-to-end: `allow_promotion_codes:true` (`checkout.ts:44`) — but they're
|
||||
entered on Stripe's page, not our `Apply` button.
|
||||
- **Trial-aware "Due today / won't be charged until"** is DERIVABLE from `trialDaysRemaining` +
|
||||
`trialStartedAt` (`/api/tier`, `settings.ts:332–333`) for a *plans-page hint*, but the binding
|
||||
"Due today $19 / charged Jun 28" on the checkout page itself is **Stripe-rendered** (Stripe knows
|
||||
the actual trial config on the price). A hardcoded "Jun 28" / "Jul 14" in our UI would be fabrication.
|
||||
|
||||
**Verdict: MUST-NOT-HAND-BUILD. Replace the custom card panel with the existing redirect-to-Stripe
|
||||
flow. The order-summary panel can be a real pre-checkout summary (plan + price from `tiers.ts`),
|
||||
but any date/tax/"due today" line must come from Stripe or be omitted — never invented.**
|
||||
|
||||
---
|
||||
|
||||
## 3. State 3 — SUCCESS (`billing.html:187–199`) — **receipt = FABRICATED, gate it**
|
||||
|
||||
### Verbatim copy
|
||||
- Healthy-green check **ring** (`.ring`, `--healthy-wash` bg, `:189`).
|
||||
- H1 **"You're _Pro._"** (`:190`).
|
||||
- Body (`:191`): **"Your hive just leveled up — _sync, the marketplace, and self-evolving skills_
|
||||
are live. Your trial runs 14 days; we'll remind you before the first charge."**
|
||||
- Receipt card (`:192–197`):
|
||||
- **"Plan" — "Waggle Pro · Monthly"**
|
||||
- **"Trial ends" — "Jun 28, 2026"** (**fabricated date**)
|
||||
- **"Then" — "$19.00 / month"**
|
||||
- **"Receipt" — "Emailed →"** (honey, clickable; **no real email-receipt feature in-app**)
|
||||
- CTA (`:198`): **"Start using Pro →"** (`data-go="home"`) + **"Manage billing"** (`data-go="manage"`).
|
||||
|
||||
### Reality check
|
||||
- **The success *trigger* is REAL**: after Stripe redirect, the app detects `?session_id=` and calls
|
||||
`POST /api/stripe/sync` (`useBilling.ts:104–115` → `sync.ts:18–90`), which **payment-gates**
|
||||
(`session.payment_status === 'paid' || 'no_payment_required'`, `sync.ts:46`) and updates the tier.
|
||||
There IS a `/payment-success?session_id=…` success_url already (`checkout.ts:42`). So a real
|
||||
"You're Pro" confirmation **can** render off the synced tier.
|
||||
- **The receipt block is fabricated.** `/api/stripe/sync` returns only `{ tier, customerId }`
|
||||
(`sync.ts:83`). **No "trial ends" date, no "$/mo then" line, no receipt number/email** is returned.
|
||||
"Trial ends Jun 28" and the "Emailed →" receipt link have **no data source** — rendering them as
|
||||
shown would invent facts. ("Receipt emailed" is even arguably true *only* if Stripe email receipts
|
||||
are enabled on the account — out of our control.)
|
||||
|
||||
**Verdict: Build the success state from the SYNCED TIER only ("You're Pro" + CTA buttons).
|
||||
GATE OFF the receipt rows (Plan/Trial-ends/Then/Receipt) unless sourced from Stripe — they are
|
||||
fabrication risks. Trial copy must be driven by real `trialDaysRemaining`, not a hardcoded "14 days
|
||||
/ Jun 28."**
|
||||
|
||||
---
|
||||
|
||||
## 4. State 4 — MANAGE (`billing.html:201–218`) — **invoice list + payment method = FABRICATED; use Customer Portal**
|
||||
|
||||
### Verbatim copy
|
||||
- H1 **"Billing"** (`:203`).
|
||||
- Current-plan card (`:204–209`, honey border):
|
||||
- **"Waggle Pro"** / **"$19.00 / month · renews Jul 14, 2026"** + **"● Active"** badge (honey).
|
||||
- Row **"Billing cycle" — "Monthly _Switch to annual (−20%)_"** (the `.chg` link is honey).
|
||||
- Row **"Payment method" — "VISA ···· 4242" _Update_** (`:207`).
|
||||
- Row **"Next charge" — "$19.00 on Jul 14"** (`:208`).
|
||||
- Invoices card (`:210–215`):
|
||||
- Header **"Invoices"**.
|
||||
- 3 rows, each: date · `$19.00` (mono) · **"Paid"** (healthy pill) · **"PDF ↓"** download link:
|
||||
- **Jun 14, 2026** · $19.00 · Paid · PDF
|
||||
- **May 14, 2026** · $19.00 · Paid · PDF
|
||||
- **Apr 14, 2026** · $19.00 · Paid · PDF
|
||||
- Footer actions (`:216`): **"Change plan"** (ghost, `data-go="plans"`) + **"Cancel subscription"** (danger/red).
|
||||
- Footer note (`:217`): **"Subscription managed securely via _Stripe_."**
|
||||
|
||||
### Reality check
|
||||
- **`renews Jul 14`, `VISA ···· 4242`, `Next charge $19.00 on Jul 14`, and ALL 3 invoices are
|
||||
fabricated.** Grep confirms **no invoices route, no payment-method route, no `invoices.list` /
|
||||
`customers.retrieve` / `paymentMethods` call anywhere in `packages/server/src`.** `/api/stripe/sync`
|
||||
+ `/api/tier` return **no renewal date, no card brand/last4, no next-charge, no invoice history.**
|
||||
- **What IS real for Manage:** the **Stripe Customer Portal**. `createPortalSession()`
|
||||
(`adapter.ts:2675`) → `POST /api/stripe/create-portal-session` (`portal.ts:14–51`, `requireTier('PRO')`,
|
||||
reads `stripe_customer_id` from config.json) → returns hosted `billingPortal` URL. **The portal IS
|
||||
where "Update payment method, view invoices, cancel subscription" actually happens** — and the
|
||||
current SettingsApp already says exactly that (`SettingsApp.tsx:632`: *"Update payment method, view
|
||||
invoices, or cancel your subscription via the Stripe customer portal."*).
|
||||
- **"Switch to annual (−20%)" / "Change plan" / "Cancel subscription"** → all **belong in the
|
||||
Customer Portal** (or a fresh checkout for an upgrade). Building in-app cancel/swap buttons that
|
||||
hit Stripe write-APIs directly is out of scope and risky; the portal is the sanctioned surface.
|
||||
|
||||
**Verdict: The Manage state's in-app "current plan / cycle / payment method / next charge / invoice
|
||||
table" must be REPLACED by (a) a real current-plan header off `useBilling().tier` and (b) a single
|
||||
"Manage subscription via Stripe" button that opens the Customer Portal. The fabricated invoice list,
|
||||
card number, renewal/next-charge dates, and PDF links MUST be gated off — there is no data for them.**
|
||||
|
||||
---
|
||||
|
||||
## 5. The custom-card-form-vs-Stripe-Checkout tension (explicit, per task)
|
||||
|
||||
| Design HTML shows | Codebase reality | PR7 resolution |
|
||||
|---|---|---|
|
||||
| In-app 2-col card form (email/card/expiry/CVC/name/country) | No card-capture endpoint; PCI-out-of-scope by design (`checkout.ts` only mints a hosted session) | **Redirect to Stripe-hosted Checkout** (existing `startCheckout` → `window.open(session.url)`). Theme via Stripe Branding, not our DOM. |
|
||||
| In-app promo `Apply`, live `Tax (est.)`, `Due today` | `allow_promotion_codes:true` (real) but applied on Stripe's page; tax/proration is Stripe-computed | Promo/tax/due-today live on the **hosted page**. An in-app pre-summary may show plan+list price from `tiers.ts` only. |
|
||||
| In-app invoice table + PDF + payment method + cancel | No invoices/payment-method/cancel route exists | **Stripe Customer Portal** (existing `openPortal`). |
|
||||
| Success receipt (#, trial-end, "emailed") | `/sync` returns `{tier, customerId}` only | Confirm off synced tier; **gate the receipt block**. |
|
||||
|
||||
`SCREENS.md:294` ("Use Stripe Checkout/Customer Portal where possible; theme to tokens") **is the
|
||||
contract**: the HTML card form / invoice table are **fidelity mockups of Stripe's hosted surfaces**,
|
||||
not a spec to reimplement. PR7 builds the **Plans** screen + the **two redirect entry points**
|
||||
(Checkout → hosted; Manage → portal) + an **honest post-redirect success** state, all themed.
|
||||
|
||||
---
|
||||
|
||||
## 6. Fabrication-risk register (what PR7 could silently invent — gate OFF)
|
||||
|
||||
1. **Logged-in identity** — design hardcodes `mara@egzakta.com` / `Mara Kovač`. There is **no real
|
||||
account email** in any contract. The only display name available is `IdentityLayer.get().name`
|
||||
(memory-derived, often undefined — `home.ts:261–267`), surfaced as `HomeBriefing.userName`. **An
|
||||
email or "name on card" must NOT be invented.** (Note: Auth/Clerk is screen 13's job; until Clerk
|
||||
lands there is no authenticated email at all.)
|
||||
2. **Invoice list** (3× $19 Paid + PDF) — **no invoices route; pure fabrication.** Gate off → Customer Portal.
|
||||
3. **Payment method** (`VISA ···· 4242`) — **no payment-method route; fabrication.** Gate off → Portal.
|
||||
4. **Receipt number / "Emailed →" / "Trial ends Jun 28"** — `/sync` has none of these. Gate off.
|
||||
5. **Renewal / next-charge dates** (`renews Jul 14`, `Next charge $19.00 on Jul 14`) — no date in any
|
||||
contract. **Do not hardcode dates.** Trial dates only via real `trialDaysRemaining`/`trialStartedAt`.
|
||||
6. **`$15` / `$39` annual prices** — these are the **design's** annual numbers (−20% rounded). The
|
||||
*authoritative* charge is whatever the `STRIPE_PRICE_*_ANNUAL` price says. Display the design's
|
||||
marketing number is fine; **the actual charged amount must come from Stripe**, never asserted by us.
|
||||
7. **Test card `4242 4242 4242 4242`** — fine as a placeholder in a *mock*, but must never appear in
|
||||
the real (hosted) flow; it's Stripe's own test PAN.
|
||||
|
||||
---
|
||||
|
||||
## 7. REAL vs MUST-BUILD vs EXTERNAL-DEP (summary)
|
||||
|
||||
| Feature | Status | Evidence / note |
|
||||
|---|---|---|
|
||||
| Plans cards + prices ($0/$19/$49) | **REAL** | `tiers.ts:7–12`; rendered in `SettingsApp.tsx:543–558` + `UpgradeModal.tsx:163–165` |
|
||||
| "Choose Pro/Teams" → checkout session | **REAL** | `checkout.ts:13–56`, `adapter.ts:2667`, `useBilling.ts:69–81` |
|
||||
| Monthly/Annual toggle honored at checkout | **MUST-BUILD (small)** | backend ready (`index.ts:91–100`); adapter drops `billingPeriod` (`adapter.ts:2667`) |
|
||||
| Hosted Stripe Checkout (card form) | **REAL (redirect)** + **EXTERNAL-DEP** | needs `STRIPE_SECRET_KEY` + price-id envs (`index.ts:29`, `checkout.ts:30`) |
|
||||
| Promo code | **REAL (on hosted page)** | `allow_promotion_codes:true` (`checkout.ts:44`) |
|
||||
| Post-checkout sync → tier flip | **REAL** | `sync.ts:18–90` (payment-gated), `useBilling.ts:104–115` |
|
||||
| Success "You're Pro" off synced tier | **DERIVABLE** | from `useBilling().tier` after sync |
|
||||
| Success receipt rows (#/trial-end/then) | **MUST-GATE (fabrication)** | `/sync` returns only `{tier, customerId}` (`sync.ts:83`) |
|
||||
| Manage: current plan header | **DERIVABLE** | `useBilling().tier`, `tierResolved` |
|
||||
| Manage: payment method / invoices / cancel / next-charge | **MUST-GATE → Customer Portal** | no route exists; `portal.ts:14–51` is the sanctioned surface; SettingsApp already does this (`SettingsApp.tsx:622–634`) |
|
||||
| Stripe Customer Portal | **REAL** + **EXTERNAL-DEP** | `portal.ts`, needs `stripe_customer_id` in config.json + Stripe account |
|
||||
| Authenticated user email/name | **EXTERNAL-DEP (Clerk, screen 13)** | no account email in any contract; `userName` is memory-derived only (`home.ts:261`) |
|
||||
| BYO-key vs metered positioning ("pay for scale" copy) | **EXTERNAL-DEP (founder decision)** | BUILD-PLAN §7 #5 / DESIGN_POV §4 — **decide before PR7** |
|
||||
|
||||
---
|
||||
|
||||
## 8. Recommended PR7 shape for Billing (so the builder doesn't fabricate)
|
||||
|
||||
1. **Plans (real):** port the 3-card grid + monthly/annual toggle from `billing.html`; bind prices to
|
||||
`tiers.ts`; wire the toggle through a new `billingPeriod` arg on `createCheckoutSession`.
|
||||
2. **Checkout (redirect):** "Choose Pro/Teams" → spinner → `window.open(session.url)`. Optional in-app
|
||||
pre-summary with plan + list price ONLY. **No card fields, no fake tax/dates.**
|
||||
3. **Success (synced):** themed "You're Pro" off the post-`?session_id=` synced tier. **Receipt block
|
||||
omitted** (or shows only what `/sync` returns: tier). Trial line only from real trial fields.
|
||||
4. **Manage (portal):** themed current-plan header (real tier) + one "Manage subscription via Stripe"
|
||||
button → Customer Portal. **No in-app invoice table / card / cancel.**
|
||||
5. **Graceful degradation:** every state must handle Stripe-not-configured (503 `STRIPE_NOT_CONFIGURED`,
|
||||
`index.ts:5`) and unresolved tier (`tierResolved=false`, `useBilling.ts:16`) — already the SettingsApp pattern.
|
||||
251
docs/redesign-warm-hive/pr7-recon/05-clerk-integration.md
Normal file
251
docs/redesign-warm-hive/pr7-recon/05-clerk-integration.md
Normal file
@@ -0,0 +1,251 @@
|
||||
# PR7 Recon · 05 — Clerk Integration (themed, reconciled with local-first)
|
||||
|
||||
> Scope: Auth screen 13 (`auth.html`) of the warm-Hive redesign. RECON ONLY — no product
|
||||
> code touched. Every claim cites `file:line` verified this session (2026-06-18).
|
||||
> Topic: how to integrate Clerk into THIS stack (React 19 + TS + **Vite SPA** `apps/web`,
|
||||
> React Router 6.30, also bundled into a Tauri desktop binary), themed to the warm tokens,
|
||||
> reconciled with "account is optional / local-first."
|
||||
|
||||
---
|
||||
|
||||
## TL;DR verdict (the highest-uncertainty recon)
|
||||
|
||||
1. **Stack reality:** `apps/web` is a **Vite SPA** (`vite@^5.4.19`, `react-router-dom@^6.30.1`,
|
||||
declarative `<BrowserRouter>`/`<Routes>` — NO loaders, NO SSR). The correct Clerk package
|
||||
is **`@clerk/clerk-react`** (skill name `@clerk/react`), env **`VITE_CLERK_PUBLISHABLE_KEY`**.
|
||||
**NOT** `@clerk/react-router` (that is React-Router-v7 *framework* mode with
|
||||
middleware+`rootAuthLoader`, which this app does not use).
|
||||
2. **Prior art exists and is excellent:** `apps/www` (the Next.js landing) already has a **full,
|
||||
themed Clerk integration** — `ClerkProvider` + `dark` baseTheme + Hive `appearance` map
|
||||
(`apps/www/app/layout.tsx:6-93,171`), hosted `/sign-in` + `/sign-up` catch-all routes,
|
||||
`/account`, `middleware.ts`. The server already verifies Clerk JWTs (`packages/server/src/plugins/auth.ts`).
|
||||
So Clerk is **REAL** in the repo — just **absent from `apps/web`** (the SPA target for screen 13).
|
||||
3. **ARCHITECTURE VERDICT (founder decision required):** **Option (b) — optional Clerk sign-in
|
||||
that unlocks sync/Teams/billing; the local desktop stays fully accountless by default.**
|
||||
This is the only option consistent with both the design copy ("An account is optional — Waggle
|
||||
runs fully local without one", `SCREENS.md:273-274`) AND the verified backend (the desktop
|
||||
sidecar is accountless: `wsSessionToken` loopback auth + `config.json` tier, `local/index.ts:2047-2052`,
|
||||
`local/routes/settings.ts:309-331`). It also matches the **prior ratified decision** that Tauri
|
||||
Clerk is a "Phase 2 fast-follow, NOT Day 0" (`2026-05-03…brief:225`).
|
||||
4. **EXTERNAL-DEP the founder must provide:** a Clerk **publishable key** for the SPA
|
||||
(`VITE_CLERK_PUBLISHABLE_KEY=pk_…`). Keys already exist for `apps/www`/server
|
||||
(`pk_test_ZWxlZ2FudC1jYW1lbC04…` in `…brief:143`; secret **rotated 2026-05-12** per
|
||||
`docs/launch/drafts/2026-05-12-apps-www-deployment-readiness.md:161`). The desktop default
|
||||
path needs **no key** (accountless).
|
||||
|
||||
---
|
||||
|
||||
## 1. Stack verification (what `apps/web` actually is)
|
||||
|
||||
| Claim | Evidence |
|
||||
|---|---|
|
||||
| Vite SPA, not Next | `apps/web/package.json:7` `"dev":"vite"`, `:95` `"vite":"^5.4.19"`; entry `apps/web/src/main.tsx:18` `createRoot(...).render(<App/>)` |
|
||||
| React 19 | `apps/web/package.json:59` `"react":"^19.2.0"`, `:61` react-dom 19.2 |
|
||||
| React Router **6.30**, declarative | `apps/web/package.json:64` `"react-router-dom":"^6.30.1"`; `apps/web/src/App.tsx:2,60,62-102` `<BrowserRouter>`/`<Routes>` — NO `createBrowserRouter`, NO loaders |
|
||||
| shadcn/ui installed | `apps/web/components.json` exists (verified); BUILD-PLAN §2 "shadcn/ui fully installed" |
|
||||
| Tauri serves the SAME `apps/web` dist | `app/src-tauri/tauri.conf.json:7` `"frontendDist":"../../apps/web/dist"`, `:8` `devUrl http://localhost:8080` |
|
||||
| `VITE_` env prefix already used | `apps/web/.env.example:11` `VITE_POSTHOG_KEY=…` |
|
||||
| **No Clerk in `apps/web` today** | `grep '@clerk' apps/web/package.json` → none; `grep -rln 'ClerkProvider|SignIn|useSignIn' apps/web/src` → **0 files**. Clean slate for screen 13. |
|
||||
|
||||
`@clerk/react` IS present under `node_modules/@clerk/react` but only as a **transitive** dep of
|
||||
`@clerk/nextjs` (apps/www) — not a direct `apps/web` dependency. PR7 must add it explicitly.
|
||||
|
||||
---
|
||||
|
||||
## 2. Clerk in the repo today (prior art — REAL)
|
||||
|
||||
| Surface | What exists | Evidence |
|
||||
|---|---|---|
|
||||
| `apps/www` (Next.js landing) | `@clerk/nextjs@^7.3.0` + `@clerk/themes@^2.4.57`; `ClerkProvider` in `<body>` with `baseTheme:dark` + full Hive `appearance.variables`+`elements` map | `apps/www/package.json:15-16`; `apps/www/app/layout.tsx:6-7,35-93,171-173` |
|
||||
| `apps/www` hosted auth | `<SignIn/>` at catch-all `/sign-in/[[...sign-in]]/page.tsx`, `/sign-up`, `/account` | `apps/www/app/sign-in/[[...sign-in]]/page.tsx:1,17-23` |
|
||||
| `apps/www` middleware | `clerkMiddleware()` (all routes public, per-route `auth.protect()`) | `apps/www/middleware.ts:6,16` |
|
||||
| Server JWT verify | `@clerk/fastify` `verifyToken` + `createClerkClient`; `authenticate` decorator; auto-provisions internal user from Clerk claims (`upsertFromClerk`) | `packages/server/src/plugins/auth.ts:3,21,31-48` |
|
||||
| Server config | `clerkSecretKey`/`clerkPublishableKey` from env (empty string default = solo mode) | `packages/server/src/config.ts:6-7,26-27` |
|
||||
| Env contract | `CLERK_SECRET_KEY` / `CLERK_PUBLISHABLE_KEY` documented across `.env.example:32-33`, `render.yaml:47-49`, `docker-compose.production.yml:8-9,35-36` |
|
||||
| **Team-mode gate** | Clerk-dependent server behavior activates **only when `CLERK_SECRET_KEY` is set**; absent ⇒ "solo/desktop mode" | `packages/server/src/ws/gateway.ts:46-54`; `packages/server/tests/local/session-timeout.test.ts:166-179` |
|
||||
|
||||
**Themed-Clerk pattern is already solved once** (`apps/www/layout.tsx`). PR7's SPA work is to port
|
||||
that appearance approach to `@clerk/clerk-react`, recolored to the **warm** tokens (apps/www uses the
|
||||
**old cooler** Hive hex `#08090c`/`#e5a000`; apps/web is now warm `#14110b`/`#e9a52c`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Local-first crux — why the desktop must stay accountless
|
||||
|
||||
The desktop sidecar (`packages/server/src/local/`) authenticates with a **machine-local loopback
|
||||
token**, NOT a Clerk identity:
|
||||
|
||||
- `GET /api/auth/session-token` returns `server.agentState.wsSessionToken` — "auth-exempt but
|
||||
same-origin gated… The Tauri webview reads this once on connect() and sends it as a Bearer"
|
||||
(`local/index.ts:2043-2052`). This is a **device** token, not a **user**.
|
||||
- Tier resolves from a local file, default **FREE**, with PATCH noting "will be replaced by Stripe
|
||||
webhook" (`local/routes/settings.ts:309-318,322-331,360`). There is **no logged-in user identity
|
||||
on the desktop today.**
|
||||
- The SPA adapter already injects `Authorization: Bearer <token>` on every non-exempt request
|
||||
(`apps/web/src/lib/adapter.ts:445`), bootstrapping the token in `connect()`
|
||||
(`adapter.ts:254-260,298-308`) with a 401→refresh→retry leg (`adapter.ts:311-321,465`).
|
||||
|
||||
**This is the seam Clerk plugs into.** `useAuth().getToken()` from `@clerk/clerk-react` returns the
|
||||
Clerk session JWT in exactly the `Bearer` shape the adapter + `packages/server/src/plugins/auth.ts`
|
||||
already consume — so an *optional* Clerk 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.
|
||||
|
||||
---
|
||||
|
||||
## 4. ARCHITECTURE VERDICT — does Clerk fit "account is optional"?
|
||||
|
||||
**Recommend Option (b): optional Clerk sign-in that unlocks sync / Teams / billing; local desktop
|
||||
stays accountless by default.**
|
||||
|
||||
| Option | Fit | Why |
|
||||
|---|---|---|
|
||||
| (a) Clerk only on SaaS cloud (`apps/www`), desktop never signs in | Partial | Already true today, but screen 13 lives in `apps/web` (the SPA the desktop loads). A pure-(a) reading means screen 13 is a **cloud-only** surface and the desktop shows no auth at all — contradicts having an Auth screen in the app shell. |
|
||||
| **(b) Optional Clerk in the SPA; accountless is the default; sign-in unlocks sync/Teams/billing** | **Best** | Matches design copy ("account is optional", `SCREENS.md:273-274`), matches the accountless sidecar (`local/index.ts:2047-2052`), matches the prior "Tauri Clerk = Phase 2 fast-follow, NOT Day 0" decision (`…brief:225`), and matches the existing `useBilling` tier flow that already gates upgrade behind a server tier. Clerk renders only when `VITE_CLERK_PUBLISHABLE_KEY` is present; absent ⇒ screen 13 shows the local-first "you're running fully local" state with no fake identity. |
|
||||
| (c) Full Clerk gate (must sign in to use the app) | **Reject** | Directly violates local-first + "account is optional"; breaks the accountless desktop boot (`/api/tier` defaults FREE with no user). Do not build. |
|
||||
|
||||
**Open sub-question for the founder (genuinely unknown):** in Option (b), does desktop Clerk sign-in
|
||||
even run inside the **Tauri WebView**? Clerk's hosted OAuth/Account-Portal flow assumes a browser
|
||||
redirect; in a desktop WebView the SSO redirect (Google/Apple) may need a system-browser + deep-link
|
||||
loopback, or Clerk's custom-flow (`useSignIn`) with email OTP only. This is the same unknown the prior
|
||||
brief deferred to "Phase 2." **Recommendation:** ship screen 13 as the **web/cloud-served** surface
|
||||
first (browser context, where the apps/www pattern is proven), and treat in-WebView desktop sign-in as
|
||||
an explicit follow-up requiring a Tauri deep-link/OAuth spike. Flag, don't guess.
|
||||
|
||||
---
|
||||
|
||||
## 5. The minimal, correct THEMED integration (for Option b)
|
||||
|
||||
### 5.1 Package + env (EXTERNAL-DEP)
|
||||
- Add `@clerk/clerk-react` (current SDK, pairs with apps/www's `@clerk/nextjs` v7) + `@clerk/themes`.
|
||||
- `VITE_CLERK_PUBLISHABLE_KEY=pk_…` (founder provides; reuse the existing `apps/www` instance key).
|
||||
Vite SPA = **publishable key only**; the secret stays server-side (`CLERK_SECRET_KEY`, already wired).
|
||||
|
||||
### 5.2 ClerkProvider placement
|
||||
- Wrap `<App/>` (or just the auth-aware subtree) in `apps/web/src/main.tsx` — same level as the
|
||||
existing `createRoot(...).render(<App/>)` (`main.tsx:18`). `ClerkProvider` must sit **above**
|
||||
`react-query`/router but the design only needs auth in the screen-13 route + the sidebar user row,
|
||||
so it can wrap inside `<App/>` if a no-key fallback is desired.
|
||||
- **No-key guard (local-first):** if `import.meta.env.VITE_CLERK_PUBLISHABLE_KEY` is undefined, render
|
||||
children **without** ClerkProvider and show the accountless state — never crash, never fabricate a user.
|
||||
|
||||
### 5.3 Appearance → warm CSS tokens (the themed part)
|
||||
- **shadcn theme first.** `apps/web/components.json` exists, so per `clerk-custom-ui` the correct first
|
||||
step is `appearance={{ theme: shadcn }}` (`@clerk/themes` shadcn, current SDK). Clerk's shadcn theme
|
||||
reads the shadcn HSL vars — which PR1 already repointed to warm values:
|
||||
`--primary:38 81% 54% (#e9a52c)`, `--background:40 29% 6% (#14110b)`, `--ring:38 81% 54%`
|
||||
(`apps/web/src/index.css:20,29,46`). So most theming is **automatic**.
|
||||
- Thin override on top, mirroring apps/www's pattern but with warm hex:
|
||||
`variables:{ colorPrimary:'#e9a52c', colorBackground:'#14110b', colorText:'#…', borderRadius:'8px'
|
||||
(=--r-sm, index.css:176), fontFamily:'Hanken Grotesk, system-ui' }`.
|
||||
- Light/dark: Clerk's default theme respects CSS `color-scheme`; `apps/web/src/index.css:185` sets
|
||||
`color-scheme:dark` (+ a `[data-theme="light"]` block at `:192`). Theme stacking
|
||||
`[shadcn, dark]` or `color-scheme`-driven both work; reconcile with the existing `ThemeProvider`.
|
||||
- apps/www's `layout.tsx:31-34` carries a real gotcha to copy: **do NOT use `as const`** on the
|
||||
appearance object (over-narrows Clerk's `Appearance` union and silently drops `baseTheme`).
|
||||
|
||||
### 5.4 Prebuilt vs custom-flow components (what the design needs)
|
||||
Screen 13 (`SCREENS.md:269-278`) wants: split brand panel + form; **Sign in (Google/Apple SSO +
|
||||
email/password)**, **Sign up**, **6-box OTP Verify (auto-advance, backspace nav)**, **SSO/enterprise**.
|
||||
|
||||
| Design element | Clerk mapping | Real/Build |
|
||||
|---|---|---|
|
||||
| Sign in / Sign up form | Prebuilt `<SignIn/>` / `<SignUp/>` (themed) — cheapest, proven in apps/www | REAL component, themed = small build |
|
||||
| Google/Apple SSO | Clerk social connections (config in Clerk dashboard) — rendered by prebuilt comps automatically | EXTERNAL-DEP (OAuth creds in dashboard) |
|
||||
| Email/password | Clerk default — prebuilt | REAL |
|
||||
| **6-box OTP Verify** | This is Clerk's **email-code verification step**, which `<SignIn/>`/`<SignUp/>` render *as their own UI*. The design's bespoke 6-box auto-advance widget = **custom flow** via `useSignIn`/`useSignUp` (`clerk-custom-ui` core-3) **only if** they want the exact 6-box look; otherwise accept Clerk's built-in code input. | DERIVABLE (prebuilt) or MUST-BUILD (custom 6-box) — **founder choice** |
|
||||
| SSO/SAML/SCIM → Teams/KVARK | Clerk **Organizations/Enterprise SSO** (`clerk-orgs`) — a "note → Teams/KVARK", not a live SAML flow in PR7 | note only; Orgs are a later/Teams concern |
|
||||
| User row in sidebar (PR1 left `userName={null}`, BUILD-PLAN §9) | `<UserButton/>` (prebuilt popover) or `useUser()` to thread `HomeBriefing.userName` | DERIVABLE |
|
||||
|
||||
**Recommendation:** use **prebuilt `<SignIn/>`/`<SignUp/>` themed** for v1 (matches apps/www, lowest risk,
|
||||
"Build with Clerk components themed to the tokens" is literally the design note, `SCREENS.md:277-278`).
|
||||
Only drop to `useSignIn` custom flow if the founder insists on the pixel-exact 6-box OTP widget.
|
||||
|
||||
---
|
||||
|
||||
## 6. Billing half of PR7 (screen 14) — mostly already REAL (brief note; not my topic)
|
||||
|
||||
Flagged because PR7 bundles Auth+Billing and the honesty contract spans both:
|
||||
- `apps/web/src/hooks/useBilling.ts` **already exists** — `getTier`, `createCheckoutSession('PRO'|'TEAMS')`,
|
||||
`createPortalSession`, `syncStripeCheckout(sessionId)`, post-redirect `?session_id=` auto-sync
|
||||
(`useBilling.ts:34-115`). Adapter methods at `adapter.ts:2658-2676`.
|
||||
- Server Stripe module exists: `packages/server/src/stripe/{checkout,portal,webhook,sync,index}.ts`.
|
||||
- ⇒ Screen 14 is largely a **re-skin of the existing flow to warm tokens** + Stripe-hosted
|
||||
Checkout/Customer-Portal (open in browser). **No card form is implemented in-app** today and the
|
||||
design's "card 4242…, expiry/CVC" panel must **not** be hand-rolled — route to Stripe Checkout.
|
||||
- **Blocked decision (DESIGN_POV §4, `DESIGN_POV.md:62-70`):** BYO-key vs Waggle-metered inference.
|
||||
This reshapes Billing/Onboarding/Usage and "must be settled before Billing goes live"
|
||||
(`DESIGN_POV.md:89`). Surface to founder before building screen 14.
|
||||
|
||||
---
|
||||
|
||||
## 7. FABRICATION RISKS (must be gated off — honesty contract)
|
||||
|
||||
PR7 is the **highest fabrication-risk PR** because Auth+Billing both render identity/money:
|
||||
|
||||
1. **Fake logged-in identity.** With no `VITE_CLERK_PUBLISHABLE_KEY`, the SPA must show the
|
||||
**accountless** state, never a placeholder "signed-in" user, name, avatar, or email. The sidebar
|
||||
user row already correctly renders "Account"/"W" when `userName={null}` (BUILD-PLAN §9) — keep that
|
||||
honest; only populate from a **real** `useUser()` / `HomeBriefing.userName`.
|
||||
2. **Fake invoices / receipts.** Screen 14 "invoices (Paid + PDF)" must come from Stripe
|
||||
(Customer Portal), never a hardcoded invoice list. If no Stripe customer exists → empty/"manage in
|
||||
portal", not invented rows.
|
||||
3. **Fake payment method.** "VISA ···4242, Update" must reflect a real Stripe payment method or render
|
||||
the empty/portal state. Do NOT ship a literal `···4242` as if it were the user's card.
|
||||
4. **Fake usage / "Due today $19".** Trial-aware amounts must come from the real tier
|
||||
(`useBilling.tierResolved`, `useBilling.ts:16-22` — it explicitly forbids presenting the `FREE`
|
||||
default as fact) and Stripe price data, never a static string.
|
||||
5. **Fake card-entry form.** The design shows a card form ("encrypted & secure, Powered by Stripe").
|
||||
Collecting card data in-app is both a fabrication trap and a PCI risk — **use Stripe Checkout**,
|
||||
render the form only as Stripe's hosted/embedded element.
|
||||
6. **Fake SSO success.** SSO buttons must do a real Clerk redirect; never simulate "Signed in with
|
||||
Google" without a Clerk session.
|
||||
7. **Tier never silently FREE.** Already enforced by `useBilling.tierResolved` — keep any new auth/billing
|
||||
surface honoring it (render "unresolved", not the FREE upgrade grid, until a real round-trip).
|
||||
|
||||
---
|
||||
|
||||
## 8. What's REAL vs DERIVABLE vs MUST-BUILD vs EXTERNAL-DEP
|
||||
|
||||
| Feature | Status | Note |
|
||||
|---|---|---|
|
||||
| Clerk JS SDK + JWT model | REAL | `@clerk/fastify` server verify (`plugins/auth.ts`), `@clerk/nextjs` themed (`apps/www/layout.tsx`) |
|
||||
| Clerk in `apps/web` SPA | MUST-BUILD | add `@clerk/clerk-react` + `ClerkProvider` in `main.tsx`; **none today** |
|
||||
| Themed appearance (warm tokens) | DERIVABLE | shadcn theme auto-reads warm shadcn vars (`index.css:20,29,46`) + thin `variables` override; pattern proven in `apps/www/layout.tsx:35-93` |
|
||||
| Prebuilt `<SignIn/>`/`<SignUp/>`/`<UserButton/>` | REAL (Clerk) | design says "Build with Clerk components themed" (`SCREENS.md:277`) |
|
||||
| Bespoke 6-box OTP widget | MUST-BUILD (optional) | only if not accepting Clerk's built-in code step; `useSignIn` custom flow |
|
||||
| `getToken()` → existing Bearer adapter | DERIVABLE | adapter already sends `Authorization: Bearer` (`adapter.ts:445`); server already verifies (`plugins/auth.ts:31`) |
|
||||
| Accountless local-first default | REAL | sidecar loopback token + FREE config (`local/index.ts:2047-2052`, `settings.ts:318`) |
|
||||
| Billing flow (checkout/portal/sync/tier) | REAL | `useBilling.ts` + adapter + `server/src/stripe/*` all exist |
|
||||
| In-app card form | EXTERNAL-DEP (Stripe-hosted) | do not hand-roll; Stripe Checkout |
|
||||
| Google/Apple SSO, SAML/SCIM | EXTERNAL-DEP | OAuth creds + Clerk Orgs/Enterprise config in Clerk dashboard |
|
||||
| `VITE_CLERK_PUBLISHABLE_KEY` for SPA | EXTERNAL-DEP | **founder must provide**; instance/keys already exist for apps/www/server |
|
||||
| In-WebView desktop sign-in (Tauri) | UNKNOWN / spike | redirect/OAuth in WebView unproven; prior brief deferred to "Phase 2" (`…brief:225`) |
|
||||
|
||||
---
|
||||
|
||||
## 9. Decisions the founder must make before PR7 builds screen 13
|
||||
|
||||
1. **Architecture:** confirm Option **(b)** — optional Clerk, accountless default. (Recommended.)
|
||||
Blast radius: defines `main.tsx` provider wrapping + the no-key fallback for the whole SPA.
|
||||
2. **Surface scope:** does screen 13 ship as a **browser/cloud-served** surface first (proven), with
|
||||
**in-WebView Tauri sign-in** as an explicit follow-up spike? (Recommended yes.)
|
||||
3. **OTP UI:** accept Clerk's built-in verification step (cheap, prebuilt) vs MUST-BUILD the pixel-exact
|
||||
6-box widget via `useSignIn`. (Recommend prebuilt for v1.)
|
||||
4. **EXTERNAL-DEP:** provide `VITE_CLERK_PUBLISHABLE_KEY` (reuse existing instance) + confirm
|
||||
Google/Apple social connections are enabled in the Clerk dashboard.
|
||||
5. **Billing prerequisite (DESIGN_POV §4):** BYO-key vs Waggle-metered — settle before screen 14.
|
||||
|
||||
---
|
||||
|
||||
## 10. Honesty log / discrepancies surfaced
|
||||
|
||||
- The task framing assumed `clerk-react-router-patterns` might apply. It does **not** — that skill is
|
||||
for React-Router **v7 framework mode** (SSR loaders + `clerkMiddleware`). This app is RR6 SPA ⇒
|
||||
`@clerk/clerk-react` (`clerk-react-patterns`) is the correct skill. Documented to prevent a wrong build.
|
||||
- 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`).
|
||||
- `@clerk/react` is in `node_modules` (transitive via nextjs) but **not** an apps/web dep — do not
|
||||
assume it's "already installed" for the SPA.
|
||||
- Server `authenticate`/team-mode is gated on `CLERK_SECRET_KEY` presence; the desktop default (no key)
|
||||
is the accountless path. PR7 must not assume Clerk is always on.
|
||||
320
docs/redesign-warm-hive/pr7-recon/06-routing-surfaces.md
Normal file
320
docs/redesign-warm-hive/pr7-recon/06-routing-surfaces.md
Normal file
@@ -0,0 +1,320 @@
|
||||
# PR7 Recon — 06 · Routing & Surfaces (Auth /auth + Billing /billing)
|
||||
|
||||
> Topic: where `/auth` and `/billing` routes + entries live, reusing the PR1–PR6 shell
|
||||
> patterns. RECON ONLY — no product code touched. Every claim cites `file:line`.
|
||||
> Verified against `origin/main @ 3764bc13` (PR1–PR6 all shipped).
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (the two registration shapes)
|
||||
|
||||
1. **`/auth` (screen 13) is SPECIAL — it is the ONE pre-shell, full-screen route.** Every
|
||||
prior warm-Hive screen mounts INSIDE the `<AppShell>` layout route (`App.tsx:63-101`);
|
||||
`/auth` must NOT. It belongs as a **sibling `<Route>` at the top level, outside the
|
||||
`path="/"` AppShell element** — no Sidebar, no StatusBar, no ChatHost, no boot gate. It
|
||||
is the only screen in the whole redesign that breaks the "child-of-AppShell" rule.
|
||||
|
||||
2. **`/billing` (screen 14) is NOT a new top-level route at all.** A complete Stripe billing
|
||||
surface ALREADY EXISTS as **Settings → "Plan" tab** (`SettingsApp.tsx:511-655`), wired to
|
||||
the real `useBilling` hook → real adapter Stripe calls → real server routes. PR7's billing
|
||||
work is **(a) reskin that existing tab to the warm tokens + the SCREENS §14 4-state layout,
|
||||
and (b) make it deep-linkable** (today `/settings` always opens on the Models tab and has
|
||||
**no `?tab=` reader** — see the breadcrumb/deep-link gap below). A standalone themed
|
||||
`/billing` route is OPTIONAL and only justified if the design wants the full-screen
|
||||
Plans/Checkout/Success/Manage flow outside the Settings chrome.
|
||||
|
||||
---
|
||||
|
||||
## 1. The route table today (`apps/web/src/App.tsx`)
|
||||
|
||||
`App.tsx:52-110` — a SINGLE layout route owns everything:
|
||||
|
||||
```
|
||||
<Route path="/" element={<AppShell />}>
|
||||
<Route index element={<IndexRedirect />} />
|
||||
…all 28 child routes (home, workspaces, memory, …, benchmarks, platform)…
|
||||
<Route path="*" element={<NotFound />} /> // App.tsx:100
|
||||
</Route>
|
||||
```
|
||||
|
||||
- **Every** screen is a child of `<AppShell>` (`App.tsx:63`). There is currently **no
|
||||
top-level route outside the shell** at all.
|
||||
- Imports come from the `@/routes` barrel (`App.tsx:12-39`); the catch-all `*` must stay last
|
||||
(`App.tsx:99` comment "ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL").
|
||||
- PR6a added the two ⌘K-only static surfaces directly here: `benchmarks` (`App.tsx:97`) and
|
||||
`platform` (`App.tsx:98`).
|
||||
- **No `/auth`, `/billing`, `/login`, `/sign-in`, `/payment-success`, `/payment-cancelled`
|
||||
routes exist** (verified by grep — zero hits in `App.tsx` / `routes/index.ts`).
|
||||
|
||||
---
|
||||
|
||||
## 2. The PR6 route-wrapper pattern (to copy)
|
||||
|
||||
A PR6 ⌘K-only surface is 3 small pieces. `BenchmarkRoute.tsx:1-11` is the template:
|
||||
|
||||
```tsx
|
||||
/** PR6a route wrapper — `/benchmarks` → BenchmarkApp (static, ⌘K-only surface). */
|
||||
import BenchmarkApp from '@/components/os/apps/BenchmarkApp';
|
||||
import SurfaceBoundary from './SurfaceBoundary';
|
||||
const BenchmarkRoute = () => (
|
||||
<SurfaceBoundary appName="Benchmarks"><BenchmarkApp /></SurfaceBoundary>
|
||||
);
|
||||
export default BenchmarkRoute;
|
||||
```
|
||||
|
||||
The three pieces for any in-shell surface:
|
||||
1. **`routes/<Name>Route.tsx`** — thin wrapper that renders the App component inside
|
||||
`<SurfaceBoundary appName="…">` (`SurfaceBoundary.tsx:10-17` wraps in `AppErrorBoundary`,
|
||||
`onClose` → `navigate('/home')`).
|
||||
2. **`routes/index.ts` barrel export** (`routes/index.ts:56-59` — PR6 lines).
|
||||
3. **`<Route path="…" element={<…Route/>}/>` in `App.tsx`** under the AppShell layout
|
||||
(`App.tsx:96-98` — the PR6a block).
|
||||
|
||||
> **PR7 BILLING (if a standalone `/billing` is wanted)** follows this exact 3-step pattern
|
||||
> — a `BillingRoute` wrapper → barrel → child route under AppShell. **PR7 AUTH does NOT** —
|
||||
> see §4: it is a sibling route, not an AppShell child, and `SurfaceBoundary`'s
|
||||
> `onClose → /home` is wrong for a pre-login screen.
|
||||
|
||||
---
|
||||
|
||||
## 3. Command-catalog entries (`apps/web/src/lib/command-catalog.ts`)
|
||||
|
||||
The ⌘K catalog is built by `buildCommandCatalog()` (`command-catalog.ts:59-118`), grouped
|
||||
**Jump to / Do / Power tools** (+ Pro "★ Pinned"). Entry shape `CatalogCommand`
|
||||
(`command-catalog.ts:22-40`): `{ id, group, name, subtitle, icon, to?, action?, meta?,
|
||||
minBillingRank?, pinned? }`. Each routes to a REAL `to:` or fires `action:'spawn'`.
|
||||
|
||||
**Billing is already represented in the catalog — but points at the wrong place:**
|
||||
- `command-catalog.ts:75` — `Settings · "models · failover · permissions · plan"` → `to:"/settings"`
|
||||
- `command-catalog.ts:76` — **`"Upgrade to Pro"` · `"plans · billing · invoices"` → `to:"/settings"`**
|
||||
|
||||
Both land on `/settings` (which opens on the **Models** tab, NOT Plan — see §6 gap). PR7
|
||||
should retarget these to whatever the billing entry point becomes (`/settings?tab=billing`
|
||||
once a `?tab=` reader exists, or a new `/billing`).
|
||||
|
||||
**Auth has NO catalog entry and should NOT get one** — sign-in is a pre-login full-screen
|
||||
route reached by redirect, not a ⌘K jump from inside the authenticated shell.
|
||||
|
||||
The catalog's tier-gating mechanism to reuse: `gate()` (`command-catalog.ts:99-100`) filters
|
||||
on `minBillingRank` (FREE 0 / TRIAL 1 / PRO 2 / TEAMS 3 / ENT 4, `command-catalog.ts:36`); Pro
|
||||
"★ Pinned" floats `pinned:true` items (`command-catalog.ts:104-111`). Catalog is consumed in
|
||||
`AppShell.tsx:272-279` (`buildCommandCatalog` + `handleCatalogSelect`).
|
||||
|
||||
---
|
||||
|
||||
## 4. `/auth` — the pre-shell, full-screen registration (THE special case)
|
||||
|
||||
### Why it can't be an AppShell child
|
||||
`<AppShell>` (`AppShell.tsx:436-466`) wraps everything in `<ShellProvider>` →
|
||||
`<ShellLayout>` (`AppShell.tsx:68`), which renders the BootScreen gate, the Sidebar
|
||||
(`AppShell.tsx:314-323`), StatusBar (`AppShell.tsx:303-309`), ChatHost, all overlays, and the
|
||||
onboarding takeover (`AppShell.tsx:285-296`). A sign-in screen must show **none of that**.
|
||||
Mounting `/auth` as a child of `path="/"` would draw the whole authenticated chrome behind
|
||||
the login form.
|
||||
|
||||
### Recommended shape (sibling route, outside the shell)
|
||||
```tsx
|
||||
<Routes>
|
||||
<Route path="/auth" element={<AuthRoute />} /> {/* NEW — sibling, pre-shell */}
|
||||
<Route path="/" element={<AppShell />}>
|
||||
…existing 28 children…
|
||||
</Route>
|
||||
</Routes>
|
||||
```
|
||||
- `AuthRoute` is a **standalone full-screen component** — it MUST NOT use `SurfaceBoundary`
|
||||
(its `onClose → navigate('/home')` assumes an authenticated home, `SurfaceBoundary.tsx:13`).
|
||||
Wrap in a plain `AppErrorBoundary` if any, with `onClose → window.location.reload()` (the
|
||||
same pattern `App.tsx:61` uses at the root).
|
||||
- Theme still applies: `data-theme` is set pre-paint in `main.tsx` (per BUILD-PLAN §4) and
|
||||
`<ThemeProvider>` wraps `<BrowserRouter>` at `App.tsx:53`, so `/auth` inherits warm tokens
|
||||
even though it's outside AppShell. Good — no extra wiring needed for theming.
|
||||
|
||||
### Honesty gate (CRITICAL — auth is the #1 fabrication risk)
|
||||
There is **no real user-identity auth in the product today.** What exists is a **same-origin
|
||||
local sidecar session-token** (`adapter.ts:295-308` `fetchSessionToken`, refreshed at
|
||||
`adapter.ts:316-331`; server route `local/index.ts:2047` `/api/auth/session-token`, auth-exempt
|
||||
`security-middleware.ts:238`). That is a *dev/desktop bootstrap Bearer*, NOT a logged-in human.
|
||||
The only "Clerk-gated" surface is the **CLOUD** server (`local/routes/agents.ts:12` comment:
|
||||
"`/api/agents/*` CRUD exists only on the Clerk-gated CLOUD server"), which the local app does
|
||||
not run.
|
||||
|
||||
So `/auth` is **EXTERNAL-DEP / MUST-BUILD**: real Clerk components need a `CLERK_PUBLISHABLE_KEY`
|
||||
+ the `@clerk/clerk-react` provider (neither present — zero `@clerk` imports in `apps/web/src`).
|
||||
**Until that wiring is real, the auth screen must NOT show a fake signed-in identity, a fake
|
||||
name/avatar, or pretend a session exists.** The design's own framing helps here: SCREENS §13
|
||||
(`SCREENS.md:271-278`) says "An account is optional — Waggle runs fully local without one" and
|
||||
"Continue routes to Home." A PR7-honest auth screen can render the themed Clerk UI but, with no
|
||||
key configured, must degrade to the local-first "continue without an account → Home" path
|
||||
rather than inventing a logged-in user. (The sidebar user row already degrades to "Account" +
|
||||
"W" avatar when `getIdentity()` returns no name — `AppShell.tsx:98-109`, PR1 LOW #2 — so the
|
||||
"no real identity" state is already an accepted, non-fabricated UI.)
|
||||
|
||||
The `clerk-setup` / `clerk-react-patterns` / `clerk-billing` skills are available for the build PR.
|
||||
|
||||
---
|
||||
|
||||
## 5. `/billing` — reuse the EXISTING Settings "Plan" tab (do not rebuild from zero)
|
||||
|
||||
### What is already REAL (verified, fully wired)
|
||||
- **Settings "Plan" tab** — `SettingsApp.tsx:41` (`{ id:'billing', label:'Plan', icon:DollarSign }`);
|
||||
renders at `SettingsApp.tsx:511-655` (`activeTab === 'billing'`).
|
||||
- **`useBilling` hook** — `hooks/useBilling.ts:24-124`: `startCheckout('PRO'|'TEAMS')`
|
||||
(`useBilling.ts:69-81`), `openPortal()` (`useBilling.ts:84-96`), `syncAfterCheckout`
|
||||
(`useBilling.ts:49-66`), auto-detects `?session_id=` on return (`useBilling.ts:104-115`).
|
||||
P1b honesty already baked in: `tierResolved=false` until a real `getTier()` succeeds; the
|
||||
default 'FREE' is NEVER shown as fact (`useBilling.ts:13-22, 38-43`; rendered unresolved
|
||||
state at `SettingsApp.tsx:525-536`).
|
||||
- **Adapter Stripe layer** — `adapter.ts:2658-2680`: `syncStripeCheckout`,
|
||||
`createCheckoutSession`, `createPortalSession`.
|
||||
- **Server Stripe routes (REAL)** — `packages/server/src/stripe/{checkout,portal,sync,webhook}.ts`.
|
||||
`checkout.ts` returns **`503 STRIPE_NOT_CONFIGURED`** when no `STRIPE_SECRET_KEY`
|
||||
(`checkout.ts:19-20`); success/cancel URLs are `/payment-success?session_id=…` and
|
||||
`/payment-cancelled` (`checkout.ts:42-43`). Webhook handler + 4 price-var resolution shipped
|
||||
per CLAUDE.md §10 (E-10).
|
||||
- **CoverageCompassCard** value-prop card already renders above the tier card
|
||||
(`SettingsApp.tsx:520`).
|
||||
|
||||
### What is a build GAP for the SCREENS §14 design (`SCREENS.md:282-295`)
|
||||
| §14 design element | Status | Evidence |
|
||||
|---|---|---|
|
||||
| Plans state (3 cards, Pro popular) | DERIVABLE — upgrade buttons exist | `SettingsApp.tsx:579-619` |
|
||||
| **Monthly / annual toggle (−20%)** | **MUST-BUILD on FE** — server already accepts `billingPeriod` (`checkout.ts:14,29`) but adapter `createCheckoutSession(tier)` does **not pass it** (`adapter.ts:2667`); `useBilling.startCheckout` has no period arg (`useBilling.ts:69`) | grep: zero `billingPeriod`/`annual`/`monthly` in adapter+useBilling |
|
||||
| Checkout state (card form) | EXTERNAL-DEP — use **Stripe Checkout** (hosted), per SCREENS §14 "Use Stripe Checkout/Customer Portal where possible" (`SCREENS.md:294`) | `checkout.ts` creates hosted `session.url` |
|
||||
| Success state | **MUST-BUILD route** — `success_url` points at `/payment-success` which **does not exist** as a route (grep: 0 hits in App.tsx); today only `?session_id=` is read by `useBilling.ts:104-115` on whatever page is mounted | `checkout.ts:42` vs App.tsx |
|
||||
| Manage state (portal, invoices, PDF) | REAL — "Manage Subscription" → `openPortal()` → Stripe Customer Portal (`SettingsApp.tsx:621-635`, `useBilling.ts:84-96`). **Invoices/PDF are inside Stripe's portal, not our UI** — do NOT render fake invoice rows in-app. |
|
||||
|
||||
### Recommended registration for billing
|
||||
- **Primary:** keep billing as the **Settings → Plan tab**, reskinned to warm tokens + the
|
||||
§14 segmented 4-state layout. Add the **`/payment-success`** route (and optionally
|
||||
`/payment-cancelled`) — these CAN be AppShell children using the PR6 wrapper pattern (the
|
||||
user is back inside the app post-checkout), OR a tiny standalone confirmation. Wire ⌘K
|
||||
`upgrade`/`settings` entries to deep-link the Plan tab.
|
||||
- **Optional standalone `/billing`:** only if design wants the full Plans/Checkout/Success/Manage
|
||||
flow outside Settings chrome. If so, follow the §2 PR6 wrapper pattern (AppShell child —
|
||||
billing IS post-login, unlike auth). Reuse `useBilling` verbatim; do not duplicate Stripe calls.
|
||||
|
||||
### BYO-vs-metered (BLOCKER — founder decision before billing ships)
|
||||
BUILD-PLAN §7 item 5 (`BUILD-PLAN.md:165-166`) and DESIGN_POV §4 (`DESIGN_POV.md:62-70`) flag
|
||||
**who pays for inference (BYO-key vs Waggle-metered)** as the decision that "quietly reshapes
|
||||
Billing, Onboarding, and Usage" and "should be settled before Billing goes live"
|
||||
(`DESIGN_POV.md:89-90`). This is a **decision gate for PR7**, not a code question — the current
|
||||
billing tab supports either ("supports either but commits to neither", `DESIGN_POV.md:70`).
|
||||
|
||||
---
|
||||
|
||||
## 6. The breadcrumb / `matchNavRoute` label gap (must NOT repeat for /billing)
|
||||
|
||||
### How the breadcrumb label is derived
|
||||
`AppShell.tsx:224-229`:
|
||||
```ts
|
||||
const labelEntries = flattenAppEntries(getDockForTier('power', billingTier)); // 224
|
||||
const activeRoute = matchNavRoute(location.pathname, labelEntries.map(e=>e.route)…); // 225
|
||||
const surfaceLabel = labelEntries.find(e => e.route === activeRoute)?.label ?? null; // 229
|
||||
```
|
||||
`surfaceLabel` is passed to `<StatusBar focusedWindowLabel={surfaceLabel}>`
|
||||
(`AppShell.tsx:304`). `matchNavRoute` (`routes.ts:146-154`) is a longest-prefix match against
|
||||
the **dock-tiers route table only**.
|
||||
|
||||
### The gap (this is the handoff "P3 / PR6 ⌘K-only routes show a fuzzy fallback label" note)
|
||||
`dock-tiers.ts` `POWER_CONFIG` (`dock-tiers.ts:64-118`) does **NOT contain `/benchmarks` or
|
||||
`/platform`** (confirmed by grep — zero hits in dock-tiers.ts). So for those routes
|
||||
`matchNavRoute` returns `null` → `surfaceLabel = null` → **the StatusBar breadcrumb simply
|
||||
does not render** (it is gated `{focusedWindowLabel && (…)}` at `StatusBar.tsx:84`). The
|
||||
PR6 ⌘K-only surfaces therefore show **no breadcrumb at all** (not literally a wrong/fuzzy
|
||||
string — the label is null and the breadcrumb chip is hidden). Either way the surface is
|
||||
unlabeled in the status bar.
|
||||
|
||||
### Requirement for PR7
|
||||
**`/billing` (and `/payment-success`, and conceptually `/auth`) MUST get a breadcrumb label
|
||||
so they don't repeat the unlabeled-surface gap.** The label map (`labelEntries`) is sourced
|
||||
ONLY from `getDockForTier(...)` — i.e. from `dock-tiers.ts`. Two options:
|
||||
1. **Add a dock-tiers entry** (with `route` + `label`) for the billing surface so
|
||||
`matchNavRoute` resolves it — same fix PR6 should have applied to benchmarks/platform. But
|
||||
billing lives under `/settings` today (Settings already has a dock entry `dock-tiers.ts:108`,
|
||||
label "Settings"), so a `?tab=billing` deep-link inherits the "Settings" breadcrumb already
|
||||
— acceptable. A standalone `/billing` would need its own entry.
|
||||
2. **`/auth` needs NO breadcrumb** — it renders outside AppShell (§4), so `AppShell.tsx`'s
|
||||
StatusBar never mounts for it. The gap is irrelevant for auth by construction.
|
||||
|
||||
> Net: the breadcrumb gap is an AppShell-internal concern. `/auth` sidesteps it (no shell).
|
||||
> `/billing` should either ride the existing "Settings" entry (deep-link path) or, if
|
||||
> standalone, add a `dock-tiers.ts` route+label entry — do **not** ship it label-less.
|
||||
|
||||
### Deep-link gap that BLOCKS the "/settings?tab=billing" approach (verified)
|
||||
`SettingsApp` initializes `activeTab` to **`'models'`** (`SettingsApp.tsx:53`) and has **NO
|
||||
`?tab=` / `useSearchParams` reader** (grep: zero `tab=`/`useSearchParams`/`searchParams`/
|
||||
`initialTab` in `SettingsApp.tsx`). So today `/settings?tab=billing` and even the existing
|
||||
`APP_ROUTES.backup = '/settings?tab=backup'` (`routes.ts:52`) **silently open on Models, not the
|
||||
requested tab.** For PR7 to deep-link billing from ⌘K / Upgrade buttons, SettingsApp needs a
|
||||
small **`?tab=` initializer** (read once on mount, snap `activeTab`). This is a real, small
|
||||
MUST-BUILD — without it the catalog "Upgrade to Pro" / UpgradeModal-fallback `navigate('/settings')`
|
||||
(`AppShell.tsx:411-413, 422`) lands a user on Models, not Plan.
|
||||
|
||||
---
|
||||
|
||||
## 7. Existing upgrade entry points (where "Upgrade"/"Manage plan" link today)
|
||||
|
||||
| Surface | Action | Target | Evidence |
|
||||
|---|---|---|---|
|
||||
| Settings → Plan tab, FREE/TRIAL | Pro / Teams cards | `billing.startCheckout('PRO'\|'TEAMS')` → Stripe Checkout | `SettingsApp.tsx:583-598` |
|
||||
| Settings → Plan tab, PRO | "Upgrade to Teams" | `startCheckout('TEAMS')` | `SettingsApp.tsx:604-619` |
|
||||
| Settings → Plan tab, PRO/TEAMS | **"Manage Subscription"** | `billing.openPortal()` → Stripe Customer Portal | `SettingsApp.tsx:621-635` |
|
||||
| Settings → Plan tab, non-ENT | Enterprise CTA | external link `https://www.kvark.ai` | `SettingsApp.tsx:638-652` |
|
||||
| `UpgradeModal` overlay | `onUpgrade(tier)` | `adapter.createCheckoutSession(...)`, **fallback `navigate('/settings')`** | `AppShell.tsx:404-415` |
|
||||
| `TrialExpiredModal` overlay | `onUpgrade(tier)` | `adapter.createCheckoutSession(...)`, fallback `navigate('/settings')` | `AppShell.tsx:417-424` |
|
||||
| ⌘K catalog | "Upgrade to Pro" / "Settings" | `to:"/settings"` | `command-catalog.ts:75-76` |
|
||||
| Sidebar user row | tier label "Trial · 9d" / "Pro" | (display only; row → Settings) | `AppShell.tsx:264-269, 321-322` |
|
||||
|
||||
All upgrade paths converge on Stripe checkout (real) or land on `/settings` (which mis-opens
|
||||
on Models per §6). The "Manage plan" affordance is the existing **"Manage Subscription"**
|
||||
button → Stripe Customer Portal. There is **no `KvarkNudge` component in `apps/web/src`**
|
||||
(grep: 0 hits; CLAUDE.md §9 references it but it is not in the web app today — Enterprise CTA
|
||||
is the inline kvark.ai link at `SettingsApp.tsx:644-651`).
|
||||
|
||||
---
|
||||
|
||||
## 8. Fabrication risks for PR7 (gate-off candidates — HONESTY CONTRACT)
|
||||
|
||||
1. **A logged-in identity that isn't real.** No Clerk/user-auth exists; the session-token is a
|
||||
local dev Bearer. The auth screen must not render a fake signed-in user/name/avatar or
|
||||
claim a session. Degrade to the design's local-first "continue without an account" path
|
||||
when no `CLERK_PUBLISHABLE_KEY` is configured. (`adapter.ts:295-308`, `local/index.ts:2047`,
|
||||
`AppShell.tsx:98-109` accepted "Account" fallback.)
|
||||
2. **Fake invoices / PDFs.** SCREENS §14 lists "invoices (Paid + PDF)". Those live inside the
|
||||
**Stripe Customer Portal**, not our UI. Do NOT render invented invoice rows or fake
|
||||
"Download PDF" links in-app — route to `openPortal()` (`useBilling.ts:84-96`).
|
||||
3. **Fake payment method ("VISA ···4242").** That `…4242` string in SCREENS §14 is design
|
||||
filler. Real card-on-file data lives in Stripe's portal. Do not display a hardcoded masked
|
||||
card in the Manage state.
|
||||
4. **Fake "next charge" / usage / due-today numbers.** SCREENS §14 Checkout shows "Due today
|
||||
$19 / won't be charged until …". Those must come from the real Stripe session, not be
|
||||
string-literal'd. Prefer hosted Stripe Checkout (`checkout.ts:39-50`) which renders the real
|
||||
amounts itself.
|
||||
5. **Presenting tier as fact before it resolves.** Already guarded by `tierResolved`
|
||||
(`useBilling.ts:13-22`); PR7 must preserve that — never show "Free plan" as fact while
|
||||
unresolved (`SettingsApp.tsx:525-536`).
|
||||
6. **A fake monthly/annual price.** The −20% annual toggle is a build gap (§5); when added it
|
||||
must resolve through a real annual price var (server `priceIdForTier(tier, 'annual')`,
|
||||
`checkout.ts:29`) — not a client-side `$19 × 0.8` cosmetic number that doesn't match what
|
||||
Stripe charges.
|
||||
|
||||
---
|
||||
|
||||
## 9. File index (everything PR7 routing touches)
|
||||
|
||||
| Concern | File:line |
|
||||
|---|---|
|
||||
| Route table (add `/auth` sibling, optional `/billing` child, `/payment-success`) | `apps/web/src/App.tsx:52-110` |
|
||||
| Route-wrapper pattern to copy | `apps/web/src/routes/BenchmarkRoute.tsx:1-11`; `routes/SurfaceBoundary.tsx:10-17` |
|
||||
| Barrel | `apps/web/src/routes/index.ts:33-59` |
|
||||
| AppId→URL table (`/auth`,`/billing` are NOT here yet; `backup` deep-link precedent) | `apps/web/src/lib/routes.ts:25-53, 70-80`; `matchNavRoute` `routes.ts:146-154` |
|
||||
| ⌘K catalog (retarget upgrade entry) | `apps/web/src/lib/command-catalog.ts:62-97` |
|
||||
| Shell breadcrumb derivation + label-source gap | `apps/web/src/components/os/AppShell.tsx:224-229, 304`; `StatusBar.tsx:84-93` |
|
||||
| Dock label table (no benchmarks/platform/billing entries) | `apps/web/src/lib/dock-tiers.ts:64-118` |
|
||||
| Existing Billing surface (REUSE) | `apps/web/src/components/os/apps/SettingsApp.tsx:36-47, 511-655` |
|
||||
| Billing hook (REUSE) | `apps/web/src/hooks/useBilling.ts:24-124` |
|
||||
| Adapter Stripe + session-token | `apps/web/src/lib/adapter.ts:2658-2680, 295-331` |
|
||||
| Server Stripe routes | `packages/server/src/stripe/{checkout,portal,sync,webhook}.ts` (checkout `checkout.ts:13-58`) |
|
||||
| Server local session-token (only "auth" today) | `packages/server/src/local/index.ts:2043-2047`; `security-middleware.ts:238` |
|
||||
| SCREENS specs | `docs/design_handoff_waggle_app/SCREENS.md:269-295` |
|
||||
| BYO-vs-metered decision gate | `docs/design_handoff_waggle_app/DESIGN_POV.md:62-70, 89-90`; `BUILD-PLAN.md:165-166` |
|
||||
166
docs/redesign-warm-hive/pr7-recon/07-byo-vs-metered.md
Normal file
166
docs/redesign-warm-hive/pr7-recon/07-byo-vs-metered.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# PR7 Recon — DESIGN_POV #4: BYO-key vs Waggle-metered (the inference-cost decision)
|
||||
|
||||
> RECON ONLY. No product code changed. Every claim below is grounded with `file:line`.
|
||||
> Topic: make the founder's BYO-vs-metered choice **concrete with build cost**, grounded in
|
||||
> what already shipped through PR1–PR6. PR7 = screen 13 Auth (Clerk) + screen 14 Billing (Stripe)
|
||||
> per `docs/redesign-warm-hive/BUILD-PLAN.md §6` (line 145).
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR (the de-facto commitment)
|
||||
|
||||
**The codebase has already committed to Option A: BYO-key + flat SUBSCRIPTION tiers, with no inference metering.** This isn't a design intention — it is *shipped, wired, and tested* across three surfaces:
|
||||
|
||||
1. **BYO-key is the only inference-payment path that exists.** Onboarding's hard model gate and Settings → Models both mount the same `ModelGate` whose entire copy is *"Bring your own key — it's stored encrypted in your Vault and never leaves your machine."* (`apps/web/src/components/os/model-gate/ModelGate.tsx:186`). Waggle never holds an inference key or pays a provider on the user's behalf in any shipped path.
|
||||
2. **Stripe billing is flat `mode: 'subscription'`** — both the desktop sidecar (`packages/server/src/stripe/checkout.ts:40`) and the `apps/www` cloud port (`apps/www/app/api/stripe/checkout/route.ts:170`). Two products (Pro $19, Teams $49/seat), monthly/annual, period-end renewal. **No `mode: 'payment'`, no usage records, no metered prices.**
|
||||
3. **There is zero inference-metering plumbing.** No `createUsageRecord`, no `billing_meter`, no `reportUsage`, no credits/balance ledger anywhere in `packages/server/src` (verified by grep — only hit is a *comment* about not burning Anthropic credits in `packages/server/src/local/index.ts:1980`). The only "usage" surface is an **estimate-based read-only cost dashboard** (`/api/cost/summary`) with a **soft, advisory** daily-budget warning — never a hard cap, never tied to billing.
|
||||
|
||||
**Recommendation: ratify Option A.** PR7 Billing becomes a *theming* task over an already-working subscription flow (near-zero new backend). Option B (Waggle-metered) is a multi-month strategic pivot touching billing, onboarding, the inference path, quota enforcement, and a new credits surface — and it contradicts the local-first / "your key never leaves your machine" promise the product already makes to users in onboarding copy. The recon's job is to make this choice concrete; the decision is the founder's.
|
||||
|
||||
---
|
||||
|
||||
## 1. Evidence — what PR5 actually shipped for BYO-key (D1)
|
||||
|
||||
**`ModelGate.tsx` is the single shared "get a working model" component** — mounted in onboarding step 3 AND Settings → Models (`apps/web/src/components/os/model-gate/ModelGate.tsx:8–20` header doc).
|
||||
|
||||
- BYO-key cloud path: pick provider → paste key → **live-validate** (`adapter.testApiKey(..., { live: true })`, line 94) → write to Vault (`adapter.setProviderKey`, line 99).
|
||||
- Explicit BYO framing in the UI: *"Bring your own key — it's stored encrypted in your Vault and never leaves your machine."* (line 186).
|
||||
- Honesty contract already enforced: "verified" only after a live probe; a format-only pass says "looks valid (not live-verified)" (lines 18–20, 247–253).
|
||||
- Local path (Ollama) is the other route to a working model — also zero cost to Waggle (lines 266–304).
|
||||
|
||||
**Onboarding hard gate (D2):** `ModelGateStep.tsx` disables "Continue" until `useHasWorkingModel` is true (`apps/web/src/components/os/overlays/onboarding/ModelGateStep.tsx:49`), with copy *"Bring your own provider key … Nothing leaves your machine without your key."* (lines 26–28) and one soft escape ("I'll do this later" → Home `NoModelBanner`, lines 9–17).
|
||||
|
||||
**Interpretation:** the user pays the provider directly. Waggle's margins are clean; it never carries inference cost. This is textbook **Option A (BYO-key)** from DESIGN_POV §4 (`docs/design_handoff_waggle_app/DESIGN_POV.md:62–70`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Evidence — Stripe tiers are flat SUBSCRIPTION, not metered
|
||||
|
||||
**`packages/shared/src/tiers.ts`** — canonical 5-tier system (TRIAL/FREE/PRO/TEAMS/ENTERPRISE), pricing in the header doc (lines 7–18): flat per-seat/per-month dollar amounts. `stripePriceId` is a single price per tier (lines 122, 143) — a **fixed recurring price**, not a metered/usage price.
|
||||
|
||||
**Desktop sidecar Stripe (`packages/server/src/stripe/`):**
|
||||
- `checkout.ts:40` — `mode: 'subscription'`, `line_items: [{ price: priceId, quantity: 1 }]`. Quantity 1, fixed price. (Only PRO/TEAMS, line 25.)
|
||||
- `webhook.ts:114–158` — handles exactly **3** subscription lifecycle events: `checkout.session.completed`, `customer.subscription.updated`, `customer.subscription.deleted`. **No `invoice.created` / usage-record handling.** On cancel → tier drops to FREE (line 150).
|
||||
- `index.ts:71–100` — `tierFromPriceId` / `priceIdForTier` map fixed monthly/annual price IDs to tiers. Pure subscription mapping.
|
||||
- `portal.ts:41` — `stripe.billingPortal.sessions.create` — defers payment-method / invoice / cancel management to **Stripe's hosted Customer Portal** (this is where invoices and payment methods legitimately come from, never invented locally).
|
||||
|
||||
**`apps/www` cloud port (already built — see §4):**
|
||||
- `app/api/stripe/checkout/route.ts:170` — `mode: 'subscription'`, fixed `priceId` resolved by env or `lookup_key` `${tier}_${billing}` (lines 109–119).
|
||||
- `app/api/webhooks/stripe/route.ts:196–205` — same 3 subscription events, mirrored to Clerk `publicMetadata`. No metering.
|
||||
|
||||
**Conclusion:** there is no metered/usage-based Stripe billing anywhere. The model is "pay a flat monthly fee for *capabilities* (workspaces, connectors, governance), not for *inference*." Inference is on the user's own key/quota.
|
||||
|
||||
---
|
||||
|
||||
## 3. Evidence — the current Usage/cost surface (what's shown today)
|
||||
|
||||
**There is NO `UsageApp.tsx`.** The de-facto Usage screen is `TelemetryApp.tsx` — titled **"Usage & cost"** in the UI (`apps/web/src/components/os/apps/TelemetryApp.tsx:159`). PR6b's "Usage/budget" screenshot (`docs/redesign-warm-hive/smoke-pr6b-20260618/04-usage-budget.png`) is this surface.
|
||||
|
||||
What it shows (all **read-only, estimate-based** — never a balance to draw down):
|
||||
- Total tokens, **estimated** cost, by-model spend, by-workspace (TEAMS-gated) — from `GET /api/cost/summary` and `/api/cost/by-workspace` (`TelemetryApp.tsx:55–100`).
|
||||
- A **daily budget** the user can set, which produces a **soft warning at 80% / "exceeded"** status (`TelemetryApp.tsx:151`, `192–195`) — purely advisory.
|
||||
|
||||
The backing route confirms the "estimate, not meter, not enforce" nature:
|
||||
- `packages/server/src/local/routes/cost.ts:8–9` — *"Data source: in-memory CostTracker … All cost values are **estimates** based on published model pricing."*
|
||||
- Returns `estimatedCost` everywhere (lines 175, 181, 188).
|
||||
- The daily-budget "exceeded" status (lines 162–169) sets a **string status only** — nothing in the codebase blocks a request when exceeded. It's a dashboard, not a quota gate.
|
||||
- Free for all tiers per a product decision (line 263, "P22 … usage/telemetry info is free for all tiers").
|
||||
|
||||
**Interpretation:** today's Usage tells the user *"here's roughly what your own provider key is costing you"* — a BYO-key courtesy readout. It is structurally NOT a metered-balance/credits surface.
|
||||
|
||||
---
|
||||
|
||||
## 4. Evidence — Auth (Clerk) status: NOT in the desktop app; FULLY built in `apps/www`
|
||||
|
||||
**Desktop `apps/web` has no Clerk and no real logged-in identity.** Grep for `Clerk|@clerk|SignIn|auth0` across `apps/web/src` → **no files**. "Identity" today = a `tier` field in `config.json`, read fail-closed-to-FREE by `readTierFromDataDir` (`packages/server/src/middleware/assert-tier.ts:21–32`). There is a *data-model placeholder*: `User.clerkId: string` exists in `packages/shared/src/types.ts:6`, but nothing populates it from a real Clerk session in the desktop path.
|
||||
|
||||
**`apps/www` (Next.js cloud/landing) already has a complete, themed Clerk + Stripe SaaS surface** — this is the direct reference (and possibly the literal home) for PR7's screens 13/14:
|
||||
- `apps/www/app/sign-in/[[...sign-in]]/page.tsx` — `<SignIn />` Clerk component, themed via `<ClerkProvider>` (header doc line 15).
|
||||
- `apps/www/app/sign-up/[[...sign-up]]/page.tsx` — sign-up (verified to exist via glob).
|
||||
- `apps/www/middleware.ts:6,16` — `clerkMiddleware()` wired, matcher includes API routes.
|
||||
- `apps/www/app/account/page.tsx` — account surface.
|
||||
- `apps/www/app/api/stripe/checkout/route.ts` — lazy-create Stripe Customer → store id in Clerk `publicMetadata` (lines 81–100); subscription checkout (line 170).
|
||||
- `apps/www/app/api/webhooks/stripe/route.ts` — mirrors subscription state Stripe → Clerk metadata (3 events).
|
||||
- `apps/www/app/_components/Pricing.tsx` — pricing cards.
|
||||
|
||||
**Net for PR7 Auth (screen 13):** in `apps/www`, Auth is REAL and only needs **theming to the warm-Hive tokens**. In the desktop `apps/web`, Auth is **MUST-BUILD if** the desktop must show a real logged-in identity (otherwise the design's own line "An account is optional — Waggle runs fully local without one" — `SCREENS.md:274` — means desktop can stay identity-light and route account/billing to the cloud `apps/www`). **This is itself a sub-decision the founder should confirm: does screen 13 live in `apps/www` only, or also in the desktop shell?**
|
||||
|
||||
---
|
||||
|
||||
## 5. The screen-14 fabrication risks (honesty contract carried from PR3–PR6)
|
||||
|
||||
`SCREENS.md:282–295` (screen 14 Billing) calls for four states. Three of them name fields that **must come from Stripe, never be invented**:
|
||||
|
||||
| Field in the design | Risk | Required gating |
|
||||
|---|---|---|
|
||||
| **Invoices (Paid + PDF)** (`SCREENS.md:292`) | Fabricating an invoice list / fake PDFs | Source ONLY from Stripe Customer Portal (`portal.ts` already does this) — do NOT render a local invoice list. If portal isn't reachable, show "Manage in Stripe" link, not a stub table. |
|
||||
| **Payment method "VISA ···4242"** (`SCREENS.md:291`) | Hardcoding a fake card (the `4242` test card is literally in the spec text) | Never render a card brand/last4 the app doesn't have from Stripe. The portal owns this. The `4242…` in the design is a *mockup placeholder* — it must not ship as real-looking data. |
|
||||
| **"Next charge" / billing cycle** (`SCREENS.md:291–292`) | Inventing a renewal date | Only from Stripe subscription data via the portal. |
|
||||
| **Checkout card form (email/card/expiry/CVC)** (`SCREENS.md:287–289`) | Building a *fake* in-app card form that collects nothing real | Use **Stripe Checkout** (hosted) — the design itself says "Use Stripe Checkout/Customer Portal where possible" (`SCREENS.md:294`). The in-app form mock is illustrative; real PCI capture is Stripe's. |
|
||||
| **Logged-in identity / avatar+name** (screen 13) | Showing a name/email for a session that isn't real | Bind to the real Clerk session (`apps/www`) or render the honest "no account / local-first" state (`SCREENS.md:274`). The desktop's `userName={null}` → "Account"/"W" pattern is the honest fallback (BUILD-PLAN §9 deferred note, line 191). |
|
||||
|
||||
**Plus a Usage-screen trap** if Option B is ever pursued: a credits/balance number, a "you've used X of Y tokens" quota bar, or a "$N remaining" figure would all be **fabricated** today (no ledger exists). The current estimate-only dashboard (§3) is the honest ceiling — do not dress it up as a metered balance.
|
||||
|
||||
---
|
||||
|
||||
## 6. The decision, made concrete
|
||||
|
||||
### Option A — Ratify BYO-key + flat subscription (RECOMMENDED, de-facto current state)
|
||||
|
||||
PR7 Billing themes the **existing** Stripe subscription flow; near-zero new backend. Exactly what's needed:
|
||||
|
||||
- **Auth (screen 13):**
|
||||
- **Cloud (`apps/www`):** theme the existing `<SignIn/>`/`<SignUp/>` Clerk components + the brand split-panel to warm-Hive tokens. Add the local-first trust copy ("an account is optional"). ~UI-only.
|
||||
- **Desktop (`apps/web`):** confirm whether it needs a real auth surface at all (§4 sub-decision). If "local-first, no account" stands, desktop screen 13 is a *deep-link to the cloud account page* + the honest no-account state — minimal build. If a real desktop session is wanted, that's the one genuine new piece (embed Clerk in the SPA / token bridge) — flag as a scoped add-on, not core to Option A.
|
||||
- **Billing (screen 14):**
|
||||
- **Plans state:** theme to tokens; data already exists (tiers.ts, `useBilling.startCheckout`). Add monthly/annual toggle UI (the −20% annual already exists as price IDs — `index.ts:91–100`).
|
||||
- **Checkout state:** redirect to **Stripe Checkout** (already wired both surfaces). The "in-app card form" from the design ships as a themed *intro/summary*, then hands off to Stripe — no PCI surface built.
|
||||
- **Success state:** `useBilling` already syncs `?session_id=` post-checkout (`useBilling.ts:103–115`). Theme the success ring/receipt; receipt link → Stripe.
|
||||
- **Manage state:** `billing.openPortal()` already exists (`useBilling.ts:84`, `SettingsApp.tsx:625`) → Stripe Customer Portal owns invoices/payment-method/cancel. Theme the entry; do NOT build a local invoice/card UI (§5).
|
||||
- **Usage:** leave the estimate-only "Usage & cost" dashboard as-is; optionally reskin to warm-Hive in the long tail. No metering.
|
||||
- **Net new backend for Option A: essentially none.** Possibly: thread `STRIPE_PRICE_*_ANNUAL` into the desktop checkout UI's monthly/annual toggle (the resolver already supports it — `index.ts:91`), and (if desktop auth is wanted) a Clerk-session bridge. Otherwise pure theming + wiring existing routes to the new screens.
|
||||
|
||||
### Option B — Pivot to Waggle-metered (MAJOR ARC, strategic reversal)
|
||||
|
||||
Enumerated NEW plumbing (none of this exists today):
|
||||
|
||||
1. **Inference-cost metering per request** — a real, persisted, authoritative usage ledger (today's CostTracker is **in-memory + estimate-only**, `cost.ts:8`; it would need to become durable, exact, and per-user/account).
|
||||
2. **Waggle holds the provider keys** — a managed model pool where Waggle's own key pays the provider. This **directly contradicts** shipped onboarding/Settings copy ("your key never leaves your machine") and the local-first promise — a product-positioning reversal, not just code. (`managedModelPool` capability exists as a *flag* in tiers.ts:97/130 but has no inference-path implementation behind it.)
|
||||
3. **Usage caps / quota enforcement** — convert the *advisory* budget (`cost.ts:162–169`, soft warning only) into a **hard gate** that blocks chat requests at the inference path when a balance/quota is exhausted. New enforcement point in the agent loop.
|
||||
4. **Stripe metered/usage-based billing** — `mode: 'payment'` top-ups or metered subscription items + `createUsageRecord`/billing-meter reporting. New webhook events (`invoice.created`, usage aggregation). None of the current 3-event handlers (`webhook.ts`) cover this.
|
||||
5. **A credits/balance surface** — a new "$N remaining / buy more credits" screen + the ledger behind it. (None exists; building it without the ledger would be fabrication — §5.)
|
||||
6. **Usage screen rework** — from "here's your own-key estimate" to "here's your metered balance, draw-down, and top-up" — a full rebuild of `TelemetryApp`.
|
||||
7. **Margin/abuse controls** — rate limits, anti-abuse, cost-of-goods accounting that the BYO model never needed because Waggle carried no inference cost.
|
||||
|
||||
This is a multi-month arc that reshapes Billing, Onboarding (the model gate would invert — from "add your key" to "you're metered"), Usage, and the core inference path, and it takes on inference COGS + abuse risk that the current architecture deliberately avoids.
|
||||
|
||||
### Recommendation
|
||||
|
||||
**Ratify Option A.** Rationale: (1) the codebase has *already committed* to it end-to-end (BYO-key gate + flat subscription + no metering), so A is "finish what's shipped," (2) it keeps the local-first / "your key never leaves your machine" promise the product *already makes to users in onboarding*, (3) it keeps margins clean (no inference COGS), and (4) PR7 collapses to theming + wiring existing routes. Option B is a deliberate strategic pivot with real COGS, abuse surface, and a contradiction of live product copy — worth a separate, founder-led decision, **not** something PR7 should absorb. DESIGN_POV §4 said "the current design supports either but commits to neither" (`DESIGN_POV.md:70`); the *implementation* has since committed to A. PR7 should make that commitment explicit and themed.
|
||||
|
||||
---
|
||||
|
||||
## 7. Open sub-decisions for the founder (surfaced, not decided)
|
||||
|
||||
1. **Does screen 13 (Auth) live in `apps/www` only, or also in the desktop `apps/web`?** Desktop has no Clerk today; the design says accounts are optional. If desktop stays identity-light, PR7 desktop-Auth is a deep-link + honest no-account state (cheap). If a real desktop session is wanted, add a scoped Clerk-bridge task.
|
||||
2. **Monthly/annual toggle on the desktop Billing tab** — the annual price resolver already exists (`index.ts:91`); the desktop UI currently only calls `startCheckout('PRO'|'TEAMS')` with default monthly (`SettingsApp.tsx:584`). Adding the toggle is small but is genuinely new desktop UI.
|
||||
3. **Where does screen 14 Billing render?** The richest, already-real flow is in `apps/www` (Clerk-linked). The desktop SettingsApp Billing tab is a thinner subscription surface. PR7 could (a) theme both, or (b) make desktop Billing a deep-link to the cloud account page. Confirm.
|
||||
|
||||
---
|
||||
|
||||
## Appendix — files read for this recon (all `file:line` claims above traceable to these)
|
||||
|
||||
- `docs/design_handoff_waggle_app/DESIGN_POV.md` (§4, lines 62–70)
|
||||
- `docs/redesign-warm-hive/BUILD-PLAN.md` (§6 PR7 row line 145; §7 #5 line 165; §9 deferred note line 191)
|
||||
- `docs/design_handoff_waggle_app/SCREENS.md` (screen 13 lines 269–278; screen 14 lines 282–295)
|
||||
- `packages/shared/src/tiers.ts` (5 tiers, single stripePriceId per tier)
|
||||
- `packages/shared/src/types.ts:6` (`User.clerkId` placeholder)
|
||||
- `packages/server/src/stripe/{index,checkout,webhook,portal}.ts` (subscription-only)
|
||||
- `packages/server/src/middleware/assert-tier.ts` (config.json tier, no real identity)
|
||||
- `packages/server/src/local/routes/cost.ts` (estimate-only, advisory budget)
|
||||
- `apps/web/src/components/os/model-gate/ModelGate.tsx` (BYO-key, shipped)
|
||||
- `apps/web/src/components/os/overlays/onboarding/ModelGateStep.tsx` (hard gate)
|
||||
- `apps/web/src/components/os/apps/TelemetryApp.tsx` ("Usage & cost" surface)
|
||||
- `apps/web/src/hooks/useBilling.ts` (checkout/portal/sync, subscription)
|
||||
- `apps/web/src/components/os/apps/SettingsApp.tsx` (Billing tab, upgrade/portal)
|
||||
- `apps/www/{middleware.ts, app/sign-in/.../page.tsx, app/api/stripe/checkout/route.ts, app/api/webhooks/stripe/route.ts}` (Clerk + Stripe subscription, already built — PR7 reference)
|
||||
Reference in New Issue
Block a user