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

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