Iriguchi — the gate app for entrance operators. Requires connectivity. Feature F-050.
This repository was reset for the V0 rebuild. The previous implementation is
preserved under the tag legacy.
Everything here is built from the Kippu specification and plan, in
kippurocks/kippu-docs: SPEC.md decides behaviour, PLAN.md and
features/ decide how it is built. Work is tracked as one issue per feature
per milestone.
React Native on Expo (SDK 57), TypeScript strict, ESM. Iriguchi runs as an Expo
development build — not Expo Go — as Saifu does, so both native clients
share one build and test harness and Iriguchi can add native modules (the
camera) without changing how it is built. The native projects are generated,
never committed: ios/ and android/ come from app.json through
expo prebuild.
No Expo or EAS account is used. Builds are local, on a developer machine or on a GitHub-hosted runner.
Iriguchi requires connectivity to validate and admit (REQ-CL-3). It never
holds a holder's key (REQ-CL-2): it submits passes it cannot forge.
Requires Node 24 and pnpm 10.17.1.
pnpm install
pnpm lint # Biome
pnpm lint:copy # no fee vocabulary or trust claims in user-visible strings
pnpm screens:check # every screen has an id; screens.json is up to date
pnpm typecheck # TypeScript
pnpm test # Vitest, on Node
pnpm deps:check # native dependencies match the Expo SDK
pnpm bundle:check # Metro bundles for iOS and Android
Development build, on a machine with Xcode or the Android SDK (JDK 17):
pnpm prebuild # generate ios/ and android/
pnpm ios # build, install on a simulator, start Metro
pnpm android # build, install on an emulator, start Metro
Every screen renders inside <Screen id> (src/screens/Screen.tsx): its
screenId is the root's testID, and a <screenId>.settled element appears once
nothing is loading, so a test or a screenshot can wait for the settled screen.
The ids and titles are the router's table, src/screens/registry.ts; every move
between screens is a navigate("from", "to", params) call naming both
literally. No link opens Iriguchi.
screens.json is the screen manifest for kippu-e2e's navigation map (F-070
§5.4), in the kippu.screens/1 format Ibento and Saifu use. It is generated and
committed:
pnpm screens:write # regenerate screens.json
pnpm screens:check # CI: fails on a screen without an id, undeclared or
# non-literal navigation, or an out-of-date screens.json
An operator signs in by redeeming the one-time enrolment code their organiser
issued in Ibento (auth.operator.redeemEnrolmentCode, F-020 §5.1, F-024).
Iriguchi keeps the session it opens in the platform's secure storage
(expo-secure-store) and holds nothing that can sign on the ledger (REQ-CL-2).
The operator then chooses one gate of one event among their grants
(operators.grants.mine), with the event's name from its public metadata
(derived.events.get), and the scanner opens for that gate. A revoked or ended
session returns the operator to sign-in. Whether the operator is authorised at
the gate right now is checked on every scan, not at selection (AC-E5.2).
Service endpoints are configuration, with placeholders until hostnames are chosen:
| Variable | Placeholder |
|---|---|
IRIGUCHI_KIPPU_API_URL |
https://api.kippu.example |
test/kippu-api-stand-in.ts serves kippu-api's operator procedures over tRPC's
HTTP wire format, with responses typed by @kippurocks/api, so a contract change fails
to compile. The unit tests drive the real tRPC client against it, and CI's smoke
flow runs it (tools/ci/kippu-api-stand-in.ts) so the development build signs in,
chooses a gate and reaches the scanner on both platforms. It is a stand-in, not
kippu-api: the system with the real services is kippu-e2e's (F-070).
The scan screen (gate.scan, T-050-03) reads QR codes with expo-camera and reads
each one as an access pass (AD-13): the pass's presented bytes,
@ticketto/profile-v0's encodeSignedPass, in one binary-mode segment — what
Saifu shows.
A binary payload does not survive the platforms' text readings of a QR code, so
expo-camera is patched (patches/expo-camera@57.0.5.patch, applied by pnpm) to
also report the symbol's decoded data codewords as rawBytes, base64:
CIQRCodeDescriptor.errorCorrectedPayload on iOS, ML Kit's Barcode.rawBytes on
Android. src/scan/payload.ts reads the byte segments back out of them, and
decodePass decides whether they are a pass. expo-camera is built from source
(expo.autolinking.buildFromSource) so the patch applies on Android too. Upgrading
expo-camera means carrying the patch forward.
Reading only decodes. Whether a pass is authentic, current and admits is the verdict's.
Simulators and emulators have no camera to point at a phone. A development bundle
started with EXPO_PUBLIC_IRIGUCHI_SCAN_FIXTURE=1 offers "Scan the test pass",
which reads test/fixtures/saifu-pass.png — a Saifu pass's QR code — through the
platform's own QR decoder (scanFromURLAsync) and the camera's reading path. CI's
smoke flow does this on Android and iOS. pnpm fixtures:write regenerates the
fixture; its inputs are fixed, so it does not change.
On a scan, three checks start together, and the verdict waits for all three
(src/verdict/verdict.ts, F-050 plan §5.1):
| Check | Where | Answers |
|---|---|---|
| Signature and window | profile-v0's verifyPass, against the credential's registration read from the ledger with getCredential |
Is the pass authentic and current? (AC-E1.2, AC-E1.3) |
getTicket and canAttend |
The ledger service, through the SDK over binding-offchain |
Is the signer the current holder, and would the ticket admit? (REQ-AP-1, REQ-Q-2) |
operators.check |
kippu-api (F-024) |
Is this operator authorised at this gate, now? (AC-E5.2) |
Every check only reads (AC-E2.2). If the ledger or Kippu does not answer, there is
no verdict and Iriguchi does not admit (REQ-CL-3); the gate's reads retry once,
briefly, rather than hold up the queue. When several checks refuse, the operator's
own authorisation is shown first, then the ledger's own order for a pass
(F-008 plan §5.2): the ticket exists and belongs to this event, the pass is the
current holder's, it is within its window, then canAttend's reason.
The ledger endpoints and the holder RP id are configuration, with placeholders:
| Variable | Placeholder |
|---|---|
IRIGUCHI_LEDGER_URL |
https://ledger.kippu.example |
IRIGUCHI_SPONSOR_URL |
https://sponsor.kippu.example |
IRIGUCHI_RP_ID |
kippu.example — must equal Saifu's SAIFU_RP_ID |
src/verdict/verdict.test.ts runs the verdict end to end in one process: the SDK
over ledger-rules (backend-memory), Saifu-kind holder passkeys (F-003's
simulated authenticator), and the real tRPC client to the kippu-api stand-in.
Against the ledger service and kippu-api themselves, the journeys are kippu-e2e's.
An admission shows at once. The pass is then submitted to the ledger in the
background, directly through the SDK and sponsored through Kippu's relay
(@kippurocks/sponsorship's createRelaySponsor), with the time it was presented at
the gate (src/admission/admissions.ts, F-050 plan §5.2). The queue never waits
on it (NFR-2); submissions keep the binding's full retry budget.
When the submission has ended, the admission is reported to kippu-api
(operators.reportAdmission) with the exact presentedAt submitted, the device's
unadjusted clock, and how the submission ended: settled with its receipt's
cursor, rejected with the ledger's code, or failed. A ledger refusal after an
admission becomes a flag to the organiser (REQ-OP-3), not a message at the gate.
Reports are retried with the same report id until kippu-api records them or
refuses them for good, for up to 24 hours. Each report keeps the session token
its admission was made under: kippu-api accepts reports of passes presented
before a session ended for 24 hours after, so signing out loses none. Only
admissions are submitted and reported; nothing is admitted without a verdict
obtained online, so nothing is queued (REQ-CL-3).
Pending reports outlive the app (T-050-11, src/admission/report-store.ts). An
admission is kept on the device before its submission is sent, and again once the
submission ends; it is forgotten only when its report is recorded, refused for good,
or given up on. The entries live in app storage (AsyncStorage); the session token
each report is sent with lives in secure storage. When the app starts, it sends every
report a previous run left, once each, relying on the idempotent report id. An
admission whose submission was still in flight when the app died is reported as
failed — the gate never learned its outcome — and is not submitted again; F-025
reconciles it against the ledger's pass records. Every report carries the pass's
holder.
Iriguchi compares its clock with Kippu's on every response that carries Kippu's
time — operators.check's checkedAt, taken when the gate opens and on every scan,
and an admission report's receivedAt — measured at the middle of the request
(src/clock/server-clock.ts, F-050 plan §5.3). Beyond 10 seconds, the tolerance
agreed with F-025, the scan screen warns the operator; the drift reaches the
organiser through each admission report, which carries the device's unadjusted
clock. Iriguchi never changes the device's clock: a pass's presentation time, and
the time its window is judged at, are server-adjusted.
Without a network connection (as the platform reports it, @react-native-community/netinfo)
the scan screen blocks: "No connection — cannot admit". It scans nothing and
queues nothing (REQ-CL-3).
test/system/ runs the gate against the real services: the ledger service
(ticketto-offchain), the sponsor relay, and kippu-api in its staging wiring over
that ledger. An organiser signs up and issues a granted ticket, a holder registers a
simulated passkey, and operator devices redeem enrolment codes — test fixtures, not
Iriguchi's — then Iriguchi's own verdict, submission and reports run as in the app:
AC-E1.2, AC-E1.3, AC-E5.2, AC-E3.1, AC-E3.3, and the two-device scenario of
AC-E3.4, which ends on the organiser's flag in derived.admissionFlags.list. The
ledger service is private, so the tests are skipped unless
IRIGUCHI_TEST_LEDGER_URL, IRIGUCHI_TEST_SPONSOR_URL and IRIGUCHI_TEST_KIPPU_API_URL
are set (IRIGUCHI_TEST_RP_ID, default kippu.example, is the stack's holder RP id);
kippu-e2e (F-070) runs them against its stack.
Flows in .maestro/ run with Maestro against an
installed development build. tools/ci/smoke.sh <android|ios> starts Metro and
runs the smoke flow; CI does this on an Android emulator (Ubuntu runner) and an
iOS simulator (macOS runner) for every pull request.
Cross-repository packages are not published. They are pnpm pack tarballs from
pinned commits, checked by pnpm vendor:check in CI:
@ticketto/sdk,profile-v0andbinding-offchain— andbackend-memory,ledger-rulesandlogfor tests — fromlibticketto(pnpm vendor:libticketto <commit>);@kippurocks/api(router types,C5) and@kippurocks/sponsorship(the relay client) fromkippu-api(pnpm vendor:kippu-api <commit>).