Skip to content

Releases: DocNR/clave

v0.2.0-build79 — Connect tab + picker (Phase 1)

12 May 14:39
d9378d9

Choose a tag to compare

Phase 1: multi-account NostrConnect

Promotes Connect from a HomeView-presented sheet to a top-level cross-account tab. All account-binding for pairing flows through a single unified picker. No protocol changes; Phase 2 (accounts=multi opt-in for clients) follows separately.

What's new

  • Connect tab. New ⚡ bottom-bar tab between Activity and Discover. Pairing is no longer buried under Home.
  • Discover tab. Placeholder for future NIP-46-compatible app links.
  • Multi-account pairing. With multiple accounts, you now explicitly pick which account each app pairs with — no more implicit "whichever was selected on Home." The picker shows your profile picture, display name, and truncated npub.
  • Per-account bunker URI. The bunker flow asks which account first, then shows that account's URI with an avatar header — you can tell at a glance which account you're sharing.
  • New help sheet. "What's the difference between Nostrconnect and Bunker?" — explains both methods plus the same-device pairing reliability gotcha.

Under the hood

  • handleNostrConnect refactored to array signature with HandshakeResult return type. Phase 1 always passes a 1-element array; Phase 2 enables N > 1.
  • Unified ConnectAccountPicker used by all 3 entry paths (in-app NostrConnect, in-app bunker, external deeplink)
  • Auto-skip when `accounts.count == 1` — single-account users see no added friction
  • Old `Clave/Views/Home/Connect/` directory deleted; new `Clave/Views/Connect/` directory established
  • 246 unit tests pass; new test files: `HandshakeResultTests`, `AppStateHandshakeSignatureTests`, `ConnectAccountPickerAutoSkipTests`

Phase 2 preview

Multi-account NostrConnect protocol opt-in (`accounts=multi` URI flag — one pairing produces N parallel signer sessions) is scoped for a separate PR once Tableau's parallel implementation lands.

TestFlight

Build 79 is on TestFlight (external rollout in progress). Pre-release marker reflects external-smoke-still-in-flight status; will be promoted to Latest when stable.

PR: #52 · Spec + plan live on branch `spec/multi-account-nostrconnect` (local).

v0.2.0 build 71 — bunker pair-cap fix + AppState refactor

09 May 12:17
cd34546

Choose a tag to compare

What's new since build 62

User-visible:

  • Bunker pair-cap bypass closed. Previously the per-account 5-connection cap could be bypassed in the bunker flow due to a timing bug. Build 71 enforces the cap at three layers (entry-gate, in-sheet rotation gate, NSE-side check). Closes the issue field-confirmed when a tester reached 7 pairs on a single signer.
  • Same-device nostrconnect UX fix. When pairing a Nostr client running on the same iPhone as Clave via paste, the "Connecting…" sheet now correctly says to switch back to the client app (was misleading "Stay in Clave"). For same-device pairing, bunker:// is the recommended path since it sidesteps the iOS WebSocket-suspension issue entirely — see docs/nip46-compatibility.md for client-developer guidance.
  • QR rescan dedup. Cancelling an approval sheet that came from a QR scan no longer immediately re-fires the same QR.

Under the hood:

  • AppState.swift refactor sprint complete: 2,338 → 348 LOC (−85%), split across 6 dedicated extension files. Zero behavior change; same code, more navigable.

PRs included: #36 (nostrconnect polish + docs), #41 (sprint 5a), #30/#32/#34/#37/#39/#42/#43/#45 (AppState refactor + dead-code cleanup), bumps #31/#33/#35/#38/#40/#44/#46.

v0.2.0 (build 62) — Connection reliability + paste polish

07 May 02:55
1e081e7

Choose a tag to compare

v0.2.0 build 62 — Connection reliability + paste polish

A small, targeted fix for a real-world failure pattern in the nostrconnect setup flow, plus two UX papercuts in the same corner of the app.

What's new

  • Nostrconnect handshake survives backgrounding. Pasting a nostrconnect:// URI, tapping Approve, and immediately swiping to the client app to verify — the natural follow-up to a connect — used to risk a silent failure. iOS suspends the app within ~5–10s of backgrounding, freezing the in-flight WebSocket mid-handshake. The connect ack and pair-client registration could fail before completing, leaving the client unable to reach the signer. Build 62 wraps the handshake in UIApplication.beginBackgroundTask so iOS gives it ~30s of background runtime — comfortable margin over the ~15–20s handshake.
  • One-tap paste button. The Connect → Nostrconnect tab now has a "Paste Nostrconnect URI" button above the text field. No more tap-field-long-press-paste — one tap reads the clipboard, populates the field, and jumps straight to the approval sheet (with inline errors for empty/invalid clipboards).
  • "Stay in Clave for a few seconds" overlay copy. The connecting overlay now nudges users to wait for the handshake before switching apps — a soft hint that complements the under-the-hood bg-task.
  • "Connect as @..." button no longer wraps. Long emoji-decorated display names used to mangle the approval button into a two-line shape. Now it's single-line with tail truncation and gentle text scaling.

What to try

  • Pair a fresh nostrconnect client (Wisp, Coracle, noStrudel, etc.), approve, then immediately swipe Clave to the app switcher and launch the client. The connection should land cleanly — no retry needed.
  • Copy a nostrconnect:// URI to the clipboard, open Clave → Connect → Nostrconnect tab, tap "Paste Nostrconnect URI". Should jump straight to the approval sheet.
  • Sign a few notes via an existing bunker pairing — confirm no regression in the proxy/APNs/NSE path.

Behind the scenes

  • submitApproval's handshake Task is now @MainActor and bookended by beginBackgroundTask / endBackgroundTask, with an idempotent expiration handler so iOS can never watchdog-kill the process for a missed cleanup.
  • The protected window covers all three critical phases: connect to URI relays → publish connect-ack → pair-client HTTP. After pair-client succeeds the proxy's secondary subscription takes over via APNs/NSE; the listen loop staying alive is just bonus latency reduction.
  • Bunker flow is unchanged. Bunker URIs handshake via relay.powr.build → proxy → APNs → NSE, so they already had an effective ~30s background budget through the NSE wake.

Known limitation

UIBackgroundTask doesn't survive process termination. If you force-quit Clave (swipe up from app switcher) within the handshake window, the connection still fails — same as before. Re-paste the URI and try again. A persistent retry queue for this case is queued for a future polish round.

pbxproj 60→62 (61 was burned in a prior archive that didn't ship). MARKETING_VERSION 0.2.0 held.

v0.2.0 (build 60) — Pending request polish

06 May 23:11
0e3dd41

Choose a tag to compare

v0.2.0 build 60 — Pending request polish

A focused cleanup pass on the pending-approval surface, fixing every rough edge surfaced by build 54 device testing. Bundles the Inbox + ConnectSheet redesign work plus several rounds of UX polish.

What's new

  • Lock-screen banners now clear with the in-app row. When a pending request times out, gets approved, or you tap Deny, the system tray entry clears in lockstep. Empty silent-sign entries in Notification Center also stop piling up between events.
  • "Not now" button on the alert. When pending requests stack, you can tap "Not now" to dismiss the whole batch and handle them at your own pace via the bell icon.
  • Tappable Pending tile. The orange "Pending" stat on Home now opens the inbox directly — second affordance alongside the bell.
  • Correct alert numbering. When 3 pending requests are stacked, the alert progresses "1 of 3" → "2 of 3" → "3 of 3" as you approve. New requests arriving mid-chain bump the total — "2 of 3" + a new arrival becomes "2 of 4".

What to try

  • Trigger a sign request that needs approval, walk away >5 minutes — when you re-open Clave, both the inbox row and the lock-screen banner should be gone.
  • Stack 2-3 pending requests, tap "Not now" — alert closes, bell badge stays, inbox lists everything for later.
  • Approve through a stack — title should progress "1 of 3" → "2 of 3" → "3 of 3".
  • Tap the orange Pending tile on Home — opens the inbox.

Behind the scenes

  • NSE-side blank-notification sweep at the start of each fresh wake (race-free; previous self-cleanup-after-contentHandler was racy because the NSE process exits before iOS commits the notification)
  • PendingApprovalBanner.clear enumerates delivered notifications and removes NSE-delivered banners by userInfo.pendingRequestId match (TTL purge / approve / deny / lock-screen action all funnel through this)
  • dismissedAlertRequestIds set on AppState — "Not now" calls dismissAllActiveAlerts() to dismiss the whole batch at once; per-request dismissal would auto-chain to the next request (the very "alert keeps popping back up" UX the button was supposed to escape)
  • processedInChain counter drives correct title progression with synchronous refresh in advanceChainPosition (avoids the brief paint where pending shrinks before counter bumps)

pbxproj 51→60, MARKETING_VERSION 0.2.0 held (no minor bump).

Known limitation

Approve/Deny chain transitions have a brief ~0.5s flicker — SwiftUI's .alert(_:isPresented:presenting:) auto-dismisses on button tap regardless of binding state, then re-presents from scratch. Functional, just visually rough. Custom-overlay rework queued for v0.2.1.

v0.2.0 (build 51) — Multi-Account & Universal Links

04 May 02:04
1674c34

Choose a tag to compare

v0.2.0 — Multi-Account is here!

The biggest update yet. Clave can now hold up to 4 accounts on one device — switch with a tap, each with its own ambient gradient identity.

This release also introduces clave.casa — our new web companion that lets you edit your Nostr profile in any browser while your nsec stays safely locked in Clave on your phone.

What's new

  • Multi-account — add up to 4 accounts. Tap the strip on Home to switch.
  • New Account Detail screen — see your profile, manage connections, and edit your profile via clave.casa.
  • Universal Links — open clave.casa, scan the Sign In QR, and Clave handles it directly on iOS (even with other Nostr apps installed).
  • Reliability fixes — more dependable swipe-to-delete in Settings, faster error recovery on web companions, and a fix for stale profile fields after pull-to-refresh.

What to try

  • Add a second account from the + pill on Home.
  • Switch between accounts on the strip and sign a few events from your favorite Nostr client.
  • Open clave.casa in any browser, scan the Sign In QR with Clave — it should drop you right into the app.
  • Edit your profile via the "Edit on clave.casa" link in Account Detail.

Thanks for testing — this one's been a long time coming.


Behind the scenes

This release ships:

  • Multi-account support across iOS + proxy (Stage A signer_pubkey payload routing on prod proxy).
  • AccountDetailView redesign (8 tasks, Phase A).
  • Universal Links chain across iOS + clave.casa (Phase B + AASA).
  • Audit-5 NIP-46 spec compliance: error responses now include result: "" per spec.
  • Kind:0 cross-relay merge by created_at — fixes stale-relay profile field bug.

pbxproj 50→51, MARKETING_VERSION 0.1.0→0.2.0. Currently in Apple Beta Review for external; this release will flip to Latest once Apple clears.

v0.1.0-build31 — activity detail + njump

30 Apr 14:17
ee9719f

Choose a tag to compare

Tagged for TestFlight; archive in progress. Marked Pre-release until external review clears, at which point it promotes to Latest and the previous external (build 29) demotes back to Pre-release per the "leave superseded builds at Pre-release" policy.

What's new since build 29 (current external)

Activity log now tells you what was signed

  • Activity rows show the connection's pet name (e.g., "Damus iPhone") instead of a raw pubkey.
  • New per-event detail screen: tap any activity row to see what was signed (reply / repost / DM / contacts diff / etc.), the event ID, and an "Open on njump.me" link for relevant kinds.
  • Per-connection "Recent Activity" rows are now tappable and use the same shared detail screen.

Smarter contact list summaries

  • Editing your follow list now shows "Followed @alice" or "Contacts +3 / -1" instead of "Updated contacts (712)" on every change.

Bug fixes

  • Pending approvals (kind:3 follow lists, etc.) now show a "signed" entry in the activity log after you approve. Previously the "signed" follow-up entry was silently dropped by an internal dedupe race.
  • "Open on njump.me" on reactions / reposts / zap requests now opens the note that was reacted to, not the bare reaction itself.

What's NOT changed

  • All proxy / NSE / signing infrastructure from build 29 is unchanged. This release is iOS-only and view-layer-heavy; the cryptographic and networking paths are untouched.

Test plan (for internal testers)

  1. Sign events from your usual clients — verify the new activity detail screen.
  2. Edit your contact list (add or remove a follow) and confirm the diff summary reads correctly.
  3. Tap a connection from the Home tab → "Recent Activity" rows drill into the same shared detail view.
  4. React to a note from your client → the "Open on njump.me" button in the activity detail should open the reacted-to note, not the bare reaction.

Merged via PR #19 at `ee9719f`.

v0.1.0-build29 — refresh triggers + L1 observability

29 Apr 03:42
a5fde66

Choose a tag to compare

External TestFlight build.

What's new since build 18 (last external)

Speed

  • Way faster signing when Clave is open and on screen — bulk-loading inboxes and follow lists is a different experience.

Reliability

  • Pending approval requests now appear in Clave's Home screen immediately. No more swiping away and back to see them.
  • Auto re-registration with the push proxy on every launch — Clave self-heals if it ever falls out of sync.
  • Profile and follow-list edits now work with more clients (response delivery routes back to the client's relay, not a fixed fallback).

Diagnostics (developer-facing, not user-facing)

  • Hidden L1 Diagnostics screen (Settings → tap Version 7× → Developer → L1 Diagnostics) shows the foreground signing subscription state, event counters, latency, current relays, and iOS notification permission status.
  • "Copy Recent Logs" now includes L1, banner, and notification-sweep activity (previously silently filtered out).

Bundled fixes from prior unmerged builds

This build also carries everything from builds 22, 24, 27, and 28 (which never reached external):

  • Per-client relay routing (V2 proxy, PR #9)
  • APNs payload-embedded events to bypass the ephemeral-fetch race (build 22)
  • L1 foreground RPC accelerator (PR #11) — ~25× speedup over bunker for in-foreground signing
  • Pending-approval refresh + banner notifications (PR #13)
  • Auto re-register on every launch + register-retry on transient failures (PRs #15, #16)
  • GOAWAY-aware APNs client on the proxy side (PR #14)

Notes for testers

If you've been on build 18 for weeks, you'll want to re-pair any apps that gave you trouble previously — many of them work better now. See TestFlight test notes for what to focus on.

🤖 Generated with Claude Code

Build 27 — register-retry + aggressive blank-NC sweep

28 Apr 16:24
2786f9f

Choose a tag to compare

Build 27 = PR #16 (register-retry on cellular failure + aggressive blank-NC sweep). Internal TestFlight only as of tag creation.

Two real-world bugs surfaced during build 26 testing today, both fixed and shipped same-day.


Summary

Two real bugs surfaced during build 26 testing today:

1. `registerWithProxy` silently drops failures on bad cellular

User launched Clave on weak cellular signal; the auto-register HTTP POST (10s timeout) failed, the failure was silently dropped because the auto-register call sites in `AppState.init` (`.apnsDeviceTokenAvailable` observer) and `loadState()` pass `completion: nil`. Token never reached proxy. Symptom: signing silently failed until user moved to wifi and tapped Settings → Register manually (the manual path surfaces the failure to the UI).

PR #15 was correct on the happy path but didn't self-heal when the initial POST failed.

2. Blank NC entries weren't being swept

PR #13 shipped `MainTabView.sweepBlankNotifications()` filtering on `title.isEmpty`. But the proxy's APNs payload sets `alert: { title: " ", body: " " }` (single SPACE characters, so NSE has something to override). When NSE doesn't run (cold-launch race, timeout, force-quit recovery), iOS keeps the proxy's original payload — title is " " (single space), NOT empty. Sweep never matched these. User reported 8 blank entries accumulating in NC while Clave was backgrounded for ~30 min during a session.

Changes

  • New `Clave/Views/Components/NotificationCenterSweep.swift`: extracts `sweepBlankNotifications()` to a top-level free function so both `MainTabView` and `ForegroundRelaySubscription` can call it. Filter now trims whitespace and checks BOTH title AND body, catching the proxy single-space fallback.

  • `MainTabView.handleScenePhase`: sweeps on `.inactive` too (catches user opening NC via swipe-down while Clave is most-recent foreground app but not `.active`). Also calls `appState.ensureRegisteredFresh()` on `.active`.

  • `Shared/ForegroundRelaySubscription.swift`: calls `sweepBlankNotifications()` after each in-process event. Catches the case where Clave is foregrounded and a parallel APNs push leaves a blank NC entry from NSE's `.noEvents` return.

  • `Clave/AppState.swift`:

    • `registerWithProxy()` records `lastRegisterSucceededAtKey` / `lastRegisterFailedAtKey` timestamps in app-group defaults.
    • New `ensureRegisteredFresh()` gates re-registers: skips if last success < 30 min ago, applies 60s cooldown after failures so a dead proxy doesn't get hammered on every foreground.
  • `Shared/SharedConstants.swift`: adds the two new defaults keys.

Test plan

  • `xcodebuild build` → BUILD SUCCEEDED
  • `xcodebuild test` → TEST SUCCEEDED
  • Device test (build 27):
    • On bad cellular: launch Clave, observe registration fails silently. Move to wifi, foreground Clave. Verify `ensureRegisteredFresh()` retries — check proxy log for `[HTTP] Registered` line.
    • Open NC after ~10 min of Clave backgrounded with active signing happening. Should be empty (sweep ran on .inactive transitions).
    • If blanks DO show up briefly, opening Clave once should clear them (sweep on .active).
    • Hot-loop guard: with proxy unreachable, background/foreground 5x in 60 sec. Verify only 1 register attempt fires (60s failure cooldown).

Closes

BACKLOG: "registerWithProxy() retry on transient network failure" (opened earlier today, fixed same day).

🤖 Generated with Claude Code

Build 24 — L1 foreground RPC accelerator

28 Apr 12:30
ae1c11e

Choose a tag to compare

Build 24 = PR #11 (L1 foreground RPC accelerator). Internal TestFlight only as of tag creation.

L1 verified on real device: 100% success across 2000 RPCs at concurrency=5, 40.46 dec/sec, p50 93ms — ~25× speedup over the bunker baseline.


PR #11 — L1 foreground relay subscription

Summary

Adds Layer 1 of the foreground RPC acceleration design — an @MainActor-isolated ForegroundRelaySubscription that holds long-lived WebSocket subscriptions to the user's relays whenever Clave is foregrounded, processing kind:24133 NIP-46 RPCs inline via LightSigner.handleRequest() instead of waiting on APNs+NSE.

Also closes audit item D.1.1 by consolidating the duplicate inline processedEventIDs dedupe logic from ClaveApp.swift and NotificationService.swift into a single SharedStorage.markEventProcessed() helper with insertion-ordered ring buffer + 60s age bound.

Why

Empirical: bunker mode (APNs+NSE) caps at ~1.6 dec/sec; foreground subscription hits 40.46 dec/sec at conc=5 (verified on real device against nak serve). For the dev's 7000-DM scenario, that's ~5.8 minutes vs 2.4 hours = ~25× speedup. Justifies the structural change and produces a markedly snappier in-app experience for any NIP-46 client (Wisp, Nostur, Coracle, etc.) — no client-side changes required.

What's in this PR

Production code

  • Shared/ForegroundRelaySubscription.swift (new, ~370 lines): @MainActor @Observable class. Per-relay URLSessionWebSocketTask actors. withTaskGroup-based dispatcher matching existing AppState/LightSigner concurrency patterns. Heartbeat (30s ping / 10s pong timeout), exponential reconnect backoff (1/2/4/8/16s). AsyncSemaphore for per-event concurrency cap (default 5, the empirical sweet spot from FINDINGS). 30s per-event budget enforced via withTaskGroup race against a sleep watchdog. Receive loop dispatches events to LightSigner.handleRequest with responseRelayUrl threaded back to the originating relay.
  • Shared/SharedStorage.swift: new markEventProcessed(eventId:, createdAt:) helper + new setClientRelayUrls() / getDebugTestRelay() / setDebugTestRelay() helpers. The dedupe helper is the single choke point both NSE and L1 inherit; the relayUrls setter populates ConnectedClient.relayUrls at pair time (it was a dead field added in PR #9 V2 but never written by any code path until this PR).
  • Shared/LightSigner.swift: dedupe check at the top of handleRequest via markEventProcessed. Returns skipped-duplicate status when the event was already processed by another path.
  • Clave/ClaveApp.swift + ClaveNSE/NotificationService.swift: removed redundant inline dedupe blocks (now handled in LightSigner). Net deletion of ~95 lines of duplicated logic.
  • Clave/AppState.swift: startForegroundSubscription / stopForegroundSubscription @MainActor bridge methods. pairClientWithProxy now also calls setClientRelayUrls and triggers refreshRelaySet(). unpairClientWithProxy triggers refreshRelaySet().
  • Clave/Views/MainTabView.swift: scenePhase observer with 2s .inactive grace (cancels pending stop on app-switcher peek / control-center swipe). Confirmed background stops immediately.
  • Clave/Info.plist: NSAllowsLocalNetworking ATS exception so the new WebSocket sub can connect to plain ws:// hosts on the local network for dev verification (production traffic still uses wss://relay.powr.build).

Tests

  • ClaveTests/SharedStorageDedupTests.swift (new, 5 tests): first-seen, repeat, ring buffer cap, age eviction, concurrent within-process serialization.
  • ClaveTests/ForegroundRelaySubscriptionTests.swift (new, 5 tests): initial state, no-signer-key error path, resetCounters, stop-while-idle no-op, double-start idempotent.
  • All existing tests still green.

DEBUG-only test scaffolding (kept in #if DEBUG)

  • Settings → Developer → DEBUG: L1 Test Relay section. Lets a developer add an extra relay to L1's subscription set without going through nostrconnect pairing. Used during the L1 verification matrix to point L1 at nak serve on Mac LAN. Status indicator + live counters (Received / Processed / Failed). Apply / Clear buttons (each in its own row to avoid SwiftUI's HStack-tap-target footgun).

Production builds (Release) compile none of this DEBUG section.

Empirical evidence

~/hq/clave/research/nip17-bulk-decrypt/FINDINGS.md — full numbers, methodology, comparison tables. Headline:

Path Throughput @ conc=5 p50 latency p99 latency Success
Clave bunker (today) 1.61/s 2409ms 7805ms 98.79%
L1 production (this PR) 40.46/s 93ms 488ms 100%

Test plan

  • Unit tests: SharedStorageDedupTests (5), ForegroundRelaySubscriptionTests (5)
  • Build clean: xcodebuild -scheme Clave -configuration Debug ⇒ BUILD SUCCEEDED
  • All existing tests still green
  • Manual matrix on device: 1000-RPC harness run against nak serve, throughput 40.46/s, success 100%
  • Backgrounding lifecycle: 2s grace verified; confirmed-background stops immediately
  • Audit D.1.1 closed (insertion-ordered ring buffer + age bound replaces Set + .suffix(50))
  • Internal TestFlight matrix (post-merge)
  • Dual-path coexistence: foreground L1 + APNs both running, dedupe prevents double-processing — needs internal TestFlight (debug build uses sandbox APNs)
  • External TestFlight (after internal pass)

Spec / plan / prototype

  • Design spec: ~/hq/clave/specs/2026-04-26-foreground-bulk-decrypt-design.md
  • Implementation plan: ~/hq/clave/plans/2026-04-26-foreground-relay-subscription.md
  • Prototype branch (research artifact, will be deleted after this lands): feat/debug-foreground-subscription (tag research/fg-sub-prototype-2026-04-26)

Layer 2 (bulk decrypt session UX, conversation-key cache, auth_url heuristic) is a separate follow-up sprint that builds on top of L1 without modifying it.

🤖 Generated with Claude Code

Build 22 — V2 proxy + response-delivery hotfix

28 Apr 12:30

Choose a tag to compare

Build 22 = PR #9 (V2 proxy-per-client-relay) + PR #10 (response-delivery hotfix), submitted to Apple external review 2026-04-21 evening.


PR #9 — V2 proxy-per-client-relay

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)

# Client URI flow Expected
1 Coracle nostrconnect Pair + sign kind:1 ✓
2 fevela nostrconnect Pair + sign kind:1 ✓
3 noStrudel nostrconnect (NOT relay.nsec.app) Pair + sign kind:1 ✓
4 zap.cooking nostrconnect Pair + sign kind:1 ✓ or documented upstream bug
5 plebsvszombies nostrconnect Pair + sign kind:1 ✓ or documented upstream bug
6 Nostur bunker Pair + sign kind:1 ✓ (regression check)
7 Unpair flow any Refcount drops; secondary subs close when last pair drops
8 Cap boundary 6th pair `409` + iOS toast
9 Offline pair airplane 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


PR #10 — Response-delivery fixes (payload-embed + pending relay-URL)

Summary

Two bug fixes bundled for build 22 — both on the "response delivery reliability" surface surfaced by build 21 matrix testing:

  • Payload-embed (proxy + NSE + foreground): proxy now size-gates the caught kind:24133 event into the APNs push payload (≤3500B). NSE and foreground push handler prefer the embedded event over the existing fetch-from-relay path, closing the ephemeral-drop race that made fevela / noStrudel / Coracle flake on signing.
  • Pending relay-URL (SharedModels + LightSigner + AppState): PendingRequest now stores the origin relay URL (responseRelayUrl: String?), so approve-later publishes land on the relay the client is actually subscribed on instead of the relay.powr.build fallback. Latent bug since PR #7 (switch_relays → null) — nostr-tools-family clients stopped migrating to powr.build, so the fallback stopped matching. Bunker flow was unaffected because Clave's bunker URIs are pinned to powr.build.

Design doc: `/hq/clave/specs/2026-04-20-response-delivery-fixes-design.md`
Plan: `
/hq/clave/plans/2026-04-20-response-delivery-fixes.md`

Changes

  • `relay-proxy/proxy.js`: ~7 lines in `dispatchCaughtEvent` — size-gate `event` embed in push payload
  • `ClaveNSE/NotificationService.swift`: ~15 lines in `handleSigningRequest` — prefer embedded event, fall through to fetch; distinguish cast-miss log from key-missing log
  • `Clave/ClaveApp.swift`: ~15 lines in `handleForegroundSigningRequest` — same pattern as NSE
  • `Shared/SharedModels.swift`: +1 field `responseRelayUrl: String?` on `PendingRequest` (Optional → default Codable synthesis handles pre-build-22 rows as nil)
  • `Shared/LightSigner.swift`: capture `responseRelayUrl` into PendingRequest at queue-time
  • `Clave/AppState.swift`: thread `request.responseRelayUrl` back to `handleRequest` at approve-time

~30 lines of net diff across 6 files. 5 commits (2 of which are small quality-review follow-ups).

Backward-compatible in both skew directions (build 21 app + build 22 proxy, build 22 app + build 21 proxy). Pre-build-22 pending rows decode with `responseRelayUrl: nil` and fall back to powr.build — unchanged broken behavior for pre-upgrade stragglers, accepted per spec as one-time degradation.

No new unit tests — manual matrix verification only for build 22 per sprint decision. Backlogged: `push-payload.test.js` (proxy) and `NotificationServiceTests` (iOS). Local Node 18 dev env has a pre-existing `nip98.test.js` failure (ESM `require` incompat with `@noble/curves@2.2.0` — Dell runs Node 20+ where this passes); not introduced by this PR.

Test plan

  • Existing proxy tests pass locally on Node 20+ (Dell deploy target)
  • Xcode `Cmd+U` — all existing iOS unit tests pass
  • Deploy proxy to Dell, verify `[Push]` log line on first caught event post-restart
  • Build 22 installed via internal TestFlight
  • Matrix:
    • Nostur bunker kind:1 auto-sign (regression)
    • Nostur bunker kind:10002 pending approval (regression)
    • fevela nostrconnect cold-start kind:1 (post-5-min-wait) — must work
    • fevela nostrconnect kind:10002 pending approval — must work
    • noStrudel nostrconnect kind:1 — must work
    • Coracle nostrconnect kind:1 (established npub) — must work
    • Coracle nostrconnect kind:10002 pending approval — must work
    • Cap boundary: 6th pair rejected
    • Offline pair: pending pair op drains on resume

🤖 Generated with Claude Code