Project: kyc Updated: 2026-09-06
| Requirement | Version | Purpose |
|---|---|---|
| Node.js | ^22.22.2 or ≥24.15 | Runtime (floor set by jsdom 30) |
| npm | Latest | Package management |
| Docker | Latest | Test database |
| Xcode | Latest | iOS builds (macOS only) |
| Android Studio | Latest | Android builds |
| Ruby | 3.2+ | CocoaPods (iOS) |
| direnv | Latest | Env management (house convention) - brew install direnv + shell hook |
| Nix (flakes) | Latest | Toolchain pinning via flake.nix (node 24, jdk 17, ruby 3.3, watchman) - loaded by direnv's use flake |
- Sumsub sandbox credentials are only needed for the manual checklist in
integration/sumsub.md. Nothing in
npm test,make coverageor any E2E suite talks to Sumsub.
# Clone repository
git clone <repository-url>
cd kyc
# Install all workspaces (library, demo app, backend - single root lockfile)
npm ci
# Enable direnv (once per machine) - loads .env files, enters the nix
# flake dev shell (pinned node/jdk/ruby/watchman), and puts workspace
# bins (tsx, knex, biome, ...) on PATH
direnv allow . && direnv allow packages/kyc-serviceWithout direnv/nix, any Node 22.22+ or 24.15+ plus a JDK 17 and Ruby 3.2+ works - the flake is the convenient, pinned path, not a hard requirement (CI uses plain setup-node).
cd examples/react-native-demo
bundle install # Ruby deps (CocoaPods)
cd ios && bundle exec pod install # iOS native deps# Start development database
cd packages/kyc-service
docker-compose up -d
# Run migrations
npm run migrateEnvironment is managed with direnv (house convention): .envrc files load
.env/.env.local when you cd in. The backend also self-loads .env via
dotenv/config as a fallback for non-direnv environments (CI, IDE launchers) -
dotenv never overrides direnv-exported values, so precedence is consistent.
Backend (packages/kyc-service/.env, see packages/kyc-service/.env.example):
DATABASE_URL=postgresql://dev:dev@localhost:5432/kyc
KYC_PROVIDER=mock # 'sumsub' for the real integration
PORT=5100
# Required when KYC_PROVIDER=sumsub (server fails fast if missing)
# SUMSUB_APP_TOKEN=<app token>
# SUMSUB_SECRET_KEY=<secret key>
# SUMSUB_WEBHOOK_SECRET=<webhook secret key>
# SUMSUB_BASE_URL=https://api.sumsub.com
# Fail-closed in production when unset; optional in dev
# JWT_SECRET=your-jwt-secretcd packages/kyc-service
npm run dev
# Server runs at http://localhost:5100
# GraphQL Playground at http://localhost:5100/graphql# In project root
npm start# iOS
npm run ios
# Android
npm run androidA Makefile at the repo root wraps all common flows (house convention) -
run make help for the list. Highlights: make test (unit + check-code),
make e2e-backend (full DB lifecycle), make db-up migrate backend.
Git hooks (lefthook, auto-installed by npm install via the prepare
script): pre-commit formats + lints staged files (Biome, auto-fixes are
re-staged; ESLint on TS/TSX), commit-msg enforces Conventional Commits
(commitlint), pre-push runs the workspace typecheck, and post-merge /
post-checkout re-run npm ci when the lockfile changed. Skip once with
git commit --no-verify; CI remains the authoritative gate. Commit message
format, scopes, and PR conventions: CONTRIBUTING.md.
The npm scripts underneath:
| Command | Description |
|---|---|
npm test |
All suites: library + demo + backend |
npm run typecheck |
tsc across all workspaces |
npm run build |
Build the library (react-native-builder-bob) |
| Command | Description |
|---|---|
npm start |
Start Metro bundler (demo) |
npm run ios |
Run on iOS simulator |
npm run android |
Run on Android emulator |
npm test |
Run Jest tests |
npm run lint |
Run ESLint |
npm run format |
Format with Biome |
| Command | Description |
|---|---|
npm run dev |
Start dev server (tsx watch) |
npm run build |
Compile TypeScript |
npm start |
Run production build |
npm test |
Run unit tests |
npm run test:e2e |
Run E2E tests |
npm run migrate |
Run Knex migrations |
npm run lint |
Run Biome lint |
npm run format |
Format with Biome |
Unit and E2E suites are hermetic and mock-only by design. They run against
KYC_PROVIDER=mock and a real (or dockerized) Postgres, never against Sumsub.
Setting SUMSUB_* variables does not - and must not - point these suites at
the real provider: that happens only through the manual runs described in
integration/sumsub.md.
# Run all tests
npm test
# Run specific test file
npm test -- IdentityVerification.test.tsx
# Watch mode
npm test -- --watchcd packages/kyc-service
# Run all tests
npm test
# Run with coverage
npm test -- --coverage
# Watch mode
npm test -- --watchThe CI/release logic under scripts/ is its own tooling npm workspace
(npm run test -w scripts, npm run test:coverage -w scripts). Pure logic
lives in scripts/lib/*.mjs (semver parsing, version resolution, badge
rendering) and is covered by Vitest at the same 100% bar as the publishable
packages and the backend; the CLI entry points that wrap it
(scripts/release/resolve-version.mjs, scripts/coverage-badge.mjs,
scripts/status-badge.mjs) are thin argv/env/git/fs wrappers and stay
excluded from that coverage measurement by design. Shell scripts
(scripts/ci/changed-class.sh, scripts/ci/docs-freshness.sh, ...) are
exercised separately in scripts/__tests__/*.test.mjs, which shell out to
the real script under a temp git repo/fixture rather than being covered by
V8 instrumentation.
# Start test database
docker-compose -f docker-compose.test.yml up -d
# Wait for database
docker-compose -f docker-compose.test.yml exec -T postgres-test pg_isready -U test -d kyc_test
# Run migrations
npm run migrate:test
# Run E2E tests
npm run test:e2e
# Cleanup
docker-compose -f docker-compose.test.yml downnpx playwright install chromium # once per machine
make e2e-web # hosted mode - what CI runs
make e2e-web-proxy # proxy mode
KYC_PORT_BASE=5300 make e2e-web # a second worktree: every service moves with the baseBoth targets bring up the dockerized test Postgres, migrate it, and let
Playwright start the backend and Vite. Every service listens on
KYC_PORT_BASE (5100) plus its offset (backend +0, hosted demo +1, proxy
demo +2, access-token example +3, the E2E Postgres +4; the table is scripts/lib/ports.mjs,
mirrored by examples/react-demo/e2e/ports.ts and checked against it), so
a second repo or worktree sets one variable and never adopts this one's
servers - a service's own variable (KYC_API_PORT, KYC_WEB_PORT, ...)
still overrides just that service; the
backend is told its port, the public base URL to mint on and the demo
origins to allow. The app and the hosted page are genuinely cross-origin
(the Vite port vs the backend port), so the suites exercise the real
postMessage path and the origin pin rather than a same-origin shortcut.
make sumsub-env APP_TOKEN=… SECRET_KEY=… WEBHOOK_SECRET=… # writes packages/kyc-service/.env
make sumsub-check # credentials + level, in one call
make e2e-live # the whole live run against the sandbox API
make live-web # the web demo on the sandbox, waiting for the browser rows
make live-ios # the RN demo on the attached iPhone (KYC_MODE=hosted|native)
make live-android # ...or the attached Android phoneSkipped without credentials, never part of make test; in CI it is the
opt-in Live Sumsub job (docs/operations/live-e2e-ci.md). The device
matrix (docs/integration/sumsub.md, sections 3-5) is manual.
# Install Maestro once
curl -Ls "https://get.maestro.mobile.dev" | bash
# Android, in one command (an emulator must already be running):
emulator -avd <avd> &
make e2e-android-local # test DB + backend + debug APK + Metro (hosted) + Maestro, then teardown
# ...or step by step, which is what CI's jobs do:
make test-db-up && npm run migrate:test -w packages/kyc-service
make e2e-backend-up # the backend on KYC_API_PORT (CI's iOS job feeds it Homebrew Postgres instead)
make android-build # debug APK for the emulator's ABI
make e2e-metro-up # Metro in hosted mode, bundle prewarmed
make e2e-android
make e2e-metro-down && make e2e-backend-down && make test-db-down
# iOS, in one command (boots the first iPhone simulator if none is booted):
make e2e-ios-local # test DB + backend + pods if missing + .app + install + Metro + MaestroSix flows run by default (app-launch, hosted-happy-path, hosted-decline,
hosted-cancel, hosted-token-refresh, hosted-error-retry). Two more are
tagged fake-native and excluded from the default run because they need their
own Metro - which is why the CI-invoked script names never change:
# in another terminal: KYC_MODE=fake-native npm start
make e2e-fake-native # no backend neededmake e2e-ios runs the same default suite on a booted simulator with the app
installed. The Android runner does adb reverse tcp:$KYC_API_PORT tcp:$KYC_API_PORT
(scripts/e2e/android-maestro.sh), which is what makes the backend's
http://localhost:<port>/hosted/<id> load inside the emulator's WebView.
- Strict mode enabled
- Prefer interfaces over types
- Explicit return types on functions
- Functional components with hooks
StyleSheet.create()for styles- Safe area handling via
useSafeAreaInsets()
- Express 5 async error handling
- GraphQL error codes for client handling
- Audit logging for all state changes
# Check formatting
npm run format:check
# Fix formatting
npm run format# Check for issues
npm run lint
# Auto-fix issues
npm run lint:fixcd packages/kyc-service
# Create migration
npx tsx "$(command -v knex)" migrate:make -x ts <migration-name>
# Apply migrations
npm run migrate
# Roll back the last migration batch
npx tsx "$(command -v knex)" migrate:rollback
# Check migration status
npx tsx "$(command -v knex)" migrate:statusMigrations are TypeScript files, and the
knexCLI can't load.tsconfig/migration files on its own — run it throughtsx(resolving the CLI viacommand -v knex, since bins hoist to the workspace root), or use thenpm run migratescript for the commonmigrate:latestcase.
npm start -- --reset-cachecd examples/react-native-demo/ios
xcodebuild clean
rm -rf Pods Podfile.lock
bundle exec pod installcd examples/react-native-demo/android
./gradlew cleanrm -rf node_modules package-lock.json
npm installcd packages/kyc-service
npx tsx "$(command -v knex)" migrate:status
npm run migrate| Variable | Required | Description |
|---|---|---|
ALLOW_INSECURE_DEV |
no | Explicit opt-in to run without JWT/webhook secrets (never in prod) |
CORS_ALLOWED_ORIGINS |
no | Comma-separated CORS allow-list |
DATABASE_URL |
Yes | PostgreSQL connection string |
JWT_SECRET |
Prod | HS256 JWT verification secret. Unset: dev treats bearer token as userId; production treats requests as unauthenticated (fail-closed) |
KYC_PROVIDER |
No | Provider selection: mock (default) or sumsub |
MOCK_WEBHOOK_SECRET |
no | Secret the mock provider signs its own webhooks with, default mock |
NODE_ENV |
no | production activates the fail-closed auth/webhook behavior described above |
OTEL_* |
no | Standard OpenTelemetry vars; tracing is off unless set (OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_SERVICE_NAME, OTEL_TRACES_EXPORTER=console for stdout) |
PORT |
No | Server port (default: KYC_PORT_BASE + 0 = 5100) |
KYC_PORT_BASE |
No | The repo's base port (default 5100); every service is base + offset (scripts/lib/ports.mjs: backend +0, hosted demo +1, proxy demo +2, access-token example +3, the E2E Postgres +4), so one variable moves a worktree; KYC_API_PORT / KYC_WEB_PORT / KYC_WEB_PROXY_PORT / TOKEN_PORT / KYC_TEST_DB_PORT override one service |
PUBLIC_BASE_URL |
Prod | Absolute http(s) base the hosted-page url and the mock webhook target are built from; required unless ALLOW_INSECURE_DEV=true, default http://localhost:5100 in insecure dev |
SUMSUB_APP_TOKEN |
sumsub | Sumsub app token |
SUMSUB_BASE_URL |
no | Sumsub API base (defaults to https://api.sumsub.com) |
SUMSUB_LEVEL_NAME |
no | IdentityVerification level requested when the client does not send one, default basic-kyc-level |
SUMSUB_SECRET_KEY |
sumsub | Sumsub secret key |
SUMSUB_TOKEN_TTL_SECS |
no | Access-token lifetime in seconds, default 600 |
SUMSUB_WEBHOOK_SECRET |
sumsub | Webhook signature validation secret |
The sumsub column means required when KYC_PROVIDER=sumsub - the server
refuses to start without them unless ALLOW_INSECURE_DEV=true. The full
control set, and what each one is defending against, is in
architecture/security.md.
| Variable | App | Description |
|---|---|---|
KYC_MODE |
React Native demo (Metro) | native (default) / hosted / proxy / fake-native - inlined at bundle time, see examples/react-native-demo/src/config.ts |
VITE_KYC_MODE |
Web demo (Vite) | hosted (default) / proxy, see examples/react-demo/src/config.ts |
Both demos resolve the backend origin themselves (Android emulators reach the
host machine via 10.0.2.2, iOS simulators via localhost); the RN and web
IdentityVerification/useIdentityVerification packages take a session/token source from
the host app rather than owning a GraphQL client directly.
Docs are hand-maintained and live beside the code they describe. The map is index.md.
make docs-check # warns on architecture changes without a docs/ update
make diagrams # renders docs/diagrams/dist/*.svg and reassembles the page
make diagrams-check # fails if the combined page is staleDiagram sources are docs/diagrams/src/*.mmd and are canonical; the SVGs and
docs/diagrams/README.md are generated. Adding a diagram means a new .mmd,
an entry in scripts/assemble-diagrams.mjs's SECTIONS, and a row in
.claude/skills/regenerate-mermaid-diagrams/SKILL.md. Changing a source
without committing its re-rendered SVG is a hard CI failure, not a warning.
Keep labels free of bare ; - mermaid parses it as a statement separator and
mermaid-cli rejects it even where GitHub's renderer is lenient.
One workflow file per event source, named after what runs. GitHub draws one graph per run; the whole pipeline with its cross-workflow edges is one diagram: CI / Release Pipeline.
| Workflow | Trigger | Purpose |
|---|---|---|
ci.yml |
Push to main, PRs, release tag (dispatched by release.yml, or a hand-cut GitHub Release), manual |
The one pipeline every branch runs, staged so a failure never spends the next stage's minutes: Checks (calls checks.yml) → Unit (calls test.yml) → E2E (calls e2e.yml; its Build Packages job is the one build of the packages), then Badges (coverage + Unit / E2E pass-fail badges for the branch to gh-pages/badges/<branch>/, after E2E so it never delays it), and on main pushes / releases / dispatch Publish (ships the tarballs Build Packages made and Web tested to GitHub Packages, nothing is rebuilt: release → stable latest, version = the tag; main → prerelease next) + Verify (installs the published packages from GitHub Packages into a clean project and asserts the consumer contract). Workflow badge, if needed: ci.yml/badge.svg?branch=<branch> |
checks.yml |
workflow_call only |
First stage, all static: Changes (classifies the change - PR or push alike: when every changed file isdocs/, *.md, a LICENSE or a template, Unit, E2E and Badges are skipped andmain ships no prerelease; the rule is scripts/lib/docs-only.mjs), Code (make audit = osv-scanner, actionlint, diagram freshness, make check-code = lint + typecheck + format), Commits (Conventional Commits on the PR's commits and title; PRs only), Docs (warns when architecture-relevant files change without a docs/ update; fails for a diagram source without its SVG) |
test.yml |
workflow_call only |
Unit tests + coverage thresholds; uploads the coverage badge (1 day, consumed by Badges) and the combined HTML coverage report (coverage-report artifact, 30 days) |
e2e.yml |
workflow_call only |
build-packages (version stamp, build, publint + arethetypeswrong, pack smoke; uploads the dist for web and the tarballs for Publish) plus the E2E suites as jobs: backend, web (Playwright, bundles the demo against that dist - what a web consumer installs), build-android → android (emulator), and build-ios → ios (simulator) on by default (see below). Outputs the stamped version / disttag for Publish |
release.yml |
Push to main; CI completed on main | Release PR / Tag (push): keeps the chore(release): X.Y.Z PR current (version from the Conventional Commits since the last tag, CHANGELOG.md entry); when that PR merges, tags vX.Y.Z, creates the GitHub Release and dispatches ci.yml at the tag with release_tag (a release the workflow token creates never fires the release: trigger). Re-run blocked releases (CI completed green): re-runs the failed Publish of any release run for that commit (releases wait for / refuse a red main run). See releasing.md |
pull-request.yml |
PR closed; PR title edited | Cancel in-flight runs + Remove branch badge (closed): cancels the PR's still-running runs (the push-to-main run is unaffected) and removes its gh-pages badge directory. Title (edited): re-lints the PR title only; the gating lint is the Commits job in checks.yml (a title edit must not re-run the whole pipeline) |
codeql.yml |
Push to main, PRs (both skip docs-only changes via the same Changes classifier), weekly schedule |
CodeQL static analysis (JavaScript/TypeScript); alerts land under Security → Code scanning; suite + alert-suppression query in .github/codeql/codeql-config.yml;make codeql runs the same analysis locally |
Badges are per branch by construction: gh-pages/badges/X/{unit,e2e,coverage}.svg
(and a workflow badge filtered with ?branch=X) all describe branch X
and nothing else. The README shows main.
The iOS job runs on every run. It needs a macOS runner, which GitHub hosts for free on a public repo; on a private repo macOS bills at 10x Linux (one 7-12 min run is 70-120 Linux minutes), which is why the job was opt-in before the repo went public. Only the live Sumsub job is conditional; the one iOS switch is where it runs:
| Switch | Effect |
|---|---|
Repo variable E2E_IOS_RUNNER |
runs-on for the iOS job, default macos-latest. Set to self-hosted label(s), e.g. ["self-hosted","macOS","arm64"], and GitHub-hosted macOS is never used. |
What keeps the job at 7-12 min on a 3-vCPU hosted runner (it was ~15, and
Maestro's XCUITest driver install is most of the spread): the simulator boots
first and only downloads overlap it, Metro starts after the boot, Metro's
transform cache is restored from the last green run (METRO_CACHE_ROOT,
scripts/e2e/metro-start.sh), and the prewarm requests the exact bundle
options the dev client asks for, so the app-launch flow is served from the
warm graph instead of building its own. Change the order or the prewarm URL
only with the step timings of a run in hand.
All workflows run with permissions: contents: read (the publish job adds
packages: write; the Badges and closed-PR cleanup jobs get
contents: write for the ruleset-exempt gh-pages branch only), have
timeouts, and cancel superseded runs per ref (never a running main run).
checks.yml also runs actionlint over the workflow files themselves;
.github/dependabot.yml keeps the action versions current.
The workflows are thin: triggers, needs, caches and one-line run: steps.
The logic lives in scripts/ci/ (runner plumbing), scripts/e2e/ (the E2E
stack) and scripts/release/ (publish), exposed through make wherever a
human would run it - so a CI failure can be reproduced without pushing:
# The whole Checks stage (static only)
make check-code check-ci codegen-check diagrams-check docs-check
# GitHub's CodeQL analysis, locally (never in a workflow: GitHub runs it there).
# Same language, same config (suite + the alert-suppression query), so a finding
# and an inline `// codeql[<rule-id>]` marker show up here before the push;
# the CLI comes from the flake on first use (nix shell .#codeql, one large fetch)
make codeql
# Unit
make coverage
# E2E
make build && npm run check:packages && bash scripts/pack-smoke.sh # Build Packages
make e2e-backend # Backend
make e2e-web # Web (Playwright; builds the libraries, then bundles + previews the demo)
make e2e-android # Android: emulator running, APK built, Metro + backend up (see `make help`)
make e2e-ios # iOS: simulator booted with the app installed, Metro + backend up
# Release plumbing
make version # what a push to main would publish; make version TAG=vX.Y.Z for a release
make release # merge the open release PR (release-please) - the whole stable release step
make release-rc V=X.Y.Z-rc.1 # hand-cut a prerelease-suffixed tag (ships under next)The repo publishes a next prerelease from every green push to main
automatically. The first stable version still needs a human, but only for
one click: release-please proposes the version and the notes, and merging its
pull request is the release. Full walkthrough: releasing.md.
- Land the work. Either merge the phase branches in order (bootstrap →
core → api → sumsub → rn → react → demos/E2E → docs), or open one PR for
the whole v1 - the pipeline is identical either way. Every PR needs an
approving review under the org ruleset;
mainis never pushed to directly. - Wait for
mainto be green. The Publish job refuses to ship from a red main run, and a release run against one is blocked until it turns green. - Let release-please open the release PR. The first
feat:(orfix:) commit onmainmakes theReleaseworkflow openchore(release): 0.1.0-0.1.0becausebump-minor-pre-majorturns afeatinto a minor bump from the manifest's0.0.1. The PR carries the newCHANGELOG.md, the rootpackage.json/package-lock.jsonversion and.release-please-manifest.json; its body is the release notes. Nothing ships until it is merged. - Check what the release would produce, without touching anything:
DRY_RUN=1 node scripts/release/resolve-version.mjs # the prerelease CI publishes today
# -> version=0.0.1-pre.<run>.<sha> disttag=next
make version # the same thing, through make
make version TAG=v0.1.0 # what the tag would publish
# -> version=0.1.0 disttag=latestDRY_RUN=1 prints the decision and skips both npm pkg set loops, so
package.json stays at 0.0.0-development.
- Approve the release PR and merge it:
make releasewhich merges the open chore(release): 0.1.0 PR (the Merge button does the
same). release.yml then tags v0.1.0, creates the GitHub Release
from the changelog entry, and dispatches ci.yml at the tag with
release_tag=v0.1.0. The tag is the version: that run stamps 0.1.0
into all five packages before building them and pins each one's
@blinkbitcoin/kyc-core dependency to exactly 0.1.0, so the packages'
own package.json files stay at 0.0.0-development.
- Watch Publish and Verify.
Verify(scripts/release/registry-smoke.sh) installs the published packages from GitHub Packages into a clean project and asserts the consumer contract - including that/hostedand/testingload without Apollo. You can re-run it later by hand:
make registry-smoke V=0.1.0- If it fails, fix forward and merge; the next release PR bumps again.
GitHub Packages never accepts the same version twice, so
0.1.0cannot be re-published. A run that failed before Publish can simply be re-run, andrelease.yml's retry job re-runs a Publish that was blocked by a red main run.
CHANGELOG.md is generated, never hand-edited on main. release-please
writes it from the Conventional Commit PR titles, which is why the title is
linted by commitlint and is the line reviewers see; only feat / fix /
perf / revert appear there, so a CI-only fix is ci:, not fix(ci):.
Context can be added to the entry on the release PR's branch before merging.