Releases: DocNR/clave
v0.2.0-build79 — Connect tab + picker (Phase 1)
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
handleNostrConnectrefactored to array signature withHandshakeResultreturn type. Phase 1 always passes a 1-element array; Phase 2 enables N > 1.- Unified
ConnectAccountPickerused 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
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
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 andpair-clientregistration could fail before completing, leaving the client unable to reach the signer. Build 62 wraps the handshake inUIApplication.beginBackgroundTaskso 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 handshakeTaskis now@MainActorand bookended bybeginBackgroundTask/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-clientHTTP. Afterpair-clientsucceeds 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
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-
contentHandlerwas racy because the NSE process exits before iOS commits the notification) PendingApprovalBanner.clearenumerates delivered notifications and removes NSE-delivered banners byuserInfo.pendingRequestIdmatch (TTL purge / approve / deny / lock-screen action all funnel through this)dismissedAlertRequestIdsset on AppState — "Not now" callsdismissAllActiveAlerts()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)processedInChaincounter drives correct title progression with synchronous refresh inadvanceChainPosition(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
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_pubkeypayload 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
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)
- Sign events from your usual clients — verify the new activity detail screen.
- Edit your contact list (add or remove a follow) and confirm the diff summary reads correctly.
- Tap a connection from the Home tab → "Recent Activity" rows drill into the same shared detail view.
- 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
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
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
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 @Observableclass. Per-relayURLSessionWebSocketTaskactors.withTaskGroup-based dispatcher matching existing AppState/LightSigner concurrency patterns. Heartbeat (30s ping / 10s pong timeout), exponential reconnect backoff (1/2/4/8/16s).AsyncSemaphorefor per-event concurrency cap (default 5, the empirical sweet spot from FINDINGS). 30s per-event budget enforced viawithTaskGrouprace against a sleep watchdog. Receive loop dispatches events toLightSigner.handleRequestwithresponseRelayUrlthreaded back to the originating relay.Shared/SharedStorage.swift: newmarkEventProcessed(eventId:, createdAt:)helper + newsetClientRelayUrls()/getDebugTestRelay()/setDebugTestRelay()helpers. The dedupe helper is the single choke point both NSE and L1 inherit; the relayUrls setter populatesConnectedClient.relayUrlsat 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 ofhandleRequestviamarkEventProcessed. Returnsskipped-duplicatestatus 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@MainActorbridge methods.pairClientWithProxynow also callssetClientRelayUrlsand triggersrefreshRelaySet().unpairClientWithProxytriggersrefreshRelaySet().Clave/Views/MainTabView.swift: scenePhase observer with 2s.inactivegrace (cancels pending stop on app-switcher peek / control-center swipe). Confirmed background stops immediately.Clave/Info.plist:NSAllowsLocalNetworkingATS exception so the new WebSocket sub can connect to plainws://hosts on the local network for dev verification (production traffic still useswss://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 Relaysection. 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 atnak serveon 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(tagresearch/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
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
- Merge this PR.
- SSH Dell → `git pull` → `sudo cp relay-proxy/{proxy,clients,relayPool}.js /opt/clave-proxy/` → `sudo systemctl restart clave-proxy`.
- Bump pbxproj to build 21 (small commit on main).
- Archive + upload to internal TestFlight from Xcode.
- Run verification matrix above on a real device.
- 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):
PendingRequestnow stores the origin relay URL (responseRelayUrl: String?), so approve-later publishes land on the relay the client is actually subscribed on instead of therelay.powr.buildfallback. 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`/hq/clave/plans/2026-04-20-response-delivery-fixes.md`
Plan: `
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