feat(grid-wallet-prod): live Grid API — real passkey auth, funding, and signed cash-out (Phase 2) - #740
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… cross-check 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>
…ter/sign-in) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rewires the passkey tap to gridSession.signIn (Task 5) instead of the
cosmetic ceremony, routing first-run EMAIL_OTP bootstrapping through the
existing AuthSheet OTP step and the WebAuthn create/get ceremonies through
the tap's own activation chain. Every real {request,response} envelope
is logged into the API panel via the new envelopeToApiCall (gridEntry.ts),
which replaces the synthesized stub whenever a real resBody is present.
Guards the sign-in call with an in-flight ref so a second tap landing
before `running` re-renders can't fire a second WebAuthn ceremony.
Also fixes next.config.mjs's webpack resolve.modules order: the absolute
project-root entry was shadowing @turnkey/crypto's own nested @noble/*
pin (a different, incompatible major version with different subpaths),
which broke `npm run build` once gridSession/gridCrypto were first
reachable from client code. The relative 'node_modules' entry now comes
first so the standard nested resolution wins, with the absolute path
only as a fallback.
Sandbox demo OTP autofill changed from 123456 to the magic code 000000
required by the live HPKE-encrypted verify.
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>
…ent add, partial failure) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lure Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…und mints book balance only) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…LETED Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… drop dead OTP branch Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
1 Skipped Deployment
|
|
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. |
Greptile SummaryThis PR replaces the fully-scripted
Confidence Score: 3/5Safe to merge for continued sandbox development; the unauthenticated /api/webhooks/events endpoint must be gated before any production key rotation. The core money loop is well-structured and verified end-to-end. The webhook events reader ships completely open — any caller who knows the URL can read all real Grid event payloads without credentials. The PR description flags this as a known pre-prod follow-up, but the route is live in this diff and would expose live transaction data the moment real keys are swapped in. Two secondary concerns (CUSTOMER_ID empty-string fallback, caches not cleared on sign-out) are manageable in a single-customer sandbox but need cleanup before wider use.
|
| Filename | Overview |
|---|---|
| components/grid-wallet-prod/src/app/api/webhooks/events/route.ts | New GET endpoint returning all buffered webhook events with no authentication. |
| components/grid-wallet-prod/src/app/api/grid/[...path]/route.ts | New server-side proxy with allow-list, Basic auth injection, and {customerId} substitution. Empty CUSTOMER_ID fallback produces malformed paths. |
| components/grid-wallet-prod/src/lib/gridSession.ts | Real Grid auth: EMAIL_OTP bootstrap to passkey register to passkey sign-in. clearSession exported and called on reset/logout. |
| components/grid-wallet-prod/src/lib/gridTransfer.ts | Quote creation, stamp/execute, and polling. Module-level externalAccountCache is never cleared on sign-out. |
| components/grid-wallet-prod/src/lib/gridCrypto.ts | Browser crypto: TEK keygen, HPKE encryption, Turnkey API key stamping. lowS:false correctly accepts non-canonical signatures from Node crypto. |
| components/grid-wallet-prod/src/lib/webhookEvents.ts | In-memory 50-event ring buffer. Works in a single Node.js process; will not share events across serverless instances. |
| components/grid-wallet-prod/src/hooks/useWalletDemoLogic.ts | Major rework wiring real Grid API calls. refreshBalance omitted from onTransferExecute dep array due to TDZ; functionally correct because both share [logEnvelope] as dep. |
| components/grid-wallet-prod/src/app/api/grid/allowlist.ts | Allow-list with anchored regexes and method enforcement. Redacts Authorization before echoing to panel. Correct and well-tested. |
Sequence Diagram
sequenceDiagram
participant Browser
participant Proxy as /api/grid proxy
participant Grid as Grid API
participant Webhook as /api/webhooks
Note over Browser,Grid: First-run sign-in EMAIL_OTP to passkey registration
Browser->>Proxy: GET /auth/credentials
Proxy->>Grid: GET /auth/credentials Basic auth injected
Grid-->>Browser: emailOtpId + passkeyId
Browser->>Proxy: POST challenge
Grid-->>Browser: otpEncryptionTargetBundle
Browser->>Browser: HPKE-encrypt OTP plus TEK pubkey
Browser->>Proxy: POST verify 202 leg
Grid-->>Browser: payloadToSign + requestId
Browser->>Browser: stamp TEK priv over payloadToSign
Browser->>Proxy: POST verify plus Grid-Wallet-Signature
Grid-->>Browser: session
Browser->>Browser: navigator.credentials.create WebAuthn
Browser->>Proxy: POST /auth/credentials passkey 202 leg
Grid-->>Browser: payloadToSign
Browser->>Browser: stamp session priv
Browser->>Proxy: POST /auth/credentials plus Grid-Wallet-Signature
Grid-->>Browser: passkeyAuthMethodId
Note over Browser,Grid: Add money on-ramp
Browser->>Proxy: GET /platform/internal-accounts
Browser->>Proxy: POST /sandbox fund platform account
Browser->>Proxy: POST /quotes platform to wallet
Browser->>Proxy: POST execute no signature
loop poll until COMPLETED
Browser->>Proxy: GET /transactions/id
end
Note over Browser,Grid: Cash out
Browser->>Proxy: POST /customers/external-accounts
Browser->>Proxy: POST /quotes wallet to external
Grid-->>Browser: payloadToSign
Browser->>Browser: stamp session priv
Browser->>Proxy: POST execute plus Grid-Wallet-Signature
loop poll until COMPLETED
Browser->>Proxy: GET /transactions/id
end
Note over Grid,Webhook: Webhooks
Grid->>Webhook: POST /api/webhooks plus X-Grid-Signature
Webhook->>Webhook: verifyGridSignature P-256
Webhook->>Webhook: pushEvent ring buffer
Browser->>Webhook: GET /api/webhooks/events unauthenticated
Prompt To Fix All With AI
Fix the following 4 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 4
components/grid-wallet-prod/src/app/api/webhooks/events/route.ts:7-9
**Unauthenticated event-listing endpoint**
`GET /api/webhooks/events` returns the full ring buffer of received webhook events with no auth check. Every event payload contains real Grid API data — transaction IDs, event types, timestamps, and the raw `data` field. Any unauthenticated caller who discovers this URL can poll it indefinitely and read the complete event history for all transactions processed by this server instance. The PR description calls this out as a pre-prod follow-up, but the endpoint is live in this diff. A minimal fix before this goes near production keys is a `Bearer` or session-cookie check, or at minimum returning an empty array when no valid session header is present.
### Issue 2 of 4
components/grid-wallet-prod/src/app/api/grid/[...path]/route.ts:12
**Empty `CUSTOMER_ID` produces malformed Grid paths**
`GRID_CUSTOMER_ID ?? ''` defaults to an empty string when the env var is not set. `substituteCustomerId` then replaces every `{customerId}` token with `''`, producing paths like `/customers//internal-accounts`. The Grid API will return an error for these, but the failure will surface as a confusing upstream 404/400 with no hint that proxy misconfiguration is the root cause.
### Issue 3 of 4
components/grid-wallet-prod/src/lib/gridTransfer.ts:25
**`externalAccountCache` not cleared on sign-out**
`externalAccountCache` is a module-level `Map` that lives for the lifetime of the browser tab. `clearSession()` (called on reset and sign-out) resets session and account state, but this cache — and `platformUsdAccountId` in `gridFunding.ts` — are never invalidated. For the current single-customer sandbox this is benign, but if a second user signs in on the same tab, `ensureExternalAccount` will return a stale ExternalAccount ID from the previous session without hitting the network.
### Issue 4 of 4
components/grid-wallet-prod/src/lib/webhookEvents.ts:10-11
**In-process ring buffer won't survive multi-instance deployments**
`events` is module-level state in a Node.js route handler. With a single long-lived server process this works, but any deployment running multiple Next.js server instances (horizontal scaling, lambda rotation) will give each instance its own ring buffer — webhooks received by instance A are invisible to a browser polling instance B. Acceptable for the sandbox demo, worth flagging before this is reused under production load.
Reviews (1): Last reviewed commit: "fix(grid-wallet-prod): panel shows only ..." | Re-trigger Greptile
| export async function GET() { | ||
| return NextResponse.json({ events: listEvents() }); | ||
| } |
There was a problem hiding this comment.
Unauthenticated event-listing endpoint
GET /api/webhooks/events returns the full ring buffer of received webhook events with no auth check. Every event payload contains real Grid API data — transaction IDs, event types, timestamps, and the raw data field. Any unauthenticated caller who discovers this URL can poll it indefinitely and read the complete event history for all transactions processed by this server instance. The PR description calls this out as a pre-prod follow-up, but the endpoint is live in this diff. A minimal fix before this goes near production keys is a Bearer or session-cookie check, or at minimum returning an empty array when no valid session header is present.
Prompt To Fix With AI
This is a comment left during a code review.
Path: components/grid-wallet-prod/src/app/api/webhooks/events/route.ts
Line: 7-9
Comment:
**Unauthenticated event-listing endpoint**
`GET /api/webhooks/events` returns the full ring buffer of received webhook events with no auth check. Every event payload contains real Grid API data — transaction IDs, event types, timestamps, and the raw `data` field. Any unauthenticated caller who discovers this URL can poll it indefinitely and read the complete event history for all transactions processed by this server instance. The PR description calls this out as a pre-prod follow-up, but the endpoint is live in this diff. A minimal fix before this goes near production keys is a `Bearer` or session-cookie check, or at minimum returning an empty array when no valid session header is present.
How can I resolve this? If you propose a fix, please make it concise.| export const dynamic = 'force-dynamic'; | ||
|
|
||
| const BASE = process.env.GRID_API_BASE_URL ?? 'https://api.lightspark.com/grid/2025-10-13'; | ||
| const CUSTOMER_ID = process.env.GRID_CUSTOMER_ID ?? ''; |
There was a problem hiding this comment.
Empty
CUSTOMER_ID produces malformed Grid paths
GRID_CUSTOMER_ID ?? '' defaults to an empty string when the env var is not set. substituteCustomerId then replaces every {customerId} token with '', producing paths like /customers//internal-accounts. The Grid API will return an error for these, but the failure will surface as a confusing upstream 404/400 with no hint that proxy misconfiguration is the root cause.
Prompt To Fix With AI
This is a comment left during a code review.
Path: components/grid-wallet-prod/src/app/api/grid/[...path]/route.ts
Line: 12
Comment:
**Empty `CUSTOMER_ID` produces malformed Grid paths**
`GRID_CUSTOMER_ID ?? ''` defaults to an empty string when the env var is not set. `substituteCustomerId` then replaces every `{customerId}` token with `''`, producing paths like `/customers//internal-accounts`. The Grid API will return an error for these, but the failure will surface as a confusing upstream 404/400 with no hint that proxy misconfiguration is the root cause.
How can I resolve this? If you propose a fix, please make it concise.| export function destSignature(input: ExternalAccountInput): string { | ||
| return input.kind === 'crypto' | ||
| ? `crypto:${input.network}:${input.currency}:${input.address}` | ||
| : `bank:${input.currency}:${input.bankName}:${JSON.stringify(input.fields)}`; |
There was a problem hiding this comment.
externalAccountCache not cleared on sign-out
externalAccountCache is a module-level Map that lives for the lifetime of the browser tab. clearSession() (called on reset and sign-out) resets session and account state, but this cache — and platformUsdAccountId in gridFunding.ts — are never invalidated. For the current single-customer sandbox this is benign, but if a second user signs in on the same tab, ensureExternalAccount will return a stale ExternalAccount ID from the previous session without hitting the network.
Prompt To Fix With AI
This is a comment left during a code review.
Path: components/grid-wallet-prod/src/lib/gridTransfer.ts
Line: 25
Comment:
**`externalAccountCache` not cleared on sign-out**
`externalAccountCache` is a module-level `Map` that lives for the lifetime of the browser tab. `clearSession()` (called on reset and sign-out) resets session and account state, but this cache — and `platformUsdAccountId` in `gridFunding.ts` — are never invalidated. For the current single-customer sandbox this is benign, but if a second user signs in on the same tab, `ensureExternalAccount` will return a stale ExternalAccount ID from the previous session without hitting the network.
How can I resolve this? If you propose a fix, please make it concise.| let events: WebhookEvent[] = []; | ||
|
|
There was a problem hiding this comment.
In-process ring buffer won't survive multi-instance deployments
events is module-level state in a Node.js route handler. With a single long-lived server process this works, but any deployment running multiple Next.js server instances (horizontal scaling, lambda rotation) will give each instance its own ring buffer — webhooks received by instance A are invisible to a browser polling instance B. Acceptable for the sandbox demo, worth flagging before this is reused under production load.
Prompt To Fix With AI
This is a comment left during a code review.
Path: components/grid-wallet-prod/src/lib/webhookEvents.ts
Line: 10-11
Comment:
**In-process ring buffer won't survive multi-instance deployments**
`events` is module-level state in a Node.js route handler. With a single long-lived server process this works, but any deployment running multiple Next.js server instances (horizontal scaling, lambda rotation) will give each instance its own ring buffer — webhooks received by instance A are invisible to a browser polling instance B. Acceptable for the sandbox demo, worth flagging before this is reused under production load.
How can I resolve this? If you propose a fix, please make it concise.…webhook-driven arrivals
Sign-in now starts on the credential a Global Account is actually born with, and
the funding flows use the endpoints Grid actually supports:
- EMAIL_OTP first (entry step prefilled from the credential's own nickname);
a passkey is added later from the wallet as its own signed action — one device
ceremony, authorized by the live session, no immediate re-authentication
- the sign-in CTA advertises the credential signIn will really use: passkeys are
device-bound, so a server-listed passkey from another device no longer sends
this browser into an assertion it can't satisfy
- Activity shows the account's real GET /transactions history (EXPIRED quotes
filtered out); saved banks are seeded from GET /customers/external-accounts,
and picking one quotes against that ExternalAccount instead of re-creating it
- Add money: a country's real deposit instructions (account/routing/rails/
reference from the customer's own fiat account; placeholder EUR IBAN until an
EUR account exists) OR "add an account" to pull from. A pull quote sources the
ExternalAccount — Grid rejects /execute for it ("funds must be pushed"), so the
quote is created and left pending; POST /sandbox/send stands in for the push
- Add from crypto: Base/Solana/Ethereum, network -> amount -> the real
Grid-provisioned deposit address for that chain
- webhooks: X-Grid-Signature parsing accepts Grid's real {"v","s"} shape and a
bare base64 header (the old {version,signature} assumption 401'd every real
delivery); verified deliveries stream to the panel over SSE, and an arrival —
not a tap — moves the balance, inserts the row and raises the toast
- bank picker limited to the US + euro area; balance hero shows available with
the account total beneath; APY 3%
- API panel shows only real traffic: every synthesized call and response body is
gone, including the fictional your-app.com webhook entry
- Dockerfile + .dockerignore (standalone output), and NEXT_PUBLIC_GRID_SANDBOX
gating the sandbox-only "simulate funding" affordance
- scripts/ensure-business-customer.mjs: idempotent BUSINESS customer provisioning
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pjqydq34z8DBK1kBrioXK2
3179b16
into
07-21-feat_grid-wallet-prod_fork_grid-wallet-demo_as_the_production_demo_app
The Dockerfile added in #740 has never produced an image. `npm ci` dies in the deps stage: the seven @central-icons-react packages each run a postinstall license-check.js that hard-fails unless CENTRAL_LICENSE_KEY is in the environment, and that variable appears nowhere in this repo. npm error path /app/node_modules/@central-icons-react/round-filled-radius-2-stroke-2 npm error command sh -c node ./license-check.js npm error Error: Central Icons license key is not set. vercel.json for this same component already sets `installCommand: npm install --ignore-scripts`, which is why the Vercel deployments work and the container build never did. This aligns the Dockerfile with the install behavior the component is actually developed and shipped against. No dependency here needs a postinstall to produce a working build. Verified linux/arm64 end to end: image builds (332MB), /app/server.js is the entrypoint, NEXT_PUBLIC_GRID_SANDBOX inlines into the client bundle, the server boots and serves / with 200 carrying no Grid credentials, and an unsigned POST /api/webhooks is rejected 401 by the signature check. Co-Authored-By: Claude Opus 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>
…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>
…#746) ## The Dockerfile added in #740 has never produced an image `npm ci` dies in the deps stage. The seven `@central-icons-react` packages each run a postinstall `license-check.js` that hard-fails unless `CENTRAL_LICENSE_KEY` is set, and that variable appears nowhere in this repo: ``` npm error path /app/node_modules/@central-icons-react/round-filled-radius-2-stroke-2 npm error command sh -c node ./license-check.js npm error Error: Central Icons license key is not set. Please set CENTRAL_LICENSE_KEY in your environment ``` #740 noted the build as *unverified* ("the daemon wasn't running here"). It's not merely unverified — it cannot succeed as written. ## Why `--ignore-scripts` is the right fix here `components/grid-wallet-prod/vercel.json` already sets: ```json { "installCommand": "npm install --ignore-scripts" } ``` That's why the Vercel deployments of this component work and the container build never did. This aligns the Dockerfile with the install behavior the component is actually developed and shipped against. No dependency here needs a postinstall to produce a working build. ## Verified end to end (linux/arm64) - image builds, 332MB - `/app/server.js` is the entrypoint (flat, not nested — this component has its own lockfile and the repo root has no `workspaces`) - `NEXT_PUBLIC_GRID_SANDBOX` inlines into the client bundle - the server boots and serves `/` with **200** carrying no Grid credentials - an unsigned `POST /api/webhooks` is rejected **401** by the signature check ## Scope Dockerfile only. Splitting this out from the CI-workflow PR that stacks on top of it, since this is a bug fix you want whether or not the tooling-cluster deployment ever happens. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: peng <peng@lightspark.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

Summary
Wires
components/grid-wallet-prodfrom 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-signedGrid-Wallet-Signatureexecute → polled toCOMPLETEDin ~62s), with the displayed balance tracking the real account throughout.What's in here
/api/grid/[...path]): allow-listed paths only, Basic-auth injected server-side,{request,response}envelopes with redacted auth — the panel logs real traffic.gridCrypto.ts): TEK keygen, HPKE encrypt/decrypt, Turnkey stamps via@turnkey/*— session keys never leave the client; cross-checked againstscripts/embedded-wallet-sign.js.gridSession.ts): first-run EMAIL_OTP bootstrap (magic000000in sandbox) → registers a real GridPASSKEYcredential via WebAuthn → returning sign-ins are pure passkey (challenge →.get()→ verify)./api/webhooks):X-Grid-SignatureP-256 verification againstGRID_WEBHOOK_PUBKEY, event ring buffer + poll route.COMPLETED.Env contract
GRID_CLIENT_ID/SECRET,GRID_API_BASE_URL,GRID_CUSTOMER_ID,GRID_WEBHOOK_PUBKEYin.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— route removed; deliveries are pushed over SSE instead of polled.GET /api/webhooks/eventsWire real activity rows onto the phone— done below.POST /quotesreturns 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
EMAIL_OTP), with the entry step prefilled from the credential's ownnickname— no invented address.signInwill 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.Funding uses the endpoints Grid actually supports
GET /customers/internal-accounts. (The EUR block is a clearly-marked placeholder IBAN indata/placeholderDeposit.tsuntil the customer has an EUR account — the read already returns one section per fiat account, so a real one appears with no code change.)/executefor such a quote ("funds must be pushed … use POST /sandbox/send"), so the quote is created and left pending, andPOST /sandbox/sendstands in for the push. Amounts here are USD cents, not USDB micro-units.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 feat(grid-wallet-prod): live Grid API — real passkey auth, funding, and signed cash-out (Phase 2) #740's original follow-ups.Webhooks
X-Grid-Signatureparsing 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./api/webhooks/stream); nothing polls. The bus is pinned toglobalThisbecause Next gives each route handler its own module instance — the publisher and subscriber were otherwise in different registries.INCOMING_PAYMENT.COMPLETEDdrives 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
POST https://your-app.com/webhooks/gridand ~330 lines of stubbed quote/transaction/session responses). Flows with no client call behind them log nothing.GET /transactionshistory (EXPIRED quotes filtered out); the balance hero shows available with the account total beneath, since sandbox book balance runs ahead of spendable.Dockerfile+.dockerignoreonoutput: 'standalone'.NEXT_PUBLIC_GRID_SANDBOXis 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: idempotentBUSINESScustomer provisioning, writesGRID_BUSINESS_CUSTOMER_IDto.env.local.60 vitest tests (was 38).
Still open
INCOMING_PAYMENT.PENDINGandOUTGOING_PAYMENT.*are logged but don't touch the wallet./sandbox/send).REALTIME_FUNDING+cryptoNetwork: SPARK, rate 1.0, verified) but isn't offered in the UI — both current paths convert.docker buildunverified: 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