Imported from blinkbitcoin/kyc (
AGENTS.md). Install upstream withnpx skills add blinkbitcoin/kyc. Copyright stays with the author.
AGENTS.md
Instructions for AI agents working with this codebase.
Project Overview
Identity verification (KYC) integration monorepo (npm workspaces): a backend GraphQL service, a platform-agnostic core package, the Node server package, publishable React Native and React web libraries, and one demo app per platform for manual and E2E testing. Status: v1 complete - all five packages (the service included), both demos, the full E2E suite and the documentation set are implemented. The design this repo followed is docs/superpowers/specs/2026-09-05-kyc-design.md; the current state is docs/index.md.
- Language: TypeScript 6.0 everywhere
- Node:
^22.22.2 || >= 24.15.0; toolchain pinned byflake.nix, entered via direnv (direnv allow . && direnv allow packages/kyc-service, once per machine) - Docs:
docs/index.mdis the current-state entry point; CLAUDE.md has the full command reference;CONTRIBUTING.mdhas the commit and release rules
Project Structure
├── packages/
│ ├── kyc-node/ # 📦 server half, the in-process tier: the verification-session domain over provider + store ports, Sumsub adapter, hosted page, Fetch handlers + the access-token preset, /express router, /knex store, the production guard (entries ., /express, /knex, /sumsub)
│ ├── kyc-core/ # 📦 platform-agnostic core: VerificationSource + guards, kyc-bridge protocol, hosted + proxy sources, Apollo factory, ErrorCode; providers/sumsub/ = the one Sumsub mapping (entry /sumsub)
│ ├── kyc-react-native/ # 📦 THE PRODUCT - RN (IdentityVerification + useIdentityVerification + hardened HostedWebView; entries ., /hosted and /sumsub = the native-SDK source in providers/sumsub/)
│ ├── kyc-react/ # 📦 THE PRODUCT - web (IdentityVerification + useIdentityVerification + origin-pinned HostedFrame; entries ., /hosted (Apollo-free, same contract as RN) and /sumsub, the reserved web-SDK seat)
│ └── kyc-service/ # 🖥️ THE SERVICE, the deployable tier: one Fetch-native app composed from packages/kyc-node - tokens always, sessions (Apollo + Knex/Postgres) with DATABASE_URL; container (Dockerfile → ghcr.io/blinkbitcoin/kyc-service), Node, Vercel or Cloudflare target; deploy/ templates ship in the tarball; runbook docs/operations/production.md
│ └── src/
│ ├── providers/ # The registry: the package adapters wired to this service's config + tracing, selected per app from its env
│ ├── app.ts # The Fetch core: capabilities → routes, security headers, CORS, session verification
│ ├── sessions.ts # The sessions capability (Apollo, hosted page, webhook), behind a loader
│ ├── config.ts # validateConfig (pure, fail-closed boot guard)
│ └── server.ts / node.ts # The Node target (rate limits, drain) / the process entry; vercel.ts, cloudflare.ts
├── examples/
│ ├── access-token-demo/ # 🖥️ in-process tier, shape 1: an existing GraphQL API adds one mutation that mints a provider access token (mode 2)
│ ├── serverless-handler-demo/ # 🖥️ in-process tier, shape 2: the access-token preset (Fetch handlers) behind a route handler, plain Node adapter
│ ├── react-native-demo/ # 📱 RN host: KYC_MODE native|hosted|proxy|fake-native, KYC_UI default|themed; Maestro suite (.maestro/)
│ └── react-demo/ # 🌐 Vite host: VITE_KYC_MODE hosted|proxy, VITE_KYC_UI default|themed; Playwright suites (e2e/; ports from KYC_PORT_BASE)
├── docs/ # Current-state documentation (hand-maintained): architecture/, integration/, diagrams/ (sources in src/*.mmd), index.md is the map
├── scripts/ # the `tooling` npm workspace: ci/, e2e/, release/ shell + node used by the Makefile and CI; lib/*.mjs is Vitest-covered at 100%, __tests__/ covers the shell scripts
├── Makefile # Root flows; packages/, examples/ and each workspace have their own
└── package.json # Workspace root (orchestration scripts, single lockfile)
Commands (repo root)
Prefer the Makefile (house convention): make help lists every target with a
one-line description. The ones you will reach for:
| Target | Description |
|---|---|
make install |
npm ci across all workspaces (also installs the git hooks) |
make test |
Unit suites + check-code (lint, typecheck, format check) |
make coverage |
Coverage - 100% enforced on the packages, backend, and scripts/lib;fails on a coverage row with nothing to cover (re-export / type-onlymodules go in the workspace's exclude list) |
make check-ci |
actionlint on the workflows + shellcheck on scripts/** |
make codeql |
GitHub's CodeQL analysis locally (same config as codeql.yml, markers honoured);never run in CI - GitHub runs it there |
make codegen |
Regenerate schema.graphql + client types after editing the SDL inpackages/kyc-node/src/graphql.ts |
make diagrams |
Re-render docs/diagrams/dist/*.svg from src/*.mmd (CI fails on drift) |
make docs-check |
Warn when architecture-relevant changes ship without a docs/ update;fail on a README table cell line wider than 72 characters (break with <br>) |
make db-up migrate backend |
Dev Postgres, migrations, backend dev server |
make e2e-backend / make e2e-web |
Backend E2E against real Postgres / Playwright browser E2E (e2e-web builds the libraries first and bundles the demo against their dist) |
make live-web / make live-ios / make live-android |
The web demo / the RN demo on the attached phone against the real Sumsubsandbox (.env + Tailscale Funnel + backend), waiting for the manual rowsof docs/integration/sumsub.md; Ctrl-C tears down |
make e2e-android-local / make e2e-ios-local |
The whole mobile stack on a laptop (DB, backend, APK or .app, Metro, Maestro, teardown); Android needs a running emulator, iOS boots a simulator. e2e-backend-up / android-build or ios-build / e2e-metro-up / e2e-android or e2e-ios are the steps |
make start / make ios / make android / make web |
Demo apps |
make release |
Merge the open release PR that release-please maintains (tags, publishes; docs/releasing.md) |
Underlying npm scripts (npm test, npm run typecheck, npm run lint,
npm run build, ...) are listed in CLAUDE.md.
Rules of the Road
The reasons behind these rules, and the check that holds each one, are in
docs/architecture/principles.md.
- Do all branch work in a git worktree (
git worktree add ../kyc-<topic> -b <branch> origin/main), never by switching branches in the main clone: several agent sessions share that checkout, and a commit made there lands on whatever branch another session left checked out - Commit messages and PR titles are Conventional Commits with an allowed
scope list (
core,server,rn,react,demo,e2e,ci,deps,deps-dev,docs,release; source of truthcommitlint.config.mjs). Squash merges take the PR title, so name the PR like a commit - Change code and the relevant doc in the same change;
docs/is hand-maintained and CI's Docs check flags architecture changes without one (apackage.jsoncounts only when the change is structural - exports, scripts, workspaces - not a dependency bump; Dependabot PRs are exempt) - Shell that CI or the Makefile runs lives in
scripts/{ci,e2e,release}/, not inline in workflows; it is shellcheck'd bymake check-ci - The provider boundary is
VerificationSource(packages/kyc-core/src/verification/types.ts) on the client side andVerificationProvider(packages/kyc-node/src/provider.ts) on the server - nothing Sumsub-specific outside aproviders/sumsub/directory:packages/kyc-core/src/providers/sumsub/(the one mapping),packages/kyc-node/src/providers/sumsub/(the adapter, its client and its hosted page),packages/kyc-react-native/src/providers/sumsub/(the native-SDK source),packages/kyc-react/src/providers/sumsub/(reserved) andpackages/kyc-service/src/providers/sumsub/(the package adapter wired to the service's config); a package'ssrc/sumsub.tsis a one-line re-export of itsproviders/sumsub/surface (guard tests), generic layers never import a provider (guard tests), and hosts select one throughproviderFromEnv(KYC_PROVIDER) - GraphQL error codes are a wire contract: the
ErrorCodeenum inpackages/kyc-service/schema.graphql(emitted from the SDL inpackages/kyc-node/src/graphql.ts, re-exported bysrc/typeDefs.ts) and the generated client types inpackages/kyc-core/src/generated/- runmake codegenafter schema changes; drift fails tests and a CI step. Client-only codes (NETWORK_ERROR,PERMISSION_DENIED,SDK_UNAVAILABLE,TOKEN_EXPIRED,TOKEN_REFRESH_FAILED,BRIDGE_PROTOCOL) live inClientErrorCodesinpackages/kyc-core/src/errors.tsand must never enter the schema enum @blinkbitcoin/kyc-corehas four entries:.(needs the Apollo peers),/hosted,/testingand/sumsub(all Apollo-free, enforced by import-graph tests and byscripts/pack-smoke.sh)- The libraries take no URLs/tokens/platform detection - host apps inject via
a
VerificationSourcefrom@blinkbitcoin/kyc-core(the Sumsub native source comes from@blinkbitcoin/kyc-react-native/sumsub); demo wiring lives inexamples/*/src/.IdentityVerificationis provider-agnostic - adding a provider is a newVerificationSource, the component never changes - The default UI renders nothing hard-coded: every string is a
IdentityVerificationLabelskey and every color aIdentityVerificationThemekey (theme/styles/labelsprops onIdentityVerification, resolved by core'sresolveLabelsWithso both platforms agree); a host that needs a different layout callsuseIdentityVerification graphqlstays on 16.x repo-wide (Apollo Server 5 peer range)- Every service listens on
KYC_PORT_BASE(default 5100 - 5000 is everybody's, 4100 is esign's) plus its offset: the backend +0, the web demo +1 (hosted) / +2 (proxy), the access-token example +3, the E2E Postgres +4. The table isscripts/lib/ports.mjs; shell reads it throughscripts/e2e/ports-env.sh($KYC_API_PORT,$KYC_WEB_PORT,$TOKEN_PORT, ...), the Playwright configs throughexamples/react-demo/e2e/ports.ts, and each service declares its own offset (ports.test.mjschecks the literal against the table). A second worktree sets one variable (KYC_PORT_BASE=5300 make e2e-web); a service's own variable (PORT,KYC_WEB_PORT,TOKEN_PORT, ...) overrides just that service. Nothing hard-codes a port outside those defaults, and nothing freezes a whole origin: derive it from the base, the way everyPORT_BASE_DEFAULT + OFFSETreader does. A frozenhttp://localhost:5100carries the right number and still ignores the worktree.ports.test.mjschecks both - the offset literals against the table, and that every port-resolving file namesKYC_PORT_BASEin code rather than only in a comment. Test runs pin the base themselves (examples/react-native-demo/jest.config.js,packages/kyc-service/vitest.setup.ts) so a developer's block never fails a suite that asserts the default; the RN demo has to do it in the jest config because babel inlines the variable at transform time - A CodeQL false positive is suppressed where it sits: a
// codeql[<rule-id>]comment alone on the line above the flagged line (.github/codeql/codeql-config.ymlruns the pack's AlertSuppression query, without which the marker is ignored). Never dismiss it in the UI/API (fingerprint-keyed: esign saw the same finding re-open three times across file moves) and never exclude the query (it stays on for real findings).make codeqlruns the same analysis locally and shows the marker as suppressed before the push - The git hooks (lefthook) run format, lint, commitlint and typecheck; CI is the authoritative gate and every workflow must be green before merge
CI
One pipeline per branch (ci.yml): Checks → Unit → E2E (incl. the one build
of the packages, which Web tests) → Badges, then Publish (ships that build) +
Verify on main. A docs-only change stops after Checks, on the PR and on
its merge alike (scripts/lib/docs-only.mjs). The iOS E2E
suite always runs (GitHub-hosted macOS is free on a public repo;
E2E_IOS_RUNNER points it at a self-hosted Mac). Native E2E builds are cached on the inputs
scripts/native-deps-hash.sh sees; bump the key's v suffix when an input it
cannot see changes. Live Sumsub checks are opt-in (E2E_LIVE=true / label e2e:live,
secrets in the sumsub-sandbox environment; docs/operations/live-e2e-ci.md):
the sandbox API only - CI never drives the Sumsub UI, the device matrix in
docs/integration/sumsub.md is manual.
Testing
- Core / Sumsub / RN / web library tests:
packages/*/src/__tests__/ - Demo tests:
examples/react-native-demo/{__tests__,src/__tests__}/,examples/react-demo/src/__tests__/; browser E2E inexamples/react-demo/e2e/(Playwright) - Backend unit tests:
packages/kyc-service/tests/(DB mocked); E2E:packages/kyc-service/tests/e2e/(real Postgres viadocker-compose.test.yml);tests/live/runs only with real Sumsub sandbox credentials (make test-live,make e2e-live) - Tooling scripts:
scripts/lib/*.test.mjs(100% Vitest coverage) for the extracted logic;scripts/__tests__/*.test.mjsshells out to the shell scripts themselves; CLI entry points are excluded from coverage by design - Mobile E2E: Maestro flows in
examples/react-native-demo/.maestro/, driven byscripts/e2e/* - Tests are silent. Every workspace's
jest.setup.ts/vitest.setup.tsfails a test that letsconsole.error,console.warnorconsole.logfire. Two things cause it, and both are bugs in the test: a module built without an injected logger (pass thesilentlogger the test file already has - every logger in this repo is injectable), and a React state update outsideact(). Anything that updates React state in a test goes through an act-wrapped API: Testing Library'sfireEvent/userEvent/waitFor/findBy*, orReactTestRenderer.act(async when the update is), never a raw DOM.click()or an awaited promise outsideact. A test that expects logging spies on the console method itself (jest.spyOn(console, 'warn').mockImplementation(() => {})) and thereby opts out for that test
Troubleshooting
- Metro cache:
npm start -- --reset-cache - Stale watchman (after moving files):
watchman watch-del . && watchman watch-project . - Clean Android build:
cd examples/react-native-demo/android && ./gradlew clean - Clean iOS build:
cd examples/react-native-demo/ios && xcodebuild clean - Reinstall deps:
make reset(root lockfile only)