Skip to content

feat(samples): add TypeScript backend using Grid TypeScript SDK#197

Open
pengying wants to merge 1 commit intomainfrom
02-12-feat_adding_typescript_sample
Open

feat(samples): add TypeScript backend using Grid TypeScript SDK#197
pengying wants to merge 1 commit intomainfrom
02-12-feat_adding_typescript_sample

Conversation

@pengying
Copy link
Contributor

@pengying pengying commented Feb 13, 2026

Add TypeScript backend sample for Grid API

Add a TypeScript (Express) backend implementation that mirrors the existing Kotlin sample with the same API contract. This implementation:

  • Uses the @lightsparkdev/grid TypeScript SDK to interact with the Grid API
  • Provides the same API endpoints for customers, external accounts, quotes, and sandbox operations
  • Streams webhook events to the frontend via Server-Sent Events (SSE)
  • Serves the shared React frontend from the /public directory
  • Includes comprehensive documentation and setup instructions

The implementation uses modern ES modules, the tsx runtime for TypeScript execution, and proper error handling throughout.

Co-Authored-By: Claude Opus 4.6 noreply@anthropic.com

Copy link
Contributor Author

pengying commented Feb 13, 2026

@pengying pengying marked this pull request as ready for review February 13, 2026 22:28
@greptile-apps
Copy link
Contributor

greptile-apps bot commented Feb 13, 2026

Greptile Summary

This PR adds a TypeScript (Express) backend sample for the Grid API that mirrors the existing Kotlin implementation, covering the full payout flow — customer creation, external account linking, quote creation/execution, and sandbox funding — with webhook events streamed to the browser via SSE. The sample is a thin proxy that delegates to the @lightsparkdev/grid TypeScript SDK and serves the shared React frontend.

Key issues found:

  • The global express.json() middleware parses the webhook body before the webhook handler runs, meaning gridClient.webhooks.unwrap() receives a re-stringified object rather than the original raw bytes. If unwrap verifies an HMAC signature against the raw payload (the standard Grid webhook security pattern), verification will always fail.
  • The SSE heartbeat writes the bare string "heartbeat" while every other event on the same stream is JSON. Clients that call JSON.parse(event.data) unconditionally will throw a SyntaxError on each heartbeat.
  • The webhook route handler has no try/catch around unwrap(), so a thrown error (e.g., invalid signature) propagates unhandled to Express's generic error handler instead of returning a structured 400/401.
  • The external account handler silently substitutes fictional placeholder PII (fullName: "Account Holder", birthDate: "1990-01-01") when no beneficiary is provided, rather than failing fast with a 400.

Confidence Score: 2/5

  • Not ready to merge — the webhook body handling bug will silently break signature verification for all inbound webhooks, and the SSE heartbeat will cause client-side JSON parse errors.
  • Two logic-level bugs affect runtime correctness: the raw-body/middleware ordering issue will defeat webhook security (or at minimum cause all webhook processing to fail), and the non-JSON heartbeat will crash any SSE client that uniformly calls JSON.parse. These need to be fixed before the sample is usable as a reference implementation.
  • samples/typescript/src/routes/webhooks.ts (body parsing + error handling) and samples/typescript/src/index.ts (SSE heartbeat format) need the most attention.

Important Files Changed

Filename Overview
samples/typescript/src/index.ts Main Express server entry point; SSE heartbeat emits bare string instead of JSON, inconsistent with all other events on the stream.
samples/typescript/src/routes/webhooks.ts Webhook handler re-stringifies an already-parsed body (due to global json() middleware), breaking raw-byte signature verification; also lacks try/catch around unwrap(), leaking exceptions to the framework error handler.
samples/typescript/src/routes/externalAccounts.ts Silently substitutes fictional placeholder PII ("Account Holder", "1990-01-01") when the beneficiary field is omitted rather than returning a 400 error.
samples/typescript/src/routes/customers.ts Straightforward customer creation route with proper try/catch and correct status codes.
samples/typescript/src/routes/quotes.ts Quote creation and execution routes; clean error handling and SDK delegation, no issues found.
samples/typescript/src/routes/sandbox.ts Sandbox send-funds route; straightforward proxy with proper error handling.
samples/typescript/src/webhookStream.ts In-memory EventEmitter with a fixed-size replay buffer; clean and correct implementation.
samples/typescript/src/gridClient.ts Single-instance Grid SDK client initialisation; straightforward and correct.
samples/typescript/src/config.ts Env-var validation with dotenv; fails fast on missing credentials, no issues found.
samples/typescript/package.json Package manifest using Express 5 and @lightsparkdev/grid ^0.8.0; lockfile pins to 0.8.0.

Sequence Diagram

sequenceDiagram
    participant Browser
    participant Express as Express Backend (:8080)
    participant GridAPI as Grid API

    Browser->>Express: POST /api/customers
    Express->>GridAPI: clients.customers.create()
    GridAPI-->>Express: Customer object
    Express-->>Browser: 201 { customer }

    Browser->>Express: POST /api/customers/:id/external-accounts
    Express->>GridAPI: clients.customers.externalAccounts.create()
    GridAPI-->>Express: ExternalAccount object
    Express-->>Browser: 201 { account }

    Browser->>Express: POST /api/quotes
    Express->>GridAPI: clients.quotes.create()
    GridAPI-->>Express: Quote object
    Express-->>Browser: 201 { quote }

    Browser->>Express: POST /api/quotes/:id/execute
    Express->>GridAPI: clients.quotes.execute()
    GridAPI-->>Express: Executed Quote
    Express-->>Browser: 200 { quote }

    Browser->>Express: POST /api/sandbox/send-funds
    Express->>GridAPI: clients.sandbox.sendFunds()
    GridAPI-->>Express: Response
    Express-->>Browser: 200 { response }

    Browser->>Express: GET /api/sse (SSE connection)
    Express-->>Browser: SSE stream (keep-alive)

    GridAPI->>Express: POST /api/webhooks (webhook event)
    Express->>Express: webhooks.unwrap(rawBody)
    Express->>Express: webhookStream.addEvent(rawBody)
    Express-->>Browser: SSE event pushed to open connections
    Express-->>GridAPI: 200 OK
Loading

Last reviewed commit: dd4347f

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

17 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

@pengying pengying changed the base branch from 02-12-feat_adding_kotlin_sample to graphite-base/197 February 18, 2026 18:49
@pengying pengying force-pushed the 02-12-feat_adding_typescript_sample branch from f0dbb39 to 62f02be Compare February 18, 2026 18:49
@graphite-app graphite-app bot changed the base branch from graphite-base/197 to main February 18, 2026 18:50
@pengying pengying force-pushed the 02-12-feat_adding_typescript_sample branch from 62f02be to bb08f69 Compare February 18, 2026 18:50
Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

17 files reviewed, 3 comments

Edit Code Review Agent Settings | Greptile

Express server mirroring the Kotlin sample with the same API contract.
Uses @lightsparkdev/grid SDK, tsx runtime, and serves the shared React frontend.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@pengying pengying force-pushed the 02-12-feat_adding_typescript_sample branch from bb08f69 to dd4347f Compare March 4, 2026 01:15
Comment on lines +8 to +17
webhookRouter.post("/", (req, res) => {
const rawBody = typeof req.body === "string" ? req.body : JSON.stringify(req.body);
log.webhook(rawBody);

const event = gridClient.webhooks.unwrap(rawBody);
log.gridResponse("webhooks.unwrap", JSON.stringify(event, null, 2));

webhookStream.addEvent(rawBody);
res.sendStatus(200);
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Webhook body re-stringification breaks signature verification

The global express.json() middleware in index.ts (line 30) parses all request bodies — including webhook payloads — before this handler runs. As a result, req.body is already a JavaScript object, so the typeof req.body === "string" check always falls through to JSON.stringify(req.body).

JSON.stringify does not reproduce the original raw bytes: it may reorder object keys, normalise number representations, or alter whitespace. If gridClient.webhooks.unwrap() performs HMAC signature verification against the raw payload (which is the standard pattern for webhook security), the derived bytes will never match the signature sent by the Grid server, causing every inbound webhook to fail verification silently.

The fix is to capture the raw body before JSON parsing for the webhook route. Use express.raw() on the specific route:

// in index.ts — register the webhook route BEFORE express.json()
app.use("/api/webhooks", express.raw({ type: "application/json" }), webhookRouter);
// Keep express.json() for all other routes
app.use(express.json());

Then in webhooks.ts, req.body will be a Buffer, and rawBody should be:

const rawBody = req.body.toString("utf-8");
Prompt To Fix With AI
This is a comment left during a code review.
Path: samples/typescript/src/routes/webhooks.ts
Line: 8-17

Comment:
**Webhook body re-stringification breaks signature verification**

The global `express.json()` middleware in `index.ts` (line 30) parses all request bodies — including webhook payloads — before this handler runs. As a result, `req.body` is already a JavaScript object, so the `typeof req.body === "string"` check always falls through to `JSON.stringify(req.body)`.

`JSON.stringify` does not reproduce the original raw bytes: it may reorder object keys, normalise number representations, or alter whitespace. If `gridClient.webhooks.unwrap()` performs HMAC signature verification against the raw payload (which is the standard pattern for webhook security), the derived bytes will never match the signature sent by the Grid server, causing every inbound webhook to fail verification silently.

The fix is to capture the raw body *before* JSON parsing for the webhook route. Use `express.raw()` on the specific route:

```typescript
// in index.ts — register the webhook route BEFORE express.json()
app.use("/api/webhooks", express.raw({ type: "application/json" }), webhookRouter);
// Keep express.json() for all other routes
app.use(express.json());
```

Then in `webhooks.ts`, `req.body` will be a `Buffer`, and `rawBody` should be:
```typescript
const rawBody = req.body.toString("utf-8");
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +71 to +73
const heartbeat = setInterval(() => {
res.write(`data: heartbeat\n\n`);
}, 30_000);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heartbeat is not JSON — will throw on the client side

Every other event written to this SSE stream is a serialised JSON string (e.g., JSON.stringify({ type: "connected", ... })). The heartbeat, however, writes the bare string "heartbeat":

data: heartbeat\n\n

Any SSE client that uniformly calls JSON.parse(event.data) will throw a SyntaxError on this message. Either format the heartbeat as JSON like the rest of the events:

Suggested change
const heartbeat = setInterval(() => {
res.write(`data: heartbeat\n\n`);
}, 30_000);
const heartbeat = setInterval(() => {
res.write(`data: ${JSON.stringify({ type: "heartbeat" })}\n\n`);
}, 30_000);

…or add a guard on the client side to skip non-JSON messages. Leaving it inconsistent is a latent runtime crash for whichever clients handle the event stream.

Prompt To Fix With AI
This is a comment left during a code review.
Path: samples/typescript/src/index.ts
Line: 71-73

Comment:
**Heartbeat is not JSON — will throw on the client side**

Every other event written to this SSE stream is a serialised JSON string (e.g., `JSON.stringify({ type: "connected", ... })`). The heartbeat, however, writes the bare string `"heartbeat"`:

```
data: heartbeat\n\n
```

Any SSE client that uniformly calls `JSON.parse(event.data)` will throw a `SyntaxError` on this message. Either format the heartbeat as JSON like the rest of the events:

```suggestion
  const heartbeat = setInterval(() => {
    res.write(`data: ${JSON.stringify({ type: "heartbeat" })}\n\n`);
  }, 30_000);
```

…or add a guard on the client side to skip non-JSON messages. Leaving it inconsistent is a latent runtime crash for whichever clients handle the event stream.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +8 to +17
webhookRouter.post("/", (req, res) => {
const rawBody = typeof req.body === "string" ? req.body : JSON.stringify(req.body);
log.webhook(rawBody);

const event = gridClient.webhooks.unwrap(rawBody);
log.gridResponse("webhooks.unwrap", JSON.stringify(event, null, 2));

webhookStream.addEvent(rawBody);
res.sendStatus(200);
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unhandled exception from unwrap() leaks to the framework error handler

The route handler is synchronous — there is no async/await and no try/catch. If gridClient.webhooks.unwrap() throws (e.g., invalid signature, malformed payload), Express 5's error handler will catch it and return a generic 500, but the response body and HTTP status will be whatever Express decides. The caller (Grid's webhook delivery system) typically retries on non-2xx responses, so returning a meaningful 400 immediately is important:

webhookRouter.post("/", (req, res) => {
  const rawBody = typeof req.body === "string" ? req.body : JSON.stringify(req.body);
  log.webhook(rawBody);

  let event;
  try {
    event = gridClient.webhooks.unwrap(rawBody);
  } catch (err) {
    log.gridError("webhooks.unwrap", err);
    res.status(400).json({ error: "Invalid webhook payload" });
    return;
  }
  log.gridResponse("webhooks.unwrap", JSON.stringify(event, null, 2));

  webhookStream.addEvent(rawBody);
  res.sendStatus(200);
});
Prompt To Fix With AI
This is a comment left during a code review.
Path: samples/typescript/src/routes/webhooks.ts
Line: 8-17

Comment:
**Unhandled exception from `unwrap()` leaks to the framework error handler**

The route handler is synchronous — there is no `async`/`await` and no `try/catch`. If `gridClient.webhooks.unwrap()` throws (e.g., invalid signature, malformed payload), Express 5's error handler will catch it and return a generic 500, but the response body and HTTP status will be whatever Express decides. The caller (Grid's webhook delivery system) typically retries on non-2xx responses, so returning a meaningful 400 immediately is important:

```typescript
webhookRouter.post("/", (req, res) => {
  const rawBody = typeof req.body === "string" ? req.body : JSON.stringify(req.body);
  log.webhook(rawBody);

  let event;
  try {
    event = gridClient.webhooks.unwrap(rawBody);
  } catch (err) {
    log.gridError("webhooks.unwrap", err);
    res.status(400).json({ error: "Invalid webhook payload" });
    return;
  }
  log.gridResponse("webhooks.unwrap", JSON.stringify(event, null, 2));

  webhookStream.addEvent(rawBody);
  res.sendStatus(200);
});
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +13 to +19
const accountInfo = body.accountInfo || {};
const beneficiary = body.beneficiary || accountInfo.beneficiary || {
beneficiaryType: "INDIVIDUAL",
fullName: "Account Holder",
nationality: "US",
birthDate: "1990-01-01",
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoded default beneficiary silently uses placeholder PII

When neither body.beneficiary nor accountInfo.beneficiary is present, the request falls back to:

{ beneficiaryType: "INDIVIDUAL", fullName: "Account Holder", nationality: "US", birthDate: "1990-01-01" }

These are obviously fictional values. A developer following the sample could accidentally send a real API request with placeholder PII, get an unexpected validation error (or worse, a successful call with garbage data) and spend time debugging the root cause. It would be safer to require the field and fail fast instead:

const beneficiary = body.beneficiary ?? accountInfo.beneficiary;
if (!beneficiary) {
  res.status(400).json({ error: "beneficiary is required" });
  return;
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: samples/typescript/src/routes/externalAccounts.ts
Line: 13-19

Comment:
**Hardcoded default beneficiary silently uses placeholder PII**

When neither `body.beneficiary` nor `accountInfo.beneficiary` is present, the request falls back to:
```ts
{ beneficiaryType: "INDIVIDUAL", fullName: "Account Holder", nationality: "US", birthDate: "1990-01-01" }
```

These are obviously fictional values. A developer following the sample could accidentally send a real API request with placeholder PII, get an unexpected validation error (or worse, a successful call with garbage data) and spend time debugging the root cause. It would be safer to require the field and fail fast instead:

```typescript
const beneficiary = body.beneficiary ?? accountInfo.beneficiary;
if (!beneficiary) {
  res.status(400).json({ error: "beneficiary is required" });
  return;
}
```

How can I resolve this? If you propose a fix, please make it concise.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant