feat: proxy-per-client-relay V2 (unblocks external past build 18) - #9
Merged
Conversation
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
marked this pull request as ready for review
April 20, 2026 18:45
9 tasks
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Spec + diagnostic
Proxy changes (`relay-proxy/`)
iOS changes (`Clave/`)
Resource caps
Enforced on proxy, mirrored pre-flight on iOS:
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
Verification matrix (build 21 on internal TestFlight, Clave backgrounded)
Items 1-3, 6, 7, 8, 9 must pass to promote external. Items 4-5 can ship with upstream-bug annotations.
Deploy checklist
🤖 Generated with Claude Code