Skip to content

feat(pos-app): set up merchant ID and API key via dashboard QR code - #626

Merged
ignaciosantise merged 12 commits into
mainfrom
feat/qr-scan-api-key
Sep 29, 2026
Merged

ignaciosantise merged 12 commits into
mainfrom
feat/qr-scan-api-key

Conversation

@ignaciosantise

@ignaciosantise ignaciosantise commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator

What

Set up a terminal by scanning the QR code from the merchant dashboard instead of typing the merchant ID and Customer API key by hand.

The dashboard's API key dialog (WalletConnect/pay-merchant-experience#1113) shows a QR encoding:

wpay://setup?apiKey=<customer api key>&merchantId=<merchant id>

It reaches the app two ways, and both end in the same PIN/biometric-gated save of both values:

  1. In-app scanner: Settings → Connection → Scan credentials QR.
  2. Device camera: the system camera opens the wpay://setup deep link.

How

  • utils/parse-setup-qr.ts parses the link. It requires both values and reads the query with URLSearchParams (React Native's implementation throws on malformed percent-encoding, which is treated as an invalid link). Suffixed wpay-* schemes are still accepted, so older QR links scan too.
  • app/scan-setup-qr.tsx is the full-screen expo-camera scanner, bound to useIsFocused, with permission and denied states. A non-setup QR shows an error once and the scanner keeps going.
  • app/setup.tsx is the deep link target. It renders nothing: it hands the payload over and uses dismissTo("/settings"), so the key doesn't stay in the route params. _layout.tsx sets initialRouteName: "index" so a cold-start deep link still has home underneath.
  • store/usePendingSetupStore.ts is a transient hand-off. settings.tsx consumes it and calls handleScannedSetup in hooks/use-merchant-flow.ts. The pending save is now a typed union, with a new setup case that saves the API key first (the async step that can fail), then the merchant ID. Toast: "Credentials saved".
  • Guards: scans and links are rejected in Test Mode and in the dashboard iframe session, the same states that disable the manual rows. The Scan credentials QR row isn't rendered in the iframe session.
  • Sentry scrubbing (utils/sentry.ts): on web, history breadcrumbs record full URLs, so /setup?apiKey=… would have put the key into the next error's breadcrumbs. filterBreadcrumb strips query strings and fragments from navigation breadcrumbs (still the only ones kept). filterTransaction deletes event.request (transactions skip beforeSend), and it and filterSpan strip queries from url.full / http.url / route.url in trace and span data. Native wasn't affected: its navigation breadcrumbs only carry route names, and params and URLs are only attached with sendDefaultPii, which is off. Sentry's default server-side scrubbing would likely also redact these values (its password rule matches apikey inside values), but that depends on a project setting, and Sentry recommends scrubbing in the SDK so the key never leaves the device.
  • Settings row uses a tint-able scan.png and is hidden on camera-less hardware (hooks/use-has-camera.ts). The viewfinder overlay is plain Views (components/scan-corners.tsx), since pos-app has no react-native-svg.
  • Adds expo-camera 56.0.8 and its config plugin (camera permission). pos-app is pure CNG, so this applies at prebuild.

Notes

  • Why keep an in-app scanner as well as the deep link: many POS terminals have no Google services or no QR-capable camera app, so the deep link alone isn't reliable there. expo-camera's scanner uses the bundled standalone ML Kit, not the GMS code scanner, and it has been verified on target POS hardware. Its JS only loads with Settings (inlineRequires), so cold start is unaffected.
  • Sandbox: the QR has no environment field. Sandbox appears to be decided by the key, on the same API base URL.
  • One scheme for both builds: production and the internal Dev build both register wpay, since the dashboard always emits wpay://. With both installed, the OS decides which app opens a device-camera link. The in-app scanner works in either.

Testing

  • New jest tests: __tests__/utils/parse-setup-qr.test.ts, __tests__/hooks/use-merchant-flow.test.ts (combined save and cancel), and __tests__/utils/sentry.test.ts (breadcrumb scrubbing). Also updates a stale params_validation copy assertion in utils/payment-errors.test.ts, left over from chore(pos): design review — POS app redesign & polish #596. Full suite: 309/309.
  • Typecheck clean for the changed files.
  • Web (Playwright, fake camera fed a real QR): cold deep link → Create PIN → saved, with back to home. In-app scan of a valid QR saves; a non-setup QR is rejected in place; an incomplete link shows an error and goes home; Test Mode rejects the link; inside an iframe the scan row is hidden.
  • Sentry on web (payloads intercepted in the browser, nothing sent): after opening /setup?apiKey=sk_SECRET… and triggering an error, the key appeared in the breadcrumbs before the fix and in none of the payloads after it.
  • Device: the in-app scanner and the device-camera deep link were verified on Android.
  • Manual deep link on Android (quote the whole command so the device shell keeps the &):
    adb shell 'am start -a android.intent.action.VIEW -d "wpay://setup?apiKey=test_key_123&merchantId=test-merchant-qr"'

🤖 Generated with Claude Code

Add a camera-based QR scanner for entering the Customer API key in
Settings, so merchants don't have to type long secrets on a POS device.

- New full-screen expo-router route `app/scan-api-key.tsx` using
  expo-camera (one lib across iOS/Android/web); camera bound to
  `useIsFocused` so it releases on leave; permission + denied states
  handled.
- Viewfinder overlay (dimmed mask + square + corner brackets) built from
  plain Views via `components/scan-corners.tsx` (pos-app has no
  react-native-svg).
- Scan button (tint-able `scan.png` icon) added to the Customer API key
  sheet; hidden on camera-less hardware via `hooks/use-has-camera.ts`
  (web: CameraView.isAvailableAsync; android: device-info
  isCameraPresent; ios: assume present).
- On a successful scan, auto-save through the existing PIN/biometric
  gate: the value is handed back via a transient store
  (`store/usePendingApiKeyScanStore.ts`) and run through
  `handleScannedCustomerApiKey` in `hooks/use-merchant-flow.ts`. QR
  payload is treated as a plain key string, isolated in
  `utils/parse-api-key-qr.ts`.
- Add expo-camera dep + config plugin (camera permission).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
pos-demo Ready Ready Preview Sep 29, 2026 5:35pm UTC
2 Skipped Deployments
Project Deployment Actions Updated
react-native-appkit Ignored Ignored Preview Sep 29, 2026 5:35pm UTC
react-native-walletkit Ignored Ignored Preview Sep 29, 2026 5:35pm UTC

Request Review

@claude

claude Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

Claude finished @ignaciosantise's task in 2m 4s —— View job


Code Review in Progress

  • Gather context and read changed files
  • Analyze code for issues
  • Run license compliance subagent
  • Run breaking changes subagent
  • Run data classification subagent
  • Post final review

ignaciosantise and others added 4 commits September 29, 2026 12:17
The merchant dashboard's API key dialog now shows a QR encoding
`wpay://setup?apiKey=<key>&merchantId=<id>`. Handle it end to end:

- Parse the setup deep link in `utils/parse-setup-qr.ts` (any `wpay`
  scheme variant, hand-rolled query parsing since RN's URL polyfill lacks
  searchParams); replaces the plain-key placeholder parser.
- Save both values behind a single PIN/biometric check via a new "setup"
  pending save in `use-merchant-flow`; toast "Credentials saved".
- Move scanning to a "Scan credentials QR" row in Settings → Connection
  (removed from the API key sheet); the scanner rejects non-setup QRs
  in place and keeps scanning.
- Add `app/setup.tsx` as the deep link target for the device camera; it
  hands the payload to settings and leaves. `initialRouteName: "index"`
  keeps home under a cold-start deep link.
- Reject scans/links in Test Mode and the dashboard iframe session.
- PIN prompt names the merchant being saved (`mrch_v2jj…WJnb` via
  `formatShortId`).
- Tests for the parser, the combined save, and `formatShortId`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Pin expo-camera to 56.0.8 (was ~56.0.8) per the repo's exact-version rule.
- Camera permission now refers to the credentials QR from the merchant
  dashboard instead of the Customer API key QR.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@ignaciosantise ignaciosantise changed the title feat(pos-app): scan Customer API key via QR code feat(pos-app): set up merchant ID and API key via dashboard QR code Sep 29, 2026
@ignaciosantise
ignaciosantise marked this pull request as ready for review September 29, 2026 15:29
Copilot AI balanced review requested due to automatic review settings September 29, 2026 15:29
@ignaciosantise

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Claude finished @ignaciosantise's task in 3m 0s —— View job


Code Review

  • Gather context and read changed files
  • Analyze code for issues
  • Run license compliance subagent — ✅ no issues (expo-camera is MIT)
  • Run breaking changes subagent — ✅ no issues (purely additive)
  • Run data classification subagent — ✅ no issues
  • Post final review

🚨 PR Too Large
Files: 18 Lines: 875 Severity: HIGH Category: maintainability
Exceeds both the 15-file and 800-line thresholds. The feature is coherent but could be split:

  • PR 1: Core credential parsing + store + deep link route (parse-setup-qr.ts, store/usePendingSetupStore.ts, app/setup.tsx, utils/misc.ts + tests)
  • PR 2: In-app scanner + settings integration + camera hook (app/scan-setup-qr.tsx, hooks/use-has-camera.ts, hooks/use-merchant-flow.ts, app/settings.tsx, components/scan-corners.tsx, app/_layout.tsx)
  • PR 3: expo-camera dep + app.json plugin config + package-lock.json
Found 1 issue

Issue 1: Sentry may record API key from deep link route params

ID: setup-sentry-apikey-leak-b3f1
File: dapps/pos-app/app/setup.tsx:12
Severity: MEDIUM
Category: security

Context:

  • Pattern: useLocalSearchParams extracts apiKey and merchantId directly from the deep link URL, and Sentry.wrap() in _layout.tsx wraps the navigation tree. The Sentry React Native SDK's default navigation instrumentation records route params in breadcrumbs, so { apiKey: "sk_live_…", merchantId: "mrch_…" } for the setup route would appear in Sentry events.
  • Risk: A Customer API key (secret credential) is recorded in Sentry breadcrumbs. Sentry is a third-party error-reporting service.
  • Impact: API keys appear in crash reports that may be accessible to non-engineer staff or via Sentry's own data retention.
  • Trigger: Any cold-start or foreground deep link open of wpay://setup?apiKey=…&merchantId=….

Recommendation: Scrub the params in Sentry's beforeSend/beforeBreadcrumb for the setup route. In utils/sentry.ts (or wherever initSentry is defined):

beforeBreadcrumb(breadcrumb) {
  if (
    breadcrumb.category === 'navigation' &&
    breadcrumb.data?.to === 'setup'
  ) {
    if (breadcrumb.data.params) {
      breadcrumb.data.params = { apiKey: '[Filtered]', merchantId: '[Filtered]' };
    }
  }
  return breadcrumb;
},

Alternatively, the setup route can strip the params from the URL before Sentry captures the navigation event by immediately calling router.setParams({}) at the top of the effect (before the async navigation).

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

Permission denial can trigger repeated requests, and first-time PIN setup omits the promised merchant confirmation.

Review effort: Balanced
Findings: 1 Medium severity · 1 Low severity

Open (2)
What changed in this PR

Adds QR-based terminal credential setup through in-app scanning and deep links, protected by existing PIN/biometric flows.

Changes:

  • Adds QR parsing, camera scanning, deep-link routing, and credential handoff.
  • Saves merchant ID and API key together after authentication.
  • Adds camera configuration, UI, documentation, and tests.
File Description
dapps/​pos-app/​utils/​parse-setup-qr.ts Parses setup links.
dapps/​pos-app/​utils/​misc.ts Adds shortened ID formatting.
dapps/​pos-app/​utils/​misc.test.ts Tests ID formatting.
dapps/​pos-app/​store/​usePendingSetupStore.ts Holds transient setup payloads.
dapps/​pos-app/​package.json Adds Expo Camera.
dapps/​pos-app/​package-lock.json Locks camera dependencies.
dapps/​pos-app/​hooks/​use-merchant-flow.ts Adds combined credential saving.
dapps/​pos-app/​hooks/​use-has-camera.ts Detects camera availability.
dapps/​pos-app/​components/​scan-corners.tsx Adds scanner framing UI.
dapps/​pos-app/​app/​setup.tsx Handles setup deep links.
dapps/​pos-app/​app/​settings.tsx Consumes setup payloads and exposes scanning.
dapps/​pos-app/​app/​scan-setup-qr.tsx Implements camera scanning.
dapps/​pos-app/​app/​_layout.tsx Registers setup routes.
dapps/​pos-app/​app.json Configures camera permission.
dapps/​pos-app/​AGENTS.md Documents QR setup architecture.
dapps/​pos-app/​__tests__/​utils/​parse-setup-qr.test.ts Tests QR parsing.
dapps/​pos-app/​__tests__/​hooks/​use-merchant-flow.test.ts Tests protected combined saves.
Files not reviewed (1)
  • dapps/pos-app/package-lock.json: Generated file

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread dapps/pos-app/app/scan-setup-qr.tsx Outdated
Comment thread dapps/pos-app/app/settings.tsx
ignaciosantise and others added 2 commits September 29, 2026 12:39
Show "Enter your PIN to save these settings." for every protected save,
including a scanned credentials QR, and drop the now-unused
`formatShortId` helper and `pendingSetupMerchantId`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On web, history breadcrumbs record full URLs, so opening
`/setup?apiKey=…` sent the Customer API key to Sentry in the next error's
breadcrumbs. Native breadcrumbs only carry route names and were not
affected.

- Strip query strings and fragments from navigation breadcrumbs
  (`filterBreadcrumb`), which are still the only ones kept.
- Delete `event.request` in `beforeSendTransaction` too, since
  transactions skip `beforeSend`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@ignaciosantise

Copy link
Copy Markdown
Collaborator Author

Thanks for the review.

Issue 1: Sentry may record the API key. Valid on web, but not through the path described. Fixed in c129010.

  • Native: not affected. The React Navigation instrumentation's breadcrumbs carry only route names ({ from, to }), not params. Params and URLs are only added to spans with sendDefaultPii, which is false here. The deep-link URL it records goes through sanitizeDeepLinkUrl, which strips the query.
  • Web: affected. The browser SDK's history breadcrumbs (category navigation, which our beforeBreadcrumb keeps) record the full relative URL, including the query string. Reproduced by opening /setup?apiKey=sk_SECRET…, triggering an error, and intercepting the envelopes. The breadcrumb contained from: "/setup?apiKey=sk_SECRET…&merchantId=…".
  • Fix (utils/sentry.ts): filterBreadcrumb still keeps only navigation breadcrumbs, and now strips query strings and fragments from from/to for every route. Filtering only setup would leave any future route open to the same leak. beforeSendTransaction now also deletes event.request, since transactions skip beforeSend. After the fix, the same check sent 3 envelopes and none contained the key or apiKey=. Covered by __tests__/utils/sentry.test.ts.
  • router.setParams({}) isn't enough on its own: by the time the route's effect runs, the history breadcrumb has already been recorded.

PR size. Keeping this as one PR. The pieces only work end to end (a parser and route with no way to scan, or a scanner with no parser), and several of the listed files are mechanical. The counts also predate 66e18e2, which removed the utils/misc.ts changes.

🤖 Generated with Claude Code

The scanner re-requested whenever permission wasn't granted and
`canAskAgain` was true. expo-camera's web implementation always reports
`canAskAgain: true`, so a denial looped (~23k getUserMedia calls in 6s)
and the denied state never showed; Android re-prompted right after a
first denial.

Auto-request once, only while the permission is undetermined. After a
denial or dismissal show an explicit action: "Allow camera" when the OS
or browser can still prompt, otherwise "Open settings".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ignaciosantise and others added 2 commits September 29, 2026 14:33
- Parse the setup link query with URLSearchParams instead of a
  hand-rolled parser (RN 0.85 implements it; catch its throw on
  malformed percent-encoding).
- ScanCorners: fixed sizes for its single caller and corners built from
  a list (100 → 60 lines); drop the stale "input icon" comment.
- Drop parser tests that only exercised Node's URLSearchParams
  (malformed encoding, repeated params).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
#596 changed the params_validation subtitle to "…Try the payment again…"
but left this assertion expecting the old copy.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@ignaciosantise
ignaciosantise merged commit 24f012b into main Sep 29, 2026
11 checks passed
@ignaciosantise
ignaciosantise deleted the feat/qr-scan-api-key branch September 29, 2026 18:37

This branch was successfully deployed

2 active (1 outdated) deployments
Preview – pos-demo — 33f1287c Deployed Sep 29, 2026 by vercel[bot]
Preview – react-native-walletkit — cb4bb2ee Deployed Sep 22, 2026 by vercel[bot]
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.

2 participants