feat(grid-wallet-prod): fork the wallet demo into a production demo (phone + API panel, passkey-only) - #730
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. 2 Skipped Deployments
|
|
Warning This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
This stack of pull requests is managed by Graphite. Learn more about stacking. |
|
Too many files changed for review. ( |
Merge activity
|
5d67e3a to
70931d5
Compare
|
✱ Stainless preview builds for gridNo changes were made to the SDKs. This comment is auto-generated by GitHub Actions and is automatically kept up to date as you push. |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…API panel Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…he layout Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…case Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… resize math Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drop the "Save a passkey?" interstitial sheet — tapping Continue with passkey now starts the WebAuthn create ceremony directly, which also keeps the tap's user activation for the system dialog. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…screen Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nd signed cash-out (Phase 2) (#740) ## Summary Wires `components/grid-wallet-prod` from fully-scripted to the **real Grid API** (public sandbox), per the Phase 2 spec (`docs/superpowers/specs/2026-07-22-grid-wallet-prod-live-api-design.md`). The API panel now renders the requests/responses that actually went over the wire. **The full money loop is live-verified end to end:** passkey sign-in → Add $20 (real platform on-ramp, `COMPLETED`) → Cash out $5 (quote → browser-signed `Grid-Wallet-Signature` execute → polled to `COMPLETED` in ~62s), with the displayed balance tracking the real account throughout. ### What's in here - **Server proxy** (`/api/grid/[...path]`): allow-listed paths only, Basic-auth injected server-side, `{request,response}` envelopes with redacted auth — the panel logs real traffic. - **Browser crypto** (`gridCrypto.ts`): TEK keygen, HPKE encrypt/decrypt, Turnkey stamps via `@turnkey/*` — session keys never leave the client; cross-checked against `scripts/embedded-wallet-sign.js`. - **Real auth** (`gridSession.ts`): first-run EMAIL_OTP bootstrap (magic `000000` in sandbox) → registers a real Grid `PASSKEY` credential via WebAuthn → returning sign-ins are pure passkey (challenge → `.get()` → verify). - **Webhook receiver** (`/api/webhooks`): `X-Grid-Signature` P-256 verification against `GRID_WEBHOOK_PUBKEY`, event ring buffer + poll route. - **Live money**: balance/activity reads, Add money via the real platform on-ramp (the customer-wallet sandbox fund endpoint mints unspendable book balance — discovered live), outbound quote/stamp/execute/poll with completion checkmarks gated on `COMPLETED`. - 38 vitest tests across the pure modules; every task browser-verified against the live sandbox. ### Env contract `GRID_CLIENT_ID/SECRET`, `GRID_API_BASE_URL`, `GRID_CUSTOMER_ID`, `GRID_WEBHOOK_PUBKEY` in `.env.local` (git-ignored). Prod cutover = swap those values; first tap re-runs passkey registration against the production customer. ### Tracked pre-prod follow-ups (not in this PR) - ~~Gate `GET /api/webhooks/events`~~ — route removed; deliveries are pushed over SSE instead of polled. - Proxy customer-scoping before production keys (today it's a single-customer sandbox gateway). - ~~Wire real activity rows onto the phone~~ — done below. - Phone-side failure states for execute errors (panel-only today). - Report upstream: `POST /quotes` returns undocumented 200 for platform-sourced quotes. --- ## Update — funding flows, webhook-driven arrivals, sandbox gating A second pass over the same component. Everything below was verified against the live sandbox in a browser; the API panel now shows **only** real traffic. ### Auth follows the documented bootstrap order - Sign-in leads with the credential a Global Account is actually born with (`EMAIL_OTP`), with the entry step prefilled from the credential's own `nickname` — no invented address. - A passkey is added **later**, from the wallet, as its own signed action: one device ceremony authorized by the live session (it used to register *and* immediately authenticate, so a single tap asked for approval twice). - The CTA advertises the credential `signIn` will really use. Passkeys are device-bound, so a passkey listed on the account but registered elsewhere no longer sends this browser into an assertion it can't satisfy. - A lapsed session (15 min) can now re-authenticate in place: the OTP sheet has a surface on the wallet screen, and the WebAuthn call runs inside the tap's user activation instead of behind the re-auth round-trip. ### Funding uses the endpoints Grid actually supports - **Add money → country → deposit instructions**: the customer's own account/routing/rails/reference, read from `GET /customers/internal-accounts`. (The EUR block is a clearly-marked placeholder IBAN in `data/placeholderDeposit.ts` until the customer has an EUR account — the read already returns one section per fiat account, so a real one appears with no code change.) - **…or "add an account"** to pull from. A pull quote sources the **ExternalAccount** — the previous body pointed at the platform's internal account. Grid rejects `/execute` for such a quote (*"funds must be pushed … use POST /sandbox/send"*), so the quote is created and left pending, and `POST /sandbox/send` stands in for the push. Amounts here are USD cents, not USDB micro-units. - **Add from crypto**: Base / Solana / Ethereum, network → amount → the real Grid-provisioned deposit address for that chain. - Saved banks are seeded from `GET /customers/external-accounts` (filtered to USD/EUR, ACTIVE); selecting one quotes against that account instead of re-creating it — which also fixes the stale-recipient gap noted in #740's original follow-ups. ### Webhooks - **`X-Grid-Signature` parsing was rejecting every real delivery.** Grid sends `{"v":"1","s":"…"}` (or a bare base64 header); we required `{"version":1,"signature":"…"}`, so verification never ran and everything 401'd. The earlier tests signed using our own assumed shape, so they passed — they now assert the documented formats. - Verified deliveries **stream to the panel over SSE** (`/api/webhooks/stream`); nothing polls. The bus is pinned to `globalThis` because Next gives each route handler its own module instance — the publisher and subscriber were otherwise in different registries. - **An arrival, not a tap, is what says money landed**: `INCOMING_PAYMENT.COMPLETED` drives the toast and Activity row from its payload and re-reads the balance from the API. Confirming an add no longer claims anything. ### Panel, wallet, packaging - The panel shows only real requests: every synthesized call and response body is gone (including a fabricated `POST https://your-app.com/webhooks/grid` and ~330 lines of stubbed quote/transaction/session responses). Flows with no client call behind them log nothing. - Activity renders the real `GET /transactions` history (EXPIRED quotes filtered out); the balance hero shows **available** with the account **total** beneath, since sandbox book balance runs ahead of spendable. - Bank picker trimmed to the US + euro area (ACH + SEPA); APY 3%. - `Dockerfile` + `.dockerignore` on `output: 'standalone'`. **`NEXT_PUBLIC_GRID_SANDBOX` is a build arg** (NEXT_PUBLIC_* is inlined at build time) and gates the sandbox-only "simulate funding" affordance — a production image must be built without it. - `scripts/ensure-business-customer.mjs`: idempotent `BUSINESS` customer provisioning, writes `GRID_BUSINESS_CUSTOMER_ID` to `.env.local`. 60 vitest tests (was 38). ### Still open - A received webhook only drives the *add* path; `INCOMING_PAYMENT.PENDING` and `OUTGOING_PAYMENT.*` are logged but don't touch the wallet. - The sandbox funding stand-in for the *instructions* screen still runs the platform on-ramp (no quote exists there to `/sandbox/send`). - USDB-native funding over Spark works (`REALTIME_FUNDING` + `cryptoNetwork: SPARK`, rate 1.0, verified) but isn't offered in the UI — both current paths convert. - `docker build` unverified: the daemon wasn't running here. The standalone server itself was run and exercised (page, webhook, SSE, proxy → Grid all responding). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
….example The README still described sign-in as "passkey only" and the live API as "Phase 2, planned" — both contradicted by the code in this branch. It now documents what the app actually does: EMAIL_OTP-first auth with a passkey added later, the push/pull split for funding (and why a pull quote can't be executed), webhook-driven arrival reporting over SSE with the ngrok setup, the sandbox flag and Docker build, and the sandbox quirks worth knowing (skipped email delivery, book-vs-spendable balance). .env.example documents the env contract the README refers to; .env.local stays git-ignored and out of the repo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pjqydq34z8DBK1kBrioXK2
70931d5 to
f482893
Compare
…ents (#742) ## Summary Fan-out to multiple clients already worked; **replay on reconnect didn't**. This adds it. The demo drives one hardcoded customer, so every connected panel is watching the same account and should see every delivery. There is no per-client filtering — `pushEvent` walks all subscribers — and that part is verified below. The gap was that a client only ever saw events that arrived *while it was connected*, so an `EventSource` auto-reconnect after a network blip silently dropped whatever landed in the gap. ### What changed - Every frame now carries an `id:` (a per-process sequence number). `EventSource` echoes the last one back as `Last-Event-ID` when it reconnects, and the stream replays events after that point. - **A fresh client replays nothing.** It presents no id, and replaying history would render old deliveries as if they had just arrived — the panel timestamps entries on receipt. - **Subscribe happens before replay**, so a delivery landing mid-replay queues behind it rather than slipping between the two steps. - The connect handshake comment reports `clients=N`. `EventSource` ignores comment frames; it shows up in `curl -N .../api/webhooks/stream` when you want to confirm every panel is attached. ### Verified Three concurrent clients, one delivery each time, with a disconnect and a reconnect in the middle: ``` A handshake: : connected clients=1 B: clients=2 C: clients=3 deliver #1 → A [1] | B [1] | C [1] ← every client, no filtering B disconnects; #2 and #3 land while it is away A [1,2,3] | C [1,2,3] ← survivors unaffected B reconnects with Last-Event-ID: 1 B replayed: [2,3] ← exactly what it missed D connects fresh: [] ← no invented history deliver #4 → A [1,2,3,4] | C [1,2,3,4] | B [2,3,4] | D [4] ``` Signature verification is untouched; the replay path only re-sends events that already passed it. ### Limits (documented in the module) - The bus is **per server process**. With multiple replicas, only the instance that received a delivery can fan it out to its own clients — a shared channel (Redis pub/sub) would be needed. - The ring buffer holds the **last 50** events, so a client away longer than that loses the overflow. Stacked on #730. ### Update: catch-up must not duplicate either Self-review caught a flaw in the first push. Subscribe-then-replay avoids gaps but **double-sends**: an event landing between `subscribe` and the replay loop goes out live *and* again from the backlog. Replay-then-subscribe has the opposite bug (it loses that event). The stream now subscribes immediately but **holds** live events, replays the backlog, then flushes the held ones the replay didn't cover. Verified by reconnecting from `Last-Event-ID: 1` while deliveries stream in: ``` client saw: 2,3,4,5,6 duplicates: none gaps : none (2..6 as expected) ``` Also added `webhookEvents.test.ts` covering the bus contract: fan-out to every subscriber, survival of a subscriber that throws, the `seq`/`eventsSince` replay windows, and the ring dropping the overflow a long-gone client can no longer catch up on. **69 tests** total (was 60). ### Update: the stream was readable by any site Second self-review finding, and a more serious one. `/api/webhooks/stream` is unauthenticated and carries whatever Grid delivered — transaction ids, amounts, and `counterpartyInformation`, which per Grid's schema can include a counterparty's **name, birth date and nationality**. That is the same exposure `GET /api/webhooks/events` was deleted for earlier in this stack; the SSE replacement inherited it. An `EventSource` opened by another site is now refused (`Sec-Fetch-Site: cross-site` → 403), so a page on someone else's origin can't attach to a panel running on your machine: ``` same-origin (our own panel) → 200 no header (curl / scripts) → 200 cross-site (another website) → 403 ``` **This is not access control**, and the code comment says so: anyone who can reach the host can still read the stream, which behind an ngrok tunnel means anyone with the URL. Gating it properly — a session cookie, or not exposing it past localhost — belongs on the pre-prod list next to proxy customer-scoping. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Pjqydq34z8DBK1kBrioXK2

Summary
Forks
components/grid-wallet-demo→components/grid-wallet-prodper the spec in the parent PR:dev:appledropped, new README, HANDOFF.md removed. The demo and its docs embed are untouched.page.tsxrewritten to phone + resizable API panel only;ConfigurePaneldeleted; mobile configure⇄playground chrome (back pill, explore CTA, blur tray) removed from TSX and SCSS.useWalletDemoLogicpinspersona: 'fintech'andmethods: ['passkey'];setPersona/useCase/setUseCase/toggleMethodpruned. The phone's sign-in offers only Continue with Passkey.useColumnResizeno longer reserves the removed 400px configure column, so the API panel can use the full width (verified by live drag test: cap 880px → 1280px at a 1600px viewport).Deferred to Phase 2 (live API integration spec): embed-path default-width math, orphaned
UseCasePicker/USE_CASESpruning,layout.tsxmetadata copy.Verification
npm run build+tscclean at every step; Playwright smoke test on :4001 confirmed no sidebar, passkey-only sign-in, and the API panel appending calls as the phone flow runs.🤖 Generated with Claude Code