Skip to content

feat: proxy-per-client-relay V2 (unblocks external past build 18) - #9

Merged
DocNR merged 20 commits into
mainfrom
feat/proxy-per-client-relay-v2
Apr 20, 2026
Merged

feat: proxy-per-client-relay V2 (unblocks external past build 18)#9
DocNR merged 20 commits into
mainfrom
feat/proxy-per-client-relay-v2

Conversation

@DocNR

Copy link
Copy Markdown
Owner

Summary

  • Adds proxy-per-client-relay V2: iOS notifies the proxy of each nostrconnect pair's URI relays via NIP-98-signed HTTP; the proxy maintains ref-counted WebSocket subs across the union of paired relays, filtered by `#p` on registered signer pubkeys; signed responses publish back on the origin relay via `responseRelayUrl` plumbed through to `LightSigner`.
  • Unblocks external TestFlight promotion past build 18 by restoring fevela / nostr-tools-family signing that regressed when #7 landed `switch_relays → null`.
  • Also fixes the swipe-to-unpair UI bug (row animated away then reappeared when confirmation alert opened).

Spec + diagnostic

  • Design spec: `~/hq/clave/specs/2026-04-20-proxy-per-client-relay-v2-design.md` (Syncthing workspace — not in the repo).
  • Five-probe diagnostic that validated the architecture end-to-end: `~/hq/clave/troubleshooting/2026-04-19-switch-relays-nsec-app-e2e.md`.

Proxy changes (`relay-proxy/`)

  • `clients.js` (new) — per-pair storage module, atomic temp-file + rename writes, 19 unit tests.
  • `relayPool.js` (new) — ref-counted secondary-relay WebSocket fan-out, narrow `{kinds:[24133], "#p":[...]}` REQ, `refreshFilter` on signer set change, exponential reconnect backoff, heartbeat/pong-timeout, 18 unit tests.
  • `proxy.js` — new `POST /pair-client` + `POST /unpair-client` (NIP-98 authenticated, parallel to `/register`); shared `dispatchCaughtEvent` pipeline for both primary and secondary; per-event `relay_url` in APNs payload; `[Compliance]` log line; boot-time restoration from `clients.json`; `refreshFilter` hooks on `/register` + `/unregister`.

iOS changes (`Clave/`)

  • `LightSigner.handleRequest(responseRelayUrl:)` — fixes the probe E hardcode that sent responses to `relay.powr.build` regardless of origin.
  • `NotificationService` + `AppDelegate` foreground push handler thread `userInfo["relay_url"]` through.
  • `AppState.pairClientWithProxy` + `unpairClientWithProxy` with `pendingPairOps` retry queue (cap 10, 3-strikes `failCount`, drains on foreground + successful `/register`).
  • `handleNostrConnect` calls `pairClientWithProxy` after handshake success.
  • `deleteKey()` bulk-unpairs before `/unregister`.
  • `HomeView` + `ClientDetailView` unpair flows now call `/unpair-client` (was missing in initial plan — caught in code review).
  • 5-pair cap enforced pre-flight in `ApprovalSheet` (nostrconnect) and `LightSigner` bunker first-connect.
  • Swipe-to-unpair on `HomeView` now uses `.swipeActions` instead of `.onDelete` — no more phantom row-reappearing when Cancel is tapped on the confirmation alert.

Resource caps

Enforced on proxy, mirrored pre-flight on iOS:

  • ≤ 10 relays per pairing
  • ≤ 5 paired clients per signer (free tier; future paid = 25)
  • ≤ 50 novel relays per signer (URLs not already in the pool)

No global hard cap — replaced with operator alerting at 500 unique relays / 80% FD limit (alerting not yet implemented, acceptable per spec).

Test plan

Unit tests

  • Proxy: `node --test test/clients.test.js test/relayPool.test.js test/storage.test.js` — 64 tests pass on Mac (`nip98.test.js` requires `@noble/curves` present on Dell only; unchanged behavior).
  • iOS: `xcodebuild build -scheme Clave` succeeds on simulator destination. Existing `LightSignerPeekMethodTests`, `LightSignerProcessRequestTests`, `NostrConnectParserTests`, `LightEventNip98Tests`, `AppStateMultiRelayHelpersTests` unchanged. New `LightSignerResponseRelayUrlTests.swift` exists on disk but not yet in the xcodeproj test target (parameter presence is compile-time verified by NSE + AppDelegate calling `handleRequest` with `responseRelayUrl:`).

Verification matrix (build 21 on internal TestFlight, Clave backgrounded)

#ClientURI flowExpected
1CoraclenostrconnectPair + sign kind:1 ✓
2fevelanostrconnectPair + sign kind:1 ✓
3noStrudelnostrconnect (NOT relay.nsec.app)Pair + sign kind:1 ✓
4zap.cookingnostrconnectPair + sign kind:1 ✓ or documented upstream bug
5plebsvszombiesnostrconnectPair + sign kind:1 ✓ or documented upstream bug
6NosturbunkerPair + sign kind:1 ✓ (regression check)
7Unpair flowanyRefcount drops; secondary subs close when last pair drops
8Cap boundary6th pair`409` + iOS toast
9Offline pairairplane mode`PairOp` queued; drains on network return

Items 1-3, 6, 7, 8, 9 must pass to promote external. Items 4-5 can ship with upstream-bug annotations.

Deploy checklist

  1. Merge this PR.
  2. SSH Dell → `git pull` → `sudo cp relay-proxy/{proxy,clients,relayPool}.js /opt/clave-proxy/` → `sudo systemctl restart clave-proxy`.
  3. Bump pbxproj to build 21 (small commit on main).
  4. Archive + upload to internal TestFlight from Xcode.
  5. Run verification matrix above on a real device.
  6. If matrix passes: promote external, tag `v0.1.0-build21`, publish GitHub release.

🤖 Generated with Claude Code

DocNR added 20 commits April 20, 2026 12:09
New storage module parallel to storage.js. Per-pair rows:
{signerPubkey, clientPubkey, relayUrls[], createdAt, lastSeenAt}.
Atomic temp-file + rename writes. CRUD: addPair (upsert), removePair,
removeBySigner, loadAll, countBySigner. 11 unit tests.
Part of proxy-per-client-relay V2. No wiring yet — isolated module.
novelRelayCount is the per-signer quota check: counts URLs that would
become new WebSocket connections in the relay pool (excludes
relay.powr.build as primary-covered, excludes URLs already in any
pairing's relay list). Caller enforces ≤ 50 per signer.
gcStale deletes pair rows older than maxAgeDays by createdAt. Used
later for the daily orphan-cleanup job.
7 new unit tests.
Secondary-relay pool. addRelay opens one WS per unique URL, sends
narrow REQ {kinds:[24133], "#p": [signers], since:now}. Ref counting
means 100 users sharing a relay → 1 WebSocket with a filter listing
all 100 pubkeys. releaseRelay decrements + closes on last drop.
URL factory is injected for testability. 9 unit tests cover lifecycle,
ref counting, message routing, and filter format.
relay.powr.build URLs are silently skipped — already covered by the
primary sub in proxy.js.
Reconnect + backoff in the next commit.
When /register adds a new signer pubkey or /unregister removes one,
every secondary sub's #p filter becomes stale. refreshFilter walks
all open WSs, sends CLOSE then a fresh REQ with the current signer
list. No-op for WSs not yet open — they'll get the current list
naturally on their open handler.
2 new unit tests (11 total in relayPool.test.js).
Per-relay backoff starts at 1s, doubles on each failure, capped at
10min. Resets to 1s on successful open. Reconnect cancelled if
releaseRelay drops refcount to 0. shutdown() helper for graceful
proxy shutdown.
3 new tests (14 total in relayPool.test.js).
NIP-98 authenticated (parallel to /register). Accepts
{client_pubkey, relay_urls} and inserts a per-pair row via
clientsStorage.addPair.
Caps enforced:
- ≤ 10 relays per pairing → 400 relay_limit_per_pair
- ≤ 5 pairings per signer → 409 pairing_limit
- ≤ 50 novel relays per signer → 409 novel_relay_quota
No relayPool wiring yet — TODO in Task 9. This commit is the surface;
next commits add /unpair-client + pool integration.
Symmetric to /pair-client. NIP-98 authenticated. Removes the
(signerPubkey, client_pubkey) row from clients.json. Always returns
200 even if no pair exists (idempotent — the iOS retry queue drives
this; a second attempt shouldn't error).
No relayPool wiring yet — Task 9 integrates the pool release on the
removed relay URLs.
Wires the new endpoints to the ref-counted secondary-relay pool:
- /pair-client → relayPool.addRelay(url) for each URL
- /unpair-client → relayPool.releaseRelay(url) for each URL in the
removed pair
Adds dispatchCaughtEvent() — a shared push pipeline for both primary
and secondary relay hits. APNs payload.relay_url is now per-event:
PUBLIC_PRIMARY_URL for primary catches, origin URL for secondary.
Compliance log emits one line per caught event:
[Compliance] source=... class=PRIMARY|SECONDARY client=... signer=... event=... ts=...
Boot-time restoration: on startup, iterates clients.json and opens
secondary subs for every unique relay URL.
Unblocks the probe-E architecture. NSE side still needs the
responseRelayUrl plumbing (iOS tasks).
When a new signer pubkey is registered (or removed), every secondary
sub's #p filter is stale. Hook relayPool.refreshFilter() into both
endpoints to CLOSE + new REQ on all open WSs so they pick up the
current signer set.
…beat
Three issues from the post-Phase-2 code review:
I1: Move relayPool init + boot restoration out of server.listen
callback to module scope. Closes a microtask-window race where
/pair-client could arrive between listen() returning and the callback
firing, crashing on TypeError: Cannot read properties of null
(reading 'addRelay') with the pair already persisted to clients.json.
I2: Validate relay_urls shape in /pair-client body before calling
relayPool.addRelay. The `ws` constructor throws synchronously on
malformed URLs, which would leave clients.json and the pool out of
sync. New 400 response: {error:"invalid_relay_url", url}. Checks
URL parseability + protocol (ws: or wss: only).
M2: Port heartbeat/pong-timeout from the primary sub's connectRelay
into relayPool. Secondary relay WSs now ping every 30s and terminate
on 10s pong timeout. NAT/firewall idle-drop could silently zombie a
socket; without heartbeat the pool would sit on it and miss events
indefinitely. 4 new tests: pings on interval, pong-timeout
terminates, pong reply cancels timeout, heartbeat stops on release.
37 tests total (19 clients + 18 relayPool). node --check proxy.js
green.
Bunker/NSE fallback publish was hardcoded to SharedConstants.relayURL.
With proxy-per-client-relay V2, request events can arrive on any URI
relay — the signed response must go back on that same relay or the
client (which isn't subbed to relay.powr.build) never sees it and
times out.
Thread responseRelayUrl from push payload all the way through to
the LightRelay publish call. Defaults to nil → SharedConstants.relayURL
(backward compatible with existing bunker flow).
Compile-time tests file added but not yet in the xcodeproj target;
parameter presence is verified by NSE + AppDelegate compiling in the
next task (they pass responseRelayUrl to handleRequest).
Runtime behavior validated on TestFlight build 17 during probe E
diagnostic.
Both the NSE (NotificationService) and the foreground AppDelegate
push handler already extract userInfo["relay_url"] for their fetch
connection. Now also pass it to LightSigner.handleRequest as
responseRelayUrl so the signed response publishes on the same relay
the request arrived on.
Completes the probe E plumbing. Without this, even with the proxy
catching events on secondary relays, responses still went to
relay.powr.build — which clients that don't include it in their URI
can't see.
PairOp captures a queued pair/unpair operation for the
pendingPairOps retry queue. Codable, Identifiable.
ConnectedClient.relayUrls defaults to [] with explicit Codable impl
so existing UserDefaults rows (written pre-V2) decode cleanly without
the field. New nostrconnect pairings populate at handshake time (see
AppState tasks). Bunker pairings stay [] since they're always on
relay.powr.build.
iOS can't always reach the proxy at pair time (flaky wifi, proxy
redeploy, Cloudflare blip). Instead of losing the pairing, stuff the
op into SharedStorage.pendingPairOps — FIFO cap 10, drop-oldest on
overflow — and drain on foreground + after /register.
CRUD: enqueuePendingPairOp, getPendingPairOps, removePendingPairOp,
bumpPendingPairOpFailCount, clearPendingPairOps. New UserDefaults
key pendingPairOpsKey. Unit tests follow in the xcodeproj once the
test file is added to the target; CRUD is straightforward and mirrors
the existing pendingRequests queue.
Parallel to registerWithProxy — NIP-98 signed POST to /pair-client
and /unpair-client. Non-200 responses (including network failures)
enqueue a PairOp in SharedStorage.pendingPairOps for retry.
handleNostrConnect now calls pairClientWithProxy after acceptedCount > 0
confirms the handshake landed on at least one URI relay. This is the
iOS side of proxy-per-client-relay V2 — once the pair is known to the
proxy, sign_event RPCs on URI relays get caught and push-dispatched.
Unpair wiring + deleteKey bulk unpair + drain come in the next commits.
…ster
deleteKey() now iterates connectedClients and /unpair-client's each
before /unregister — proxy releases its secondary relay refs. Failed
unpairs become 90-day-GC'd orphans on the proxy. Also clears any
queued pendingPairOps since the key is going away.
drainPendingPairOps retries queued ops with up to 3 attempts per op
(failCount cap). Triggered on:
- Successful /register completion
- applicationDidBecomeActive (via NotificationCenter post from
AppDelegate → AppState observer set up in init)
Blocks Connect button when connectedClients.count >= 5 and shows
an alert pointing the user to Settings → Clients to unpair.
Defensive: proxy also enforces (409 pairing_limit), but catching
client-side avoids a handshake + failed HTTP roundtrip. Re-pairing
an existing client (same pubkey) is not blocked.
Bunker first-time-connect cap in the next commit (LightSigner).
Bunker has no ApprovalSheet — clients show up as connect RPCs with
the bunker secret. When an unknown client_pubkey presents a valid
secret, check connectedClients.count before creating the permissions
row. At cap, reject with a NIP-46 error telling the user to unpair
someone in Clave settings.
Completes the 5-pair cap enforcement: ApprovalSheet covers
nostrconnect; this covers bunker. Proxy's /pair-client 409 still
backs up nostrconnect as defense-in-depth.
C1: /unpair-client was never called from the UI unpair flows. Only
the bulk path from deleteKey() reached the proxy. Without this, the
verification matrix item 7 (unpair → refcount decrement) fails:
proxy keeps clients.json rows + secondary WS subs + quota slots until
90-day GC. Now HomeView.swift (unpair alert) and
ClientDetailView.performUnpair() both call
appState.unpairClientWithProxy before removeClientPermissions.
I2: retryPairOp / retryUnpairOp previously early-returned on
no-nsec / setup-failure without incrementing or removing the op,
leaving immortal queue entries. Now each guard arm either removes
the op (unrecoverable — missing key, malformed URL, serialization
failure) or bumps failCount (recoverable — signing threw). The
failCount >= 3 cap still caps server-facing retries.
.onDelete on a ForEach auto-animates the row out of the list before
our confirmation alert resolves — when the user taps Cancel, the row
snaps back into place, which looks buggy (row disappears, dialog pops,
row reappears). .onDelete is meant for "delete now" semantics.
Replace with .swipeActions(edge: .trailing, allowsFullSwipe: false).
This exposes a custom Unpair button that triggers the confirmation
alert without touching list state. Row only animates away after the
alert resolves with Unpair tapped (because refreshData() then
re-fetches without the removed client).
@DocNR
DocNR marked this pull request as ready for review April 20, 2026 18:45
@DocNR
DocNR merged commit f09b86c into mainApr 20, 2026
@DocNR
DocNR deleted the feat/proxy-per-client-relay-v2 branch April 20, 2026 18:45
DocNR added a commit that referenced this pull request May 2, 2026
Brainstorm review of design-system.md against shipped code surfaced 9
inconsistencies + 1 anti-pattern still present. Fixed everything in one
batch so the next TestFlight archive carries it all.
Code:
- HomeView: drop .padding(.bottom, 8) on SlimIdentityBar invocation —
slim banner owns its outer bottom padding (12pt); stacking another 8pt
on top was double-counting (review #4)
- HomeView: drop .padding(.bottom, 8) inside statsRow — listSectionSpacing(0)
carries the gap to Connected Clients; the residual padding kept the
visible gap excessive after polish round 2 (review #2)
- AccountDetailView: avatarLarge letter fallback opacity 0.25 → 0.22 to
align with SlimIdentityBar's 0.22 (review #3)
- ConnectSheet: add .presentationBackground(Color(.systemGroupedBackground))
— was the last sheet still defaulting to translucent (review #9)
- ApprovalSheet: rename @State capExceeded → showConnectionCapAlert for
naming convention parity with HomeView (review #7)
design-system.md:
- New "Cross-platform applicability" section at the top — clarifies what
carries directly to clave.casa web companion (color tokens, displayLabel
rule, identity-vs-functional zone philosophy, avatar treatments, copy
patterns, anti-patterns) vs what's iOS-only (SwiftUI modifiers, haptics,
sheet/toolbar conventions)
- §3 Typography: corrected initial-letter font scale — AvatarView uses
size*0.35 mono (pubkey) or size*0.4 proportional (name); was wrongly
documented as a single 0.37 (review #1)
- §4 Avatars: added Treatment Selection Rule table (B on neutral bg,
C on saturated theme gradient) + clarified 1-vs-2 letter behavior
(review #5, #6)
- §4 Sizing scale: expanded table to include initial font + border
thickness per slot, with the ~5% border scaling rule (review #8)
- §5 Spacing: explicit "single source of truth" note on slim banner
bottom padding; new "Stats row" subsection capturing the
ultraThinMaterial-on-small-cards-OK rule (review gap #10, #4)
- §6 HomeView gradient: documented palette[0] defensive fallback when
currentAccount is nil (review #13)
- §7 Patterns: new "State variable naming" subsection with the
showCapAlert / showAccountCapAlert / showConnectionCapAlert
convention (review #7 doc side)
- §11 Anti-patterns: audit-point note that ConnectSheet was the last
surface to acquire .presentationBackground (review #9 doc side)
Build green on iOS Simulator 26.4. pbxproj still 41 — assumes user hasn't
yet archived 41; bump to 42 if needed before re-archive.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@DocNR