Skip to content

feat(samples): add card issuing flow to the Kotlin sample - #1058

Open
pengying wants to merge 1 commit into
mainfrom
peng/kotlin-sample-card-issuing
Open

pengying wants to merge 1 commit into
mainfrom
peng/kotlin-sample-card-issuing

Conversation

@pengying

@pengying pengying commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

What

Adds a Card Issuing flow to the Kotlin sample app.

  1. Create a cardholder. Sandbox customers come back with kycStatus: APPROVED.
  2. Find the customer's USD internal account and fund it with POST /sandbox/internal-accounts/{id}/fund.
  3. Issue a virtual card with POST /cards. The request sends an Idempotency-Key.
  4. Open a card console with three tabs:
    • Transactions: simulate authorizations with preset merchants (approved, -002 insufficient funds, -003 card not active). Each row can settle, settle with a 20% tip, expire the authorization, or refund. The list polls GET /transactions?type=CARD&cardId=… until the simulated event lands.
    • Card details: POST /cards/{id}/reveal renders the processor iframe with an expiry countdown. An optional cssUrl field sends a stylesheet for that reveal. A sample stylesheet is served at /card-reveal.css. The reveal URL is never logged.
    • Controls: freeze and unfreeze, set or clear maxSpendPerTransaction, and close the card after a confirmation step.

The card renders as a styled card visual with the status, last 4 digits, and expiry. It shows a frozen overlay when frozen and turns grey when closed.

Access control

The backend holds the platform credentials. So every per-card route (GET, PATCH, reveal, transactions, simulators) returns 403 unless the card was issued from the caller's X-Session-Id. There's no unscoped card list route. Card create and update bodies aren't logged, because they can carry threeDSecurePassword. The reveal URL is never logged, and the iframe uses sandbox="allow-scripts".

Why the card and external-account routes skip the SDK

The pinned lightspark-grid-kotlin 1.7.1 has no card support. The latest release, 1.9.0, still sends cardholderId and fundingSources[] and has no reveal. Moving to 1.8.0 or 1.9.0 also breaks the customer, quote, and auth-credential routes.

So the card routes call the REST API through a small GridRest helper. It uses the same credentials and base URL as the SDK client. The existing KYC-link route already made raw calls, and now uses the same helper.

This also fixes the sample build on main. ExternalAccounts.kt referenced ArbitrumWalletInfo, PlasmaWalletInfo, bankAccountType, and the new INR fields, none of which exist in SDK 1.7.1. That route now forwards the spec-shaped body to POST /customers/external-accounts.

Testing

Ran the flow against the dev sandbox with Playwright:

  • Issue a card, reveal it, and authorize $12.50. Settle it with a tip ($15.00 SETTLED), then refund it.

  • Authorize with the -002 descriptor and get DECLINED.

  • Freeze the card, then authorize again and get DECLINED.

  • Create an MXN external account through the rewritten route: 201.

  • From a different session, reveal and PATCH a card: both return 403.

  • Render the reveal iframe with sandbox="allow-scripts" only: the PAN, expiry, and CVV still render.

  • Reveal with cssUrl pointing at the sample stylesheet on jsDelivr: the iframe loads it, and the revealed card matches the card visual.

The stylesheet field defaults to the jsDelivr copy of samples/frontend/public/card-reveal.css on main. That URL resolves once this PR merges. When the app is served over HTTPS (for example behind ngrok), the field defaults to the app's own /card-reveal.css.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

3 Skipped Deployments
Project Deployment Actions Updated
grid-cards-demo Ignored Ignored Preview Sep 23, 2026 6:57pm UTC
grid-flow-builder Ignored Ignored Preview Sep 23, 2026 6:57pm UTC
grid-wallet-demo Ignored Ignored Preview Sep 23, 2026 6:57pm UTC

Request Review

Copy link
Copy Markdown
Contributor Author

This stack of pull requests is managed by Graphite. Learn more about stacking.

@pengying
pengying marked this pull request as ready for review September 23, 2026 18:10
@greptile-apps

greptile-apps Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 1/5

The PR is not safe to merge until the card routes enforce an authorization boundary, sensitive request fields are redacted from logs, and the explicit SVG icon requirement is satisfied.

Findings

  1. P1 Security Card routes lack authorization ▶
  2. P1 Security Card passwords reach logs ▶
  3. P2 Edited retries reuse keys ▶
  4. P2 Polling stops too early ▶
  5. P2 Icons violate SVG requirement ▶
Fix with agent prompt
### Issue 1
samples/kotlin/src/main/kotlin/com/grid/sample/routes/Cards.kt:56-64
The new reveal and card-management routes accept caller-selected card IDs without authenticating the caller or checking ownership, then execute the requests using the server's Grid credentials. Because the sample is designed to be exposed through a tunnel and allows requests from any origin, an untrusted caller can enumerate cards, obtain a reveal URL containing PAN, CVV, and expiry, or freeze, close, and re-limit another card. Add an authorization boundary before exposing these operations; `X-Session-Id` only routes webhooks and is not an access-control mechanism.

**How this was verified:** The routes forward unvalidated IDs under account-level credentials, while the application installs no authentication middleware and documents public tunnel exposure.

### Issue 2
samples/kotlin/src/main/kotlin/com/grid/sample/routes/Cards.kt:46-50
The generic card create and update proxies log complete request bodies without redaction. Both request schemas permit `threeDSecurePassword`, so a client using that supported field writes the cardholder's 3-D Secure credential into application logs. Avoid logging these bodies or redact sensitive fields first.

**How this was verified:** The logging helper only truncates text, while these routes pass complete create and update bodies containing a supported password field to it.

### Issue 3
samples/frontend/src/steps/cards/IssueCard.tsx:19-21
The idempotency key is created once per component mount rather than once per request payload. If issuance fails validation and the user edits the JSON before retrying, the materially different request reuses the old key and Grid returns an idempotency conflict. This leaves the flow unable to issue the corrected card without remounting. Retain the key only for retries of an unchanged payload and rotate it when the body changes.

### Issue 4
samples/frontend/src/steps/cards/CardTransactions.tsx:68-75
Polling stops as soon as the transaction list changes, even though the added delayed-pull preset explicitly takes about 30 seconds to reach its final state. The first appearance of the new transaction therefore ends polling before its later status transition, leaving the UI stale until the user manually refreshes. Continue polling while the relevant transaction is pending or authorized.

### Issue 5
samples/frontend/src/components/CardVisual.tsx:70-74
The newly added contactless and frozen-card icons, along with the lock icon in `RevealCard.tsx`, use SVG paths without the required `vector-effect: non-scaling-stroke` wrapper. The repository directive requires that property on all icon SVG paths. Add the prescribed wrapper styling before merging.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

This PR adds an end-to-end virtual card issuing sample across the React frontend and Kotlin backend, including account funding, issuance, secure reveal, simulated card transactions, and lifecycle controls. It also introduces a direct REST helper for API shapes not modeled by the pinned SDK and migrates external-account creation to that helper.

  • Adds card issuance, visualization, reveal, transaction simulation, and controls to the frontend.
  • Adds Kotlin proxy routes for card APIs and consolidates direct REST requests in GridRest.
  • Updates CORS and sample documentation for the new flow.
  • Requires changes before merge because privileged card operations lack an authorization boundary, sensitive card credentials can be logged, and the new icons violate an explicit repository rule.
Diagram
sequenceDiagram
    participant Browser
    participant Sample as Kotlin sample server
    participant Grid as Grid REST API
    participant Processor as Card reveal iframe

    Browser->>Sample: Create customer and fund USD account
    Sample->>Grid: Authenticated customer/funding requests
    Grid-->>Sample: Customer and account
    Browser->>Sample: POST /api/cards
    Sample->>Grid: POST /cards + Idempotency-Key
    Grid-->>Browser: Virtual card
    Browser->>Sample: "POST /api/cards/{id}/reveal"
    Sample->>Grid: Mint reveal URL
    Grid-->>Browser: Short-lived panEmbedUrl
    Browser->>Processor: Load protected iframe
    Browser->>Sample: Simulate authorization/clearing/return
    Sample->>Grid: Sandbox simulation request
    Browser->>Sample: PATCH freeze, limits, or close
    Sample->>Grid: Card update
Loading

Reviews (1) · Last reviewed commit: "feat(samples): add card issuing flow to ..."

Comment on lines +56 to +64
post("/{cardId}/reveal") {
try {
val cardId = call.cardId()
// Optional body: { "cssUrl": "https://..." } styles this one reveal.
val body = call.receiveText().ifBlank { "{}" }
Log.gridRequest("cards.reveal", "$cardId $body")
val response = GridRest.request("POST", "/cards/${enc(cardId)}/reveal", body)
Log.gridResponse("cards.reveal", "status=${response.status} (URL redacted)")
call.respondText(response.body, ContentType.Application.Json, HttpStatusCode.fromValue(response.status))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 security Card routes lack authorization

The new reveal and card-management routes accept caller-selected card IDs without authenticating the caller or checking ownership, then execute the requests using the server's Grid credentials. Because the sample is designed to be exposed through a tunnel and allows requests from any origin, an untrusted caller can enumerate cards, obtain a reveal URL containing PAN, CVV, and expiry, or freeze, close, and re-limit another card. Add an authorization boundary before exposing these operations; X-Session-Id only routes webhooks and is not an access-control mechanism.

How this was verified: The routes forward unvalidated IDs under account-level credentials, while the application installs no authentication middleware and documents public tunnel exposure.

Prompt To Fix With AI
This is a comment left during a code review.
Path: samples/kotlin/src/main/kotlin/com/grid/sample/routes/Cards.kt
Line: 56-64

Comment:
**Card routes lack authorization**

The new reveal and card-management routes accept caller-selected card IDs without authenticating the caller or checking ownership, then execute the requests using the server's Grid credentials. Because the sample is designed to be exposed through a tunnel and allows requests from any origin, an untrusted caller can enumerate cards, obtain a reveal URL containing PAN, CVV, and expiry, or freeze, close, and re-limit another card. Add an authorization boundary before exposing these operations; `X-Session-Id` only routes webhooks and is not an access-control mechanism.

**How this was verified:** The routes forward unvalidated IDs under account-level credentials, while the application installs no authentication middleware and documents public tunnel exposure.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. Every per-card route (get, patch, reveal, transactions, and the sandbox simulators) now returns 403 unless the card was issued from the caller's X-Session-Id. I also removed the unscoped GET /api/cards list. Verified: reveal and PATCH from another session return 403, and the issuing session still works end to end. This is a sample, so it has no real user auth. The scoping is enough to stop other visitors to a tunneled deployment from reaching a card they didn't create.

Comment on lines +46 to +50
patch("/{cardId}") {
proxy("cards.update") {
val body = call.receiveText()
Log.incoming("PATCH", "/api/cards/${call.cardId()}", body)
GridRest.request("PATCH", "/cards/${enc(call.cardId())}", body)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 security Card passwords reach logs

The generic card create and update proxies log complete request bodies without redaction. Both request schemas permit threeDSecurePassword, so a client using that supported field writes the cardholder's 3-D Secure credential into application logs. Avoid logging these bodies or redact sensitive fields first.

How this was verified: The logging helper only truncates text, while these routes pass complete create and update bodies containing a supported password field to it.

Prompt To Fix With AI
This is a comment left during a code review.
Path: samples/kotlin/src/main/kotlin/com/grid/sample/routes/Cards.kt
Line: 46-50

Comment:
**Card passwords reach logs**

The generic card create and update proxies log complete request bodies without redaction. Both request schemas permit `threeDSecurePassword`, so a client using that supported field writes the cardholder's 3-D Secure credential into application logs. Avoid logging these bodies or redact sensitive fields first.

**How this was verified:** The logging helper only truncates text, while these routes pass complete create and update bodies containing a supported password field to it.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. The card create and update routes no longer log request bodies. The reveal route logs only the card ID and the optional cssUrl body, and never the response URL.

Comment on lines +19 to +21
// Issuance is fee-bearing and irreversible, so one key per attempt: a retry of
// the same request returns the card from the first call instead of a second card.
const idempotencyKey = useRef(randomUUID())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Edited retries reuse keys

The idempotency key is created once per component mount rather than once per request payload. If issuance fails validation and the user edits the JSON before retrying, the materially different request reuses the old key and Grid returns an idempotency conflict. This leaves the flow unable to issue the corrected card without remounting. Retain the key only for retries of an unchanged payload and rotate it when the body changes.

Prompt To Fix With AI
This is a comment left during a code review.
Path: samples/frontend/src/steps/cards/IssueCard.tsx
Line: 19-21

Comment:
**Edited retries reuse keys**

The idempotency key is created once per component mount rather than once per request payload. If issuance fails validation and the user edits the JSON before retrying, the materially different request reuses the old key and Grid returns an idempotency conflict. This leaves the flow unable to issue the corrected card without remounting. Retain the key only for retries of an unchanged payload and rotate it when the body changes.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. The key now rotates whenever the body changes. An unchanged retry reuses the key and gets the first card back.

Comment on lines +68 to +75
const pollUntilChanged = async (before: CardTransaction[]) => {
const signature = (list: CardTransaction[]) => list.map((t) => `${t.id}:${t.status}:${t.settledAmount?.amount}`).join('|')
const start = signature(before)
for (let i = 0; i < 15; i++) {
await new Promise((r) => setTimeout(r, 1000))
const next = await refresh()
if (signature(next) !== start) return
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Polling stops too early

Polling stops as soon as the transaction list changes, even though the added delayed-pull preset explicitly takes about 30 seconds to reach its final state. The first appearance of the new transaction therefore ends polling before its later status transition, leaving the UI stale until the user manually refreshes. Continue polling while the relevant transaction is pending or authorized.

Prompt To Fix With AI
This is a comment left during a code review.
Path: samples/frontend/src/steps/cards/CardTransactions.tsx
Line: 68-75

Comment:
**Polling stops too early**

Polling stops as soon as the transaction list changes, even though the added delayed-pull preset explicitly takes about 30 seconds to reach its final state. The first appearance of the new transaction therefore ends polling before its later status transition, leaving the UI stale until the user manually refreshes. Continue polling while the relevant transaction is pending or authorized.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Partly valid. I watched the -005 preset in sandbox for 45 seconds: it stays AUTHORIZED the whole time, and the spec has no pending card-transaction status, so there was no later transition to poll for. The real bug was the preset itself. Its descriptor was 23 characters, and sandbox truncates descriptors to 22 before decisioning, so the suffix got cut off. I replaced that preset with -003 (CARD_NOT_ACTIVE) and capped the descriptor input at 22 characters.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

That’s a fair correction. With no PENDING card-transaction state and -005 remaining AUTHORIZED, stopping when the new transaction first appears is not stale-state behavior here. The descriptor truncation explains why the intended delayed-pull scenario never occurred; switching to the 22-character-safe -003 preset and enforcing maxLength={22} fixes the actual issue. I’m withdrawing the polling finding.

Comment on lines +70 to +74
<svg viewBox="0 0 24 24" className="h-5 w-5 text-white/70" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
<path d="M8.5 8.5a5 5 0 0 1 0 7" />
<path d="M12 6a8.5 8.5 0 0 1 0 12" />
<path d="M15.5 3.5a12 12 0 0 1 0 17" />
</svg>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Icons violate SVG requirement

The newly added contactless and frozen-card icons, along with the lock icon in RevealCard.tsx, use SVG paths without the required vector-effect: non-scaling-stroke wrapper. The repository directive requires that property on all icon SVG paths. Add the prescribed wrapper styling before merging.

Context Used: Central icon library import conventions (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: samples/frontend/src/components/CardVisual.tsx
Line: 70-74

Comment:
**Icons violate SVG requirement**

The newly added contactless and frozen-card icons, along with the lock icon in `RevealCard.tsx`, use SVG paths without the required `vector-effect: non-scaling-stroke` wrapper. The repository directive requires that property on all icon SVG paths. Add the prescribed wrapper styling before merging.

**Context Used:** Central icon library import conventions ([source](https://github.com/lightsparkdev/grid-api/blob/main/components/grid-visualizer/.cursor/rules/central-icons.mdc))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not applying this one. That rule lives in components/grid-visualizer/.cursor/rules and covers that app's central-icons imports. The samples frontend doesn't use the central icon library. These are small inline SVGs rendered at a fixed size, so non-scaling-stroke doesn't change anything.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You're right — that rule is scoped to components/grid-visualizer/ and its central icon imports, not the samples frontend. These inline SVGs render at fixed dimensions, so vector-effect: non-scaling-stroke is not needed here. I’m withdrawing this comment and won’t flag the contactless, frozen-card, or lock icons for that requirement.

@pengying
pengying force-pushed the peng/kotlin-sample-card-issuing branch 2 times, most recently from a2f66b0 to 3973de8 Compare September 23, 2026 18:37
Adds a Card Issuing flow: create a cardholder, fund their USD internal
account, issue a virtual card, reveal it in the processor iframe (with an
optional cssUrl), simulate authorizations/clearings/returns, and freeze,
re-limit, or close the card.

The pinned SDK predates the current card and external-account shapes, so
those routes call the REST API through a small GridRest helper. This also
fixes the sample build on main, which referenced SDK types that 1.7.1
lacks.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@pengying
pengying force-pushed the peng/kotlin-sample-card-issuing branch from 3973de8 to a3670b3 Compare September 23, 2026 18:57

This branch has not been deployed

No deployments
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