Linky is a mobile-first PWA for contacts, Nostr messaging, and Lightning/Cashu payments. It is local-first: data is stored in Evolu (SQLite) and syncs between devices.
The repo also contains a separate public website in apps/site/ intended for linky.fit, while the product app remains a distinct deployment on app.linky.fit.
- Nostr (chat, profile, auth-related flows)
- Evolu (local-first DB + sync)
- Cashu + mints (Lightning wallet flow)
- npub.cash (LN address + mint preference sync)
- Login supports either:
nsec, or- one 20-word SLIP-39 share
- With SLIP-39 login:
- Nostr keypair is derived at
m/44'/1237'/0'/0/0 - deterministic Evolu owner lanes are derived for:
- contacts (
contacts-n) - cashu (
cashu-n) - messages (
messages-n) - owner metadata (
ownerMeta)
- contacts (
- Nostr keypair is derived at
- If user pastes custom
nsecduring a SLIP-39 session, app switches to pasted key locally without immediate Evolu restore/write; choosing Derive switches back to seed-derived key.
- Contacts/cashu/messages owner lanes auto-rotate when owner-local write delta reaches:
OWNER_ROTATION_TRIGGER_WRITE_COUNT = 1000
- Per-type rotation cooldown:
OWNER_ROTATION_COOLDOWN_MS = 60000(1 minute)
- Contacts and valid token data migrate forward; messages are pointer-rotated (no message copy).
- App reads active + previous message owner for continuity.
- Stale owners are pruned locally (
n-2) after rotation. - Contact cap:
MAX_CONTACTS_PER_OWNER = 500
- Contacts: add/edit/delete, QR scan/share, grouping
- Messages: encrypted private chat (gift-wrap/NIP-17 flows)
- Wallet: Cashu token ingest, restore, validation, spend
- Payments:
- Lightning invoice and LN address payment
- contact payment via Cashu message flow
- Push: optional Bun push service in
apps/push/for generic Web Push notifications on new outer inboxkind: 1059events - Debug pages for Evolu current/history data and owner/rotation diagnostics
Requirements: Bun; Docker for the local dev service stack
For Android native builds: Java 17
bun run dev— full local environment: startsdocker-compose.dev.yml(local Nostr relay :7777, Evolu sync relay :4001, Cashu Nutshell FakeWallet mint :3338 that auto-settles invoices with fake sats), then runs the web app (:5173) and push service (:8787) against it via the committed.env.developmentfiles. npub.cash flows are disabled locally (#219); the mint has no real Lightning backend (#220).bun run dev:prod— web app only, on :5175, against production services. The separate port keeps browser storage isolated from local-dev sessions.bun run dev:services— just the docker stack, attached.
While the dev server runs, the domain-agnostic inspector shows events on open, namespaced channels.
Current Nostr emitters use nostr.operation (linky-level linkstr operations and routed inbox facts)
and nostr.wire (raw relay traffic — publishes, subscriptions, incoming events). Rows are
correlated by shared ids in their links (gift-wrap ids, rumor ids, optimistic-update client ids),
while non-correlating location metadata such as relay urls lives in context. Development builds
collect automatically until the setting is explicitly turned off.
- Start the app (
bun run devorbun run dev:prod). - Open
http://localhost:5173/inspector.html(:5175fordev:prod) in a window next to the app — not as a route inside the app. - Use the app; rows stream in live. Selecting a row highlights every related row and the detail
pane lists them (click to jump) — e.g. one
reactions.reactoperation and the twoWirePublishedwraps it produced.
The same timeline is available in-app at #advanced/inspector. Production builds collect only
after enabling Inspector in Advanced settings. The in-app buffer can contain decrypted
message payloads in plain text, stays in browser memory only, and is cleared on reload or when the
setting is turned off. Production collection never sends inspector rows to a server.
An independent Advanced toggle can retain the same plain-text rows in on-device browser storage
for up to 24 hours (about 25 MiB) and download them as import-compatible .ndjson.
Toolbar: channel chips and a text filter narrow the timeline; Pause freezes the view while
still buffering; Clear resets the collector; the timeline auto-follows the newest row until you
scroll up (Follow ↓ jumps back). When several app tabs report, an App selector appears.
Import loads .ndjson, .jsonl, or .txt captures entirely in the browser and switches the
timeline offline; close the displayed file name to discard it and reconnect to the live feed.
Programmatic access:
# poll as JSON; cursor in the response resumes the next call
# optional filters: channel=<lowercase dotted token>, client=<per-tab app id>
curl "http://localhost:5173/__inspector/events?cursor=0&channel=nostr.wire"
# live SSE stream / reset between scenarios
curl -N "http://localhost:5173/__inspector/stream"
curl -X POST "http://localhost:5173/__inspector/clear"
# or tail the append-only file (one JSON row per line, reset on dev-server start)
tail -f apps/web-app/.inspector/rows-5173.ndjsonAndroid shell currently adds:
- encrypted native secret storage for identity data
- native QR scanning in the Capacitor shell
- native Android notification permission + FCM token bridge
Native push delivery now works end-to-end when:
apps/native-shell/android/app/google-services.jsonis present for the Android shell buildapps/pushis configured withPUSH_FIREBASE_SERVICE_ACCOUNT_JSON
bun install
bun run dev
bun run site:dev
bun run push:dev
bun run native:android:add
bun run native:apk:debug
bun run native:apk:releaseBuild:
bun run build
bun run site:buildAndroid native shell debug APK:
bun run native:android:add
bun run native:apk:debugAndroid signed release APK:
bun run native:apk:releaseLatest built debug APK ends up at:
apps/native-shell/android/app/build/outputs/apk/debug/app-debug.apkPublic download URL for the latest GitHub Release APK:
https://github.com/hynek-jina/linky/releases/latest/download/linky.apkStart the push service once:
bun run push:startUnit tests (Vitest) across all workspaces:
bun run testEnd-to-end tests (Playwright) live in apps/web-app/tests/*.spec.ts and are split into two
projects. prod-services is the original suite and runs against production relays and mints:
cd apps/web-app && bunx playwright test --project=prod-serviceslocal-stack runs the proxy-payment flow — three accounts on one machine, talking over the local
Nostr relay and paying each other with the local Cashu mint. It needs the docker stack up first,
because the app is served from it as a production build on :5176:
docker compose -f docker-compose.dev.yml --profile e2e up -d --build --wait
cd apps/web-app && bunx playwright test --project=local-stackRe-run the up --build after changing app source; the endpoints are baked into the image.
To watch or debug a run:
bunx playwright test --project=local-stack --ui # step through it
bunx playwright test --project=local-stack --headed # three live browsers
bunx playwright show-trace test-results/*local-stack/trace.zip # after the factEvery run records a trace containing all three accounts, and the console output of each app is
printed prefixed with its account label ([A], [B], [C]). The run takes ~20s, so --ui and the
trace viewer are far more useful than watching it live.
In CI, local-stack gates every release: it runs on each push to main (Vercel Deployment Checks
holds the production promotion until it passes) and as a required job in both Android release
workflows.
Always run the full check pipeline after changes:
bun run check-codeThis runs:
typecheckeslint --fixprettier --write
Workspace-scoped commands (web app only):
bun run --filter @linky/web-app typecheck
bun run --filter @linky/web-app eslint
bun run --filter @linky/web-app prettierWorkspace-scoped commands (public site only):
bun run --filter @linky/site dev
bun run --filter @linky/site build
bun run --filter @linky/site previewWorkspace-scoped commands (native shell):
bun run --filter @linky/native-shell android:sync
bun run --filter @linky/native-shell android:open
bun run --filter @linky/native-shell android:apk:debugPush service workspace commands:
bun run --filter @linky/push typecheck
bun run --filter @linky/push startPush service container artifacts live in apps/push/:
Dockerfilebuilds a production Bun imagedocker-compose.example.ymlshows a persistent SQLite/datavolume for prod-style deployment.env.production.examplelists the runtime env vars expected by that compose setup
Linky is released under the Zero-Clause BSD license (0BSD). See
LICENSE.