Saifu — the holder app. The only client holding holder credentials. Feature F-030.
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. Saifu runs as an Expo
development build — not Expo Go — because holder credentials need a native
passkey module. 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.
Saifu Web is the same app from Expo's web target (see Saifu Web).
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 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
pnpm web:build # Saifu Web: static export into dist/
pnpm web:smoke # Playwright smoke test of dist/ (needs `playwright install chromium`)
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
Two kinds of link open Saifu (src/links/links.ts, T-030-10). Both are https links
under the link base, SAIFU_LINK_BASE — an https origin with no path, which
defaults to Saifu Web's origin, https://saifu.kippu.rocks (nothing is served
there yet):
| Link | Built by | Opens |
|---|---|---|
<link base>/checkout#<handoff token> |
Ichiba, for a checkout's Saifu handoff (universal link on mobile, QR code on desktop) | checkout.link |
<link base>/invitations#<token> |
Ibento, for a guest invitation | invitation.redeem |
- Tokens are kippu-api's: 43 base64url characters.
- The token is in the fragment, never the path or query. A browser does not send a fragment to the server, so opening a link on a phone without Saifu does not put a token in the link host's request logs.
- The link host vouches for the app:
applinksin itsapple-app-site-associationandhandle_all_urlsin itsassetlinks.json(pnpm well-known, which also takesSAIFU_LINK_BASE). The app declares the host as an associated domain on iOS, and as verified App Links for/checkoutand/invitationson Android. - Development builds also accept
saifu://checkout#<token>andsaifu://invitations#<token>. - On Saifu Web the link is the page's own address (
src/screens/deep-links.web.ts). Its host serves the app for/checkoutand/invitations, and Saifu takes the token out of the address bar once read.
What Saifu does with them:
- Checkout handoff. Saifu links the holder's account with
sales.checkout.linkand shows the 6-digit pairing code, with what is being bought. The buyer compares it with the checkout page and confirms there; Saifu confirms nothing. - Invitation. Saifu redeems the token with
events.invitations.redeem, waits for Kippu's copy (derived.waitFor), and opens the ticket. Unknown, used and refused invitations each get a plain explanation. - A link that arrives before Saifu is set up, or after the Kippu session ended, waits for setup and continues right after.
A ticket's pass is produced on the phone (src/passes/, T-030-07): @ticketto/profile-v0's producePass over the cached ticket id and the holder's account, signed with the passkey. No network is involved (NFR-3), so it works in airplane mode for every ticket the phone has displayed.
- QR code: the signed pass's bytes (
encodeSignedPass, about 445 bytes) as one binary-mode segment at error correction level M, drawn withreact-native-svg. That is QR version 16. - Window: the event's (T-030-17), as kippu-api's derived copy reports it with each holding (
passWindow): the organiser's setting, orNFR-5's 60 s default, never longer than the ledger's 5-minute maximum. The holdings cache keeps it, so a pass produced offline uses the last window Saifu read. With none, the window is 60 s. - Refresh: while the pass screen is open and Saifu is in the foreground, the code is replaced 10 s before its window closes, or halfway through a window of 20 s or less. Every code is a new pass, and every pass is one passkey assertion with user verification (
features/030-saifu/plan.md§5.3). With the default window, the holder therefore confirms with face, fingerprint or screen lock about once every 50 s. Dismissing a prompt stops the refresh until the holder asks for a new code. - Screenshots: a code on screen can be photographed and used once within its window (
OQ-24). The screen says so.
A holder receives a ticket by showing their receive code (src/receive/, T-030-09). It is a QR code of the text ticketto:account:<account id>: the holder's account, and nothing that can use a ticket. The sender's Saifu scans it with AccountScanner, which reads only codes with that prefix. A ticket id or an event id is also 64 hex characters, and a ticket sent to one is lost, so a bare id is never accepted as a receiver.
Transfers (src/transfer/, T-030-08) are signed with the passkey, sponsored through Kippu's relay, and submitted directly to the ledger through the SDK. Kippu's APIs are not in the path (REQ-CL-1).
- Receiver. It comes from a scanned receive code, or an account typed in. A typed id the ledger knows as a ticket or an event is refused, as is the holder's own account.
- Warning (
REQ-FR-3). A transfer to an account this Saifu has never sent a ticket to first shows a full-screen warning: no payment is involved, and it cannot be undone. Nothing is signed before the holder confirms. "Never sent to" is recorded per holder on the device. Kippu exposes no read of who sent a holder their tickets, so receiving from an account does not make it known. - After submission. Saifu waits for Kippu's copy (
derived.waitFor) and refreshes the holdings before saying the ticket is gone. The relay is given the copy's cursor from the last holdings read, so a lagging relay waits instead of refusing. - Restricted tickets (
AC-B3.3). A ticket that cannot be transferred says why, and offers no transfer.
A holder adds Saifu on a second phone of theirs (src/devices/, T-030-13). This is V0's only way to keep tickets after losing a phone (REQ-CP-6, DEF-7). The two phones exchange QR codes in person, and nothing passes through Kippu:
- On the existing phone, Settings → Add a device shows
saifu:add-device:<user handle>— the passkey's user handle,SHA-256(userId), as lower-case hex. The new phone ("I already use Saifu on another phone", at onboarding) scans it and creates its passkey with that user handle, so it names the same account; the raw user id never leaves the first phone. - The new phone shows
saifu:device-registration:<registration, base64url>and a six-digit short code: the first four bytes ofBLAKE2b-256("saifu/v0/device-short-code" ‖ registration), big-endian, modulo one million. - The existing phone scans it. Before the passkey prompt, it shows a full-screen confirmation, with the same short code to compare: the new phone gets full control of every ticket, and this cannot be undone.
- The existing phone signs
registerCredential, sponsored. The new phone never registers itself: it waits forgetCredential, then links to Kippu with its own credential.
Settings lists every device registered to the account (derived.credentials.mine, T-025-13) and marks this phone. Saifu cannot remove one, and says so.
Every screen renders inside <Screen id> (src/screens/Screen.tsx): its
screenId is the root's testID — on Saifu Web also its data-screen — and a <screenId>.settled element appears once
nothing is loading or waiting on a passkey ceremony, so a test or a screenshot
can wait for the settled screen. The ids, titles and deep-link routes are the
router's table, src/screens/registry.ts; every move between screens is a
navigate("from", "to", params) call naming both literally, and a link into
Saifu enters its screen with enter("to", params): only a screen with a route —
the link's path — can be entered. Only src/screens/deep-links.ts reads URLs.
screens.json is the screen manifest for kippu-e2e's navigation map (F-070
§5.4), in the kippu.screens/1 format Ibento uses. Saifu is one app on two
platforms, so it lists platforms: ["native", "web"] where Ibento lists one
platform: every screen, id and route holds on both. 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
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.
Holder credentials are passkeys, following Kreivo Pass accounts as
virto-network/papi-signers implements them (AD-10). papi-signers'
@virtonetwork/authenticators-webauthn runs unmodified: Saifu gives it a
navigator.credentials (src/passkey/credentials-container.ts) backed by a
native bridge (modules/saifu-passkey) — ASAuthorization on iOS, Credential
Manager on Android — so attestations and assertions have the same bytes as in a
browser. Hermes gets crypto.getRandomValues and SHA-256 crypto.subtle.digest
from src/platform/polyfills.ts; Expo's runtime provides TextDecoder.
The relying party id is configuration, not code:
SAIFU_RP_ID=<domain> pnpm prebuild
Unset, it is the holder RP id as ruled, saifu.kippu.rocks: Saifu Web's own
host, and never a parent of another Kippu origin, which could otherwise ask a
holder's passkey to sign (REQ-CL-2, REQ-CL-4). Kippu's login RP id,
kippu.rocks, is kippu-api's, not Saifu's. No passkey ceremony can succeed in
the native app until that domain vouches for it — the platforms only create
passkeys for a domain that does — by serving two documents, generated by
SAIFU_RP_ID=<domain> SAIFU_APPLE_TEAM_ID=<team> \
SAIFU_ANDROID_CERT_SHA256=<fingerprint>[,<fingerprint>] pnpm well-known
at https://<domain>/.well-known/apple-app-site-association and
https://<domain>/.well-known/assetlinks.json. No DNS record or hosting exists
for it yet, and the Apple team and the release signing certificate are not yet
decided.
Saifu Web (T-030-18; features/030-saifu/plan.md §5.1a) is this app built with
Expo's web target, to be served at https://saifu.kippu.rocks — the holder RP
id's own origin, so the browser's passkeys work with papi-signers' WebAuthn
authenticator unmodified. It is Saifu, not another client: REQ-CL-2 is about
which client holds holder credentials, not which platform runs it. Nothing is
deployed.
It is not a fork. Where a platform differs, a .web.ts module sits beside the
native one, and the app imports it without an extension, so Metro bundles the web
module for the web and the native one elsewhere. test/web/adapters.ts holds each
web module to its native module's exports.
| Concern | Native | Web |
|---|---|---|
Passkeys (src/passkey/install) |
The native bridge as navigator.credentials |
The browser's navigator.credentials, in a secure context |
Holder record (src/platform/secure-storage) |
expo-secure-store |
IndexedDB, on Saifu's origin |
Camera (src/receive/AccountScanner) |
expo-camera |
getUserMedia, frames read with jsQR in the page (QrScanner.web.tsx) |
Links (src/screens/deep-links) |
Universal links and App Links | The page's address, /checkout#<token> and /invitations#<token> |
Copy (src/copy/recovery, src/copy/onboarding) |
"this phone" | "this device", "this browser" |
Everything else — the recovery disclosure, the REQ-FR-3 warning, passes
produced with no network, the event's pass window, the copy lint and the screen
manifest — is the same code on both.
Offline (T-030-19; NFR-3, REQ-AP-5). pnpm web:build writes a service
worker into the export, dist/sw.js (tools/web/service-worker.ts), registered
by src/platform/offline.web.ts. On the first visit it caches every file of the
export, under a cache named for their contents; page loads ask the network first
and fall back to the cached app, so any path opens offline; nothing from another
origin is cached. The holder's tickets come from the app's own device cache, as
natively, and the holder record from IndexedDB, so after one online visit Saifu
Web opens and produces access passes with no network.
Holdings without a network (on both platforms, loadHoldingsProgressively
in src/holdings/load.ts). When the device reports it is offline
(navigator.onLine on the web, src/platform/network), or Kippu's copy does not
answer, the cached tickets are shown at once; the ledger keeps connecting in the
background, and once it does the cached tickets are read from it. On the web,
coming back online reads Kippu's copy again.
A host serving dist/ must answer /checkout and /invitations with
index.html, and serve sw.js without long-lived HTTP caching. Browsers may evict a site's storage (notably Safari, for a site not
added to the home screen); the holder record is then gone from that browser.
Restore (T-030-19; §5.1a). Onboarding on the web offers to sign in with an
existing passkey (restoreHolderCredential): the browser offers any
discoverable Saifu passkey it has, synced or not, and the assertion's user
handle names the account. The registration is read back from the ledger — a
passkey with none is refused — and the account is linked to Kippu again. No
passkey is created. The web disclosure then says a synced passkey signs the
holder in again, and promises no sync. Natively this is T-030-16's.
Smoke test. test/web/smoke.spec.ts drives dist/ in Chromium, desktop and
mobile, with Chromium's virtual authenticator standing in for the platform
authenticator: an Ichiba checkout link opens onboarding, the holder sets up —
one discoverable passkey bound to saifu.kippu.rocks, registered on the ledger
and linked to Kippu — and the pairing code appears; a reload resumes from
IndexedDB with no second passkey. test/web/offline.spec.ts visits once, turns
the network off, reloads, and checks the pass on screen verifies against the
credential the ledger records; then it clears the origin's storage and restores
the same account with the same passkey. The page really runs at
https://saifu.kippu.rocks: Playwright intercepts every request to that origin
and to the service endpoints and answers it locally (test/web/stack.ts) — the
export as a static host would serve it, a stand-in for the ledger service over
backend-memory speaking the part of C4 onboarding uses, a software sponsor
relay, and a stand-in for kippu-api's holder linking and checkout handoff. CI
runs it on every change; kippu-e2e drives Saifu Web against the real services
(F-070).
Saifu is the only client holding a holder's credential (REQ-CL-2).
- Provisioning (
src/holder/credential.ts). On first use Saifu draws a random 32-byte user id and creates a passkey, user verification required. The passkey's user handle isSHA-256(userId), as papi-signers creates it, and it is Saifu's durable identity for the holder: the ledger account is its Kreivo Pass derivation,BLAKE2b-256(0³² ‖ userHandle). Nothing is shown to write down. - The passkey signer signs the payload it is given — a profile signing
payload — with one biometric prompt per signature. The V0 challenger
(
src/holder/challenger.ts) prefixesticketto/v0/registrationon the registration ceremony only. - On the device (
src/holder/store.ts) Saifu keeps the user handle (and, on the phone that created the account, the user id), the passkey's credential id, the registration until the ledger accepts it, and the Kippu session — in the platform's secure storage (expo-secure-store: the Keychain, or the Keystore-backed store; IndexedDB on Saifu Web). No key: the passkey's private key never leaves the platform authenticator. - Registration (
src/holder/register.ts) isregister_credentialthrough the Ticketto SDK overbinding-offchain, sponsored through Kippu's relay (@kippurocks/sponsorship). A repeated registration is accepted unchanged, so an interrupted one is resubmitted. - Linking (
src/kippu/link.ts) answers kippu-api's proof-of-control challenge (auth.holder.beginLink/completeLink) withsignProofOfControl.
Service endpoints are configuration, with placeholders until hostnames are chosen:
| Variable | Placeholder |
|---|---|
SAIFU_LEDGER_URL |
https://ledger.kippu.example |
SAIFU_SPONSOR_URL |
https://sponsor.kippu.example |
SAIFU_KIPPU_API_URL |
https://api.kippu.example |
test/system/ runs Saifu's holder flows against the real services: the ledger
service (ticketto-offchain), the sponsor relay, and kippu-api wired to that
ledger (KIPPU_LEDGER_ENVIRONMENT=staging). kippu-api's development wiring
keeps its ledger inside its own process, where Saifu cannot write to it. The
ledger service is private, so these tests are skipped unless
SAIFU_TEST_LEDGER_URL, SAIFU_TEST_SPONSOR_URL and SAIFU_TEST_KIPPU_API_URL
are set (and SAIFU_TEST_RP_ID, default kippu.example, matches the stack's
holder RP id); kippu-e2e (F-070) runs them against its stack. Every other test
uses backend-memory and F-003's simulated authenticator.
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>).