backend / tether

Tether API

A Bun HTTP API that records who is holding a lent item, settles hand-overs with a single-statement compare-and-swap, and pushes changes to both phones over server-sent events.

2026 · paused · local repository

mobile → router: HTTPS JSON + bearer tokenrouter → sse: GET /events (text/event-stream)router → custody: POST claim / pass-on + idempotencyKeyrouter → catalog: GET /catalog/resolvecustody → sqlite: CAS UPDATE + log INSERTcustody → sse: publishTo(ids only)settle → sqlite: settle due pending_transferssettle → sse: transfer settledcatalog → sqlite: catalog_cache read/writecatalog → openlib: HTTPS, 6s timeoutrouter → google: JWKS fetch, cached by max-ageExpo mobile appExpo mobile app (client) scans tags, holds SSE streamSettlement timerSettlement timer (worker) settles veto windows every 30sBun.serve + routerBun.serve + router (service) 37 routes, request ids, CORSCustody routesCustody routes (service) claim, return, pass-on, veto, disputesCode resolverCode resolver (service) ISBN/EAN/IMEI check digits + cacheGoogle JWKSGoogle JWKS (third party) ID-token signing keysSSE hubSSE hub (service) per-user fan-out, 64-event replaySQLite (WAL)SQLite (WAL) (store) 14 tables, append-only custody logOpen Library /UPCitemdbOpen Library / UPCitemdb (third party) product namesHTTPS JSON + bearer t…GET /events (text/eve…POST claim / pass-on …GET /catalog/resolveCAS UPDATE + log INSE…publishTo(ids only)settle due pending_tr…transfer settledcatalog_cache read/wr…HTTPS, 6s timeoutJWKS fetch, cached by… mobile → router: HTTPS JSON + bearer tokenrouter → sse: GET /events (text/event-stream)router → custody: POST claim / pass-on + idempotencyKeyrouter → catalog: GET /catalog/resolvecustody → sqlite: CAS UPDATE + log INSERTcustody → sse: publishTo(ids only)settle → sqlite: settle due pending_transferssettle → sse: transfer settledcatalog → sqlite: catalog_cache read/writecatalog → openlib: HTTPS, 6s timeoutrouter → google: JWKS fetch, cached by max-ageExpo mobile appExpo mobile app (client) scans tags, holds SSE streamSettlement timerSettlement timer (worker) settles veto windows every 30sBun.serve + routerBun.serve + router (service) 37 routes, request ids, CORSCustody routesCustody routes (service) claim, return, pass-on, veto, disputesCode resolverCode resolver (service) ISBN/EAN/IMEI check digits + cacheGoogle JWKSGoogle JWKS (third party) ID-token signing keysSSE hubSSE hub (service) per-user fan-out, 64-event replaySQLite (WAL)SQLite (WAL) (store) 14 tables, append-only custody logOpen Library /UPCitemdbOpen Library / UPCitemdb (third party) product namesHTTPS JSON + bearer t…GET /events (text/eve…POST claim / pass-on …GET /catalog/resolveCAS UPDATE + log INSE…publishTo(ids only)settle due pending_tr…transfer settledcatalog_cache read/wr…HTTPS, 6s timeoutJWKS fetch, cached by…
clientserviceworkerstorethird partyasync
  • [*]LanguagesTypeScript
  • [*]FrameworksBun.serve (hand-written router, no framework)
  • [*]StoresSQLite (bun:sqlite, WAL)
  • [*]AsyncsetInterval settlement sweep (30s) · Server-sent events with replay buffer
  • [*]InfraBun single-binary build (bun build --compile) · Local host only (no deploy config)
  • [*]Third partiesGoogle OAuth JWKS (ID-token verification) · Open Library Books API · UPCitemdb

Tether tracks physical things you lend: every item carries a printed tag, and scanning it moves custody. The API under apps/api is about 3,000 lines of TypeScript on Bun with an empty dependencies block in package.json. Routing is a hand-written 83-line router over Bun.serve, the database is bun:sqlite, password hashing is Bun.password (argon2id), and Google ID tokens are checked with crypto.subtle against Google's JWKS, so there is no npm package in the server at all.

The core race is two phones scanning the same tag. A claim is one UPDATE ... WHERE id = ? AND holder_id = ? and succeeds only if changes === 1; the loser gets a 409 naming who took it. Every custody change also appends a row to custody_events, which is never updated, and a UNIQUE idempotency key on that log turns a double-tap or network retry into a no-op rather than a second loan.

Passing an item on to a third person is not immediate. It writes a pending_transfers row with a 10-minute veto window for the owner; the transfer settles lazily on read and by a 30-second timer, so a quiet server still converges. Changes fan out over SSE with a per-user 64-event replay buffer keyed on Last-Event-ID, and the events carry ids only, never rows, so clients refetch through the normal authorised endpoints.

Eight hand-written SQL migrations, applied in a transaction each, cover 14 tables: users, devices, the custody log, sessions, trust circles, disputes, guest loans, preferences, and a barcode/ISBN/IMEI identifier layer with check-digit validation and a cached catalogue lookup. 37 routes in total. There are no automated tests in the repo; the concurrency result quoted in the README has no committed script behind it.

How it holds up

Auth
Opaque 40-character bearer session tokens stored in a sessions table with a 30-day TTL; sign-in by phone or email with argon2id password hashes and a dummy-hash verify so a missing account and a wrong password take the same time; Google sign-in verifies the RS256 ID token's signature, issuer, audience and expiry itself; SMS one-time codes are a fixed dev code because no SMS provider is wired, and the API says so in its response.
Data
14 tables across 8 migrations in apps/api/src/db/schema.ts: devices holds denormalised current holder, custody_events is an append-only log with a UNIQUE idempotency_key, plus users, sessions, oauth_identities, trust, pending_transfers, disputes, guest_loans, nudges, preferences, identifiers (unit vs model scope, with a partial unique index so only unit-scoped codes like IMEIs are globally unique), catalog_cache and tac_models.
Consistency
Custody moves are compare-and-swap UPDATEs checked on changes === 1, with idempotency keys enforced by UNIQUE columns on custody_events and pending_transfers (settlement and dispute writes use derived keys such as settle:<id>). The atomic() helper is a type-level guard that refuses an async callback so no await can split a read-modify-write; it is not a SQLite transaction, so the holder UPDATE and the log INSERT are two autocommit statements kept together only by bun:sqlite being synchronous in a single process.
Failure
Catalogue lookups have 6s timeouts and a provider outage is reported to the user and deliberately not cached as a miss; the JWKS fetch has a 10s timeout and honours Cache-Control max-age; the router turns any unhandled error into a problem-style JSON body with a request id; SSE uses a 25s heartbeat under a 120s idle timeout and drops dead connections without stopping fan-out; /metrics exposes event-loop lag and live SSE connection counts. There is no retry or dead-letter path because nothing is queued.
Testing
None committed: no test files exist under apps/api despite a bun test script (git ls-files finds no *.test.* or spec files). The README's 20-concurrent-claims load result has no script in the repo.

Techniques

  1. Compare-and-swap custody claimA claim is a single UPDATE guarded by the holder the client saw, accepted only when exactly one row changed; the loser gets a 409 with the current holder. No lock and no read-modify-write window.apps/api/src/routes/devices.ts:149 apps/api/src/routes/devices.ts:169 apps/api/src/routes/devices.ts:183
  2. Append-only custody log with idempotency keyscustody_events is never updated and carries a UNIQUE idempotency_key, so a replayed scan returns replayed: true instead of creating a second loan; timer- and dispute-driven writes derive their own keys (settle:<id>, dispute:<id>).apps/api/src/db/schema.ts:57 apps/api/src/routes/devices.ts:164 apps/api/src/routes/lending.ts:45
  3. Veto window as a pending transferPass-on writes a pending_transfers row that settles after 10 minutes unless the owner vetoes; settlement runs lazily on read and on a 30s timer, and re-uses the same CAS so a holder change underneath makes the transfer moot instead of retrying forever.apps/api/src/routes/lending.ts:18 apps/api/src/routes/lending.ts:33 apps/api/src/routes/lending.ts:153 apps/api/src/server.ts:47
  4. Type-level guard against await in critical sectionsatomic() is typed so an async callback fails to compile, which stops an await from opening a gap inside a read-modify-write; KeyedMutex serialises per key for cases that must await.apps/api/src/core/atomic.ts:28
  5. SSE with Last-Event-ID replay and id-only payloadsEach user gets a 64-event backlog with monotonic ids; a reconnecting phone sends Last-Event-ID and receives what it missed. Events carry ids and a reason, never rows, so there is one authorisation path.apps/api/src/core/events.ts:56 apps/api/src/core/events.ts:116 apps/api/src/routes/events.ts:20
  6. Zero-dependency migrations and Google token verificationA 25-line migration runner applies each SQL migration in its own transaction, toggles foreign keys around table rebuilds and fails startup on PRAGMA foreign_key_check; Google ID tokens are verified with crypto.subtle against a JWKS cache that honours max-age.apps/api/src/db/schema.ts:296 apps/api/src/db/schema.ts:314 apps/api/src/core/google.ts:17 apps/api/src/core/google.ts:55

Counted

  • 21Commits (whole repo)
    git rev-list --count HEAD
  • 0npm dependencies in apps/api
    apps/api/package.json "dependencies": {}
  • 37Routes
    grep -hE '^\s*(r|router)\.(get|post|patch|delete)\("' src/routes/*.ts src/server.ts | wc -l
  • 8Migrations
    grep -c 'id: "00' apps/api/src/db/schema.ts
  • 14 (+ _migrations)Tables
    CREATE TABLE in apps/api/src/db/schema.ts (16 statements incl. users_new rebuild and _migrations)
  • 2,991API TypeScript LOC
    git ls-files 'apps/api/*.ts' | xargs wc -l | tail -1
  • 0Test files
    git ls-files | grep -iE 'test|spec' (only icon SVGs match)
  • 10 minVeto window
    apps/api/src/routes/lending.ts:18
  • 64 events per userSSE replay buffer
    apps/api/src/core/events.ts:56