feat: multi-account support — Stage B (iOS plumbing + dev menu)#23
Merged
Conversation
…sk 1) Pure additive change. Reserves the new UserDefaults namespace for multi-account state and extracts CachedProfile from AppState into Shared/SharedModels.swift so multi-account code in Shared/ can reference it. Added: - Account struct (pubkeyHex, optional petname, addedAt, optional profile) - CachedProfile struct (extracted from AppState; field shape preserved) - SharedConstants.accountsKey, currentSignerPubkeyHexKey, bunkerSecretsKey, lastContactSetsKey, lastRegisterTimesKey No callers wired up yet — follow-up tasks (2 through 8) populate the new keys. Single-account UX continues to work identically. Tests: AccountModelTests (8 tests, all passing on iPhone 17 / iOS 26.4). Verifies Codable roundtrip, Optional-field decode, Identifiable conformance, Equatable, legacy CachedProfile shape compatibility, and the 22-key SharedConstants namespace uniqueness invariant. Plan: ~/hq/clave/plans/2026-04-30-multi-account-sprint.md Architecture: ~/.claude/plans/doesnt-each-account-have-dreamy-journal.md Security audit: ~/hq/clave/security-audits/2026-04-30-multi-account-pre-implementation.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ion (Task 2) Pure additive. One Keychain entry per account, keyed by `kSecAttrAccount = pubkeyHex`. `kSecAttrService` and accessibility class unchanged from the legacy single-account path. Legacy single-account methods remain — Task 8 migration reads the legacy entry once, copies to a pubkey-keyed entry, then deletes the legacy entry. Added (Shared/SharedKeychain.swift): - saveNsec(_ nsec: String, for pubkeyHex: String) throws - loadNsec(for pubkeyHex: String) -> String? - deleteNsec(for pubkeyHex: String) Added (Shared/SharedKeychain+Enumeration.swift, NEW): - listAllPubkeys() -> [String] SECURITY (audit 2026-04-30 finding A1): listAllPubkeys lives in a separate file included only in the main app target — NOT ClaveNSE. Compile-time enforcement prevents NSE compromise from enabling enumeration → exfiltration of all accounts' nsecs from a single push wake. NSE only ever needs loadNsec(for:) with the pubkey from the APNs payload. Verified A1 enforcement: - pbxproj: SharedKeychain+Enumeration.swift has ONE build file UUID (Clave main-app target only); shared files have TWO (both targets). - grep -r listAllPubkeys ClaveNSE/ → 0 matches. - strings ClaveNSE binary → 0 matches for "listAll". - find DerivedData → SharedKeychain+Enumeration.o exists only under Clave.build/, never ClaveNSE.build/. SECURITY (audit 2026-04-30 finding A4): test fixtures are GENERATED at setUp via SecRandomCopyBytes, never hardcoded. Avoids realistic- looking nsec strings that could cause copy-paste confusion in screenshots, logs, or stack traces. Tests: SharedKeychainMultiAccountTests (8 tests, all passing on iPhone 17 / iOS 26.4): - save/load roundtrip - two accounts coexist - isolated delete (one removed, other intact) - save-twice replaces - load-when-absent returns nil cleanly - listAllPubkeys returns both pubkey-keyed entries - listAllPubkeys excludes the legacy fixed-account entry - listAllPubkeys empty when no entries Plan: ~/hq/clave/plans/2026-04-30-multi-account-sprint.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds non-Optional `let signerPubkeyHex: String` to ActivityEntry, PendingRequest, ConnectedClient, ClientPermissions, and PairOp. Wire format tolerates legacy build-31 rows via `decodeIfPresent(forKey:) ?? ""` in `init(from:)`. Memberwise inits default the new parameter to `""` for source-compat with existing call sites — Tasks 4-5 thread the actual signer pubkey through SharedStorage writers and AppState. Promoted `PendingRequest`, `PairOp`, and `ClientPermissions` from synthesized to explicit `Codable` so the missing-key tolerance works (matches the existing pattern on ActivityEntry and ConnectedClient). ClientPermissions.id is now composite: `"signer:client"` so the same client paired with multiple signers produces distinct SwiftUI identities. Defensive fallback for the brief decode-before-migration window: if signerPubkeyHex is empty, id falls back to bare pubkey (pre-multi-account behavior). After Task 8 migration backfills, every row uses the composite form. Tests: MultiAccountRecordCodableTests (13 tests, all passing on iPhone 17 / iOS 26.4): - 5× legacy-row decode without signerPubkeyHex (assert empty string) - 5× new-row roundtrip with signerPubkeyHex - ActivityEntry preserves PR #19 fields (signedEventId, signedSummary, signedReferencedEventId) alongside the new field - ClientPermissions composite id distinct across signers - ClientPermissions id falls back to pubkey when signerPubkeyHex empty Full ClaveTests suite: 131 tests passing. Zero regressions in ActivitySummaryTests, LightSignerEnrichmentTests, LightSignerPeekMethodTests, LightSignerProcessRequestTests, LightSignerResponseRelayUrlTests, NostrConnectParserTests, ForegroundRelaySubscriptionTests, etc. Existing constructors continue to compile because the new parameter is defaulted. Plan: ~/hq/clave/plans/2026-04-30-multi-account-sprint.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…signer routing (Task 4) Largest Phase 1 task. New SharedStorage surface for per-signer data; LightSigner caller updates that prevent kind:3 lastContactSet cross-account corruption (PR #19 hazard); AppState + view caller updates to use scoped variants. SharedStorage.swift: - ADD filtered readers: getActivityLog(for:), getPendingRequests(for:), getConnectedClients(for:), getClientPermissions(forSigner:), getClientPermissions(signer:client:), getPendingPairOps(for:) (the array variant uses `forSigner:` label to disambiguate from the legacy single-arg `getClientPermissions(for: pubkey)` — same `String` type, different return type, Swift can't pick the right overload from `for:` alone). - ADD per-signer bunker secrets: getBunkerSecret(for:), rotateBunkerSecret(for:). Defense-in-depth legacy-seed branch on first read if bunkerSecretsKey is empty AND legacy bunkerSecretKey exists. - ADD per-signer last-contact-set: getLastContactSet(for:), saveLastContactSet(_:for:). CRITICAL CORRECTNESS FIX from PR #19 in multi-account context. - ADD per-signer register timestamps: getLastRegisterSucceededAt(for:), setLastRegisterSucceededAt(_:for:), getLastRegisterFailedAt(for:), setLastRegisterFailedAt(_:for:). - ADD scoped writers: removeClientPermissions(signer:client:), touchClient(pubkey:signer:), unpairAllClients(for:). All use composite-key matching to avoid clobbering rows across signers. - MODIFY saveClientPermissions(_) to match on (signerPubkeyHex, pubkey) composite. Without this, the same client paired with two signers would clobber on save. - DELETE dead code: getBunkerSecret() (no-arg), rotateBunkerSecret() (no-arg), getLastContactSet() (no-arg), saveLastContactSet(_) (no-arg), removeClientPermissions(for:), touchClient(pubkey:), unpairAllClients() (no-arg), isClientPaired(_), pairClient(_), unpairClient(_) (single-arg). - PRIVATIZE getPairedClients() — only called by internal migrateIfNeeded. LightSigner.swift: - Compute signerPubkey once at top of handleRequest from privateKey. - Update 3 SharedStorage call sites to use per-signer variants (getBunkerSecret, rotateBunkerSecret, touchClient). - Update extractSignedEventEnrichment signature with optional signerPubkey param (defaults to "" for test source-compat); thread to per-signer lastContactSet readers. THIS IS THE PR #19 CROSS-ACCOUNT CORRUPTION FIX. - Update logAndTrack signature with required signerPubkey param; stamps ActivityEntry.signerPubkeyHex correctly + scopes touchClient to the right (signer, client) row. 9 call sites updated. Clave/AppState.swift: - Update 5 bunker secret call sites + unpairAllClients call site to use per-signer variants (passing the existing signerPubkeyHex scalar; Task 5 will replace it with currentAccount.pubkeyHex). - deleteKey now scopes unpairAllClients(for:) to current signer (transitional — Task 5 rewrites the whole flow as deleteAccount(pubkey:)). View caller updates (drive-by since old API was deleted): - Clave/Views/Home/HomeView.swift:110 — removeClientPermissions(for:) → removeClientPermissions(signer: appState.signerPubkeyHex, client: client.pubkey). - Clave/Views/Home/ClientDetailView.swift:401 — same pattern. Tests: SharedStorageMultiAccountTests (13 tests, all passing on iPhone 17 / iOS 26.4): - 4× filtered readers (activityLog, pendingRequests, clientPermissions composite, legacy empty-signer not surfaced). - 4× scoped writers (saveClientPermissions composite match, touchClient scoped, removeClientPermissions scoped, unpairAllClients scoped — all verify cross-signer rows are NOT touched). - 2× bunker secrets (independent rotation, legacy-seed inheritance). - 2× lastContactSet (independent + clear-for-one-signer). - 1× per-signer register timestamps. Full ClaveTests suite: 144 tests passing (was 131; +13 new). Zero regressions in PR #19 enrichment tests, parser tests, foreground subscription tests, response-relay-url tests. Plan: ~/hq/clave/plans/2026-04-30-multi-account-sprint.md Security audit: ~/hq/clave/security-audits/2026-04-30-multi-account-pre-implementation.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…session (Task 5)
The big one. AppState becomes a multi-account-aware session manager:
an `accounts: [Account]` published list with `currentAccount: Account?`
as the UI scope. `signerPubkeyHex` and `profile` become derived
properties. New methods for account lifecycle.
State shape changes:
- ADD `accounts: [Account]` (published) — source of truth, persisted
to `accountsKey` UserDefaults.
- ADD `currentAccount: Account?` (published) — UI scope.
- DERIVE `signerPubkeyHex` from `currentAccount?.pubkeyHex ?? ""`
(was stored).
- DERIVE `profile` from `currentAccount?.profile` (was stored).
- DERIVE `isKeyImported` from `currentAccount != nil` (was stored).
- REMOVE `bunkerSecret` stored property — `bunkerURI` getter reads
SharedStorage directly each access; zero view callers depended on
the cache.
New account-lifecycle methods:
- `switchToAccount(pubkey:)` — UI scope swap, persists
currentSignerPubkeyHexKey + legacy signerPubkeyHexKey for
source-compat.
- `addAccount(nsec:petname:)` — parse nsec → derive pubkey →
saveNsec(_:for:) → append to accounts → switch → fire-and-forget
registerWithProxy + fetchProfileIfNeeded. Idempotent: re-adding the
same nsec just switches to the existing account, doesn't duplicate.
- `generateAccount(petname:)` — Keys.generate() then addAccount().
- `deleteAccount(pubkey:)` — audit 2026-04-30 finding A2 ordering:
unpair-clients FIRST (still has nsec for NIP-98 signing) →
unregisterWithProxy → SharedKeychain.deleteNsec(for:) → bunker
rotation → records cleanup → accountsKey write → auto-switch or
clear current. Scoped per-signer; never touches other accounts.
- `renamePetname(for:to:)` — audit 2026-04-30 finding A3 sanitization:
trim whitespace, strip newlines, cap 64 chars. Empty after
sanitization → nil (not empty string).
Bootstrap + cleanup:
- `bootstrapFromLegacyKeychainIfNeeded()` — one-shot. If accountsKey
is empty AND legacy fixed-account Keychain entry exists: copy nsec
to new pubkey-keyed slot, delete legacy, seed accountsKey +
currentSignerPubkeyHexKey. Build-31 testers upgrade transparently.
- `cleanupOrphanLegacyKeychainEntry()` — every-launch defensive. If
accountsKey is populated AND legacy entry still exists, delete it.
Handles the rare race where bootstrap saved-but-failed-to-delete.
Idempotent.
Legacy method wrappers (preserves view caller signatures):
- `importKey(nsec:)` → `addAccount(nsec:)`
- `generateKey()` → `generateAccount()`
- `deleteKey()` → `deleteAccount(pubkey: currentAccount?.pubkeyHex)`
Caller updates throughout AppState — every `SharedKeychain.loadNsec()`
(legacy fixed-account) is now `loadNsec(for: signerPubkeyHex)` or
`loadNsec(for: op.signerPubkeyHex)`. Sites updated:
- approvePendingRequest — uses request.signerPubkeyHex (Task 3 field)
with currentAccount fallback.
- handleNostrConnect — current account.
- registerWithProxy — current account; per-signer register timestamps.
- unregisterWithProxy — current account.
- pairClientWithProxy / unpairClientWithProxy — current account.
- retryPairOp / retryUnpairOp — op.signerPubkeyHex (Task 3 field) with
currentAccount fallback.
- ensureRegisteredFresh — per-signer throttle/cooldown via
SharedStorage.{get,set}LastRegister{Succeeded,Failed}At(for:).
The only remaining `SharedKeychain.loadNsec()` (no-arg legacy)
callers are the bootstrap + the orphan cleanup itself — both require
the legacy reader by definition.
Tests: AppStateMultiAccountTests (15 tests, all passing on iPhone 17
/ iOS 26.4):
- 2× derived state (signerPubkeyHex, profile from currentAccount)
- 2× addAccount (append + duplicate-nsec switches to existing)
- 2× switchToAccount (changes + unknown-pubkey no-op)
- 3× deleteAccount (Keychain + records cleanup, doesn't touch others;
current → auto-switch; last → clears)
- 3× renamePetname (persists; sanitization audit A3; empty input →
nil)
- 2× bootstrap (legacy entry → new format; fresh install no-op)
- 1× orphan cleanup idempotency
Full ClaveTests suite: 159 tests passing (was 144; +15 new). Zero
regressions in any prior test (Task 4 storage, Task 3 record codable,
PR #19 enrichment, V2 response-relay-url, foreground subscription,
parser, etc.).
Plan: ~/hq/clave/plans/2026-04-30-multi-account-sprint.md
Security audit: ~/hq/clave/security-audits/2026-04-30-multi-account-pre-implementation.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…sk 6) Closes the multi-account loop: NSE wakes for any account by reading the APNs payload's `signer_pubkey` (Stage A proxy field) and loading the matching Keychain entry. Falls back to currentSignerPubkeyHexKey for the transient case where the proxy hasn't shipped Stage A yet. After this commit, ZERO production code calls the legacy `SharedKeychain.loadNsec()` (no-arg) except the bootstrap + cleanup-orphan paths in AppState — both by design. Shared/SharedKeychain.swift: - ADD `static func resolveSignerPubkey(userInfo:) -> String` — payload-first, currentSignerPubkeyHexKey fallback. Lives in Shared/ so both NSE and main-app callers use the same implementation. ClaveNSE/NotificationService.swift: - Replace `SharedKeychain.loadNsec()` with `SharedKeychain.loadNsec(for: SharedKeychain.resolveSignerPubkey(...))`. - Defense-in-depth: verify loaded nsec derives back to the routed pubkey; mismatch → silent error (catches Keychain integrity issues). Clave/ClaveApp.swift (handleForegroundSigningRequest): - Same pubkey-routing pattern. Same defense-in-depth pubkey-derive verification. Shared/ForegroundRelaySubscription.swift: - Line 92: read currentSignerPubkeyHexKey (was legacy signerPubkeyHexKey — still write-through-synchronized, but the new key is the canonical source). - Line 354 (processEvent dispatch): replace `SharedKeychain.loadNsec()` with `SharedKeychain.loadNsec(for: currentSignerPubkeyHexKey)`. Without this fix, L1 dispatch would fail post-bootstrap (Task 5 deletes the legacy Keychain entry; legacy loadNsec returns nil). Clave/Views/Settings/ExportKeySheet.swift: - ADD `static loadCurrentNsec()` helper that reads the current account via currentSignerPubkeyHexKey + `loadNsec(for: pubkey)`. All 3 sites (success path, no-auth-available path, biometric-disabled path) use the helper. Stage C will refactor the sheet to take a `pubkey:` parameter so AccountDetailView can export per-account. DELETE Shared/SignerService.swift: - Dead code (zero callers anywhere — verified via grep). Imported NostrSDK and paralleled LightSigner but was never invoked. Cleaning up rather than maintaining unused code. - pbxproj surgery: remove 4 entries (build file, file ref, group children, sources phase) — single build file UUID since it was main-app-target-only. Tests: NotificationServicePubkeyRoutingTests (5 tests, all passing on iPhone 17 / iOS 26.4): - payload field present → returns payload value (overrides current) - payload field empty → falls back to currentSignerPubkeyHex - payload field missing → falls back to currentSignerPubkeyHex - neither set → returns empty string (silent-drop trigger) - payload field wrong type → falls back rather than throws Full ClaveTests suite: 164 tests passing (was 159; +5 new). Zero regressions. Verified: only 2 production callers of legacy loadNsec() remain — both in AppState.swift (bootstrap line 203, cleanup line 262), both intentional. Plan: ~/hq/clave/plans/2026-04-30-multi-account-sprint.md Security audit: ~/hq/clave/security-audits/2026-04-30-multi-account-pre-implementation.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…sk 7) Two coupled changes that together make multi-account work end-to-end in the UI for build-31 testers post-upgrade: 1. Bootstrap backfill of legacy records (Task 5 oversight, fixed here) 2. 11 view caller sites migrated to scoped SharedStorage readers Why coupled: without (1), build-31 testers post-bootstrap have records with `signerPubkeyHex = ""` (Task 3 default for missing wire-format key). Filtered view reads (Task 4) skip empty-signer rows → all existing activity, pending requests, and client permissions become invisible after upgrade. With (1), every record gets stamped with the bootstrapped pubkey, and (2)'s filtered reads return them correctly. Bootstrap backfill (Clave/AppState.swift): - ADD `backfillSignerPubkeyHex(for: pubkeyHex)` private helper. Loops over every persisted record array (ActivityEntry, PendingRequest, ConnectedClient, ClientPermissions, PairOp), stamps empty signers with the bootstrapped pubkey, writes back only when changes occurred (avoids spurious UserDefaults churn). - Called from `bootstrapFromLegacyKeychainIfNeeded` after the Account record is persisted. Idempotent — rows with non-empty signerPubkeyHex are skipped. View caller updates (11 sites across 8 files). Each uses the cleanest pubkey source for its context: - HomeView.swift:312 — getActivityLog(for: appState.signerPubkeyHex) (has @Environment AppState). - ApprovalSheet.swift:247 — getConnectedClients(for: currentSigner) read from currentSignerPubkeyHexKey UserDefaults. Pairing cap is now per-account: each account independently maintains 5 pairings. - PendingApprovalsView.swift:115 — getClientPermissions(signer: request.signerPubkeyHex, client: ...) using request's signer (Task 3 field), with appState fallback for legacy entries. - ConnectionInfoSheet.swift:13 — getConnectedClients(for: perms.signerPubkeyHex). Sheet receives ClientPermissions which carries the signer. - ActivityView.swift:126 — getActivityLog(for: currentSigner) read from UserDefaults. Scope decision: activity log is always per-account (no merged toggle). - ActivityDetailView.swift:141 — entry.signerPubkeyHex with current account fallback for legacy entries. - ActivityDetailView.swift:186 — same pattern, uses composite getClientPermissions(signer:client:). - ClientDetailView.swift:107, 374, 422 — appState.signerPubkeyHex (has @Environment AppState). - ActivityRowView.swift:91 — entry.signerPubkeyHex with current account fallback. Row views often render outside an AppState context, so reading from entry + UserDefaults is more robust than threading appState through. Pubkey-source selection rationale for views without AppState binding: prefer the most-specific source (entry.signerPubkeyHex > perms.signerPubkeyHex > UserDefaults currentSignerPubkeyHexKey). For legacy entries with empty signer, fall back to current. After Task 7 backfill, no records have empty signers, so the fallback is dormant in practice. Tests: AppStateMultiAccountTests gains testBootstrap_backfillsSignerPubkeyHexOnLegacyRecords (1 test). Verifies the full bootstrap → backfill → filtered-reader flow: plant legacy records with empty signers, run bootstrap, assert all records get stamped with the bootstrapped pubkey, assert filtered readers return them. Full ClaveTests suite: 165 tests passing (was 164; +1 new). Zero regressions in any prior task's tests. After Task 7, no view in the codebase calls unfiltered SharedStorage.getX() or single-arg by-client variants — every read is scoped to a specific signer. Phase-1 single-account user sees identical behavior; Phase-2 multi-account user (when Stage C UI ships) sees correctly per-account-scoped data. Plan: ~/hq/clave/plans/2026-04-30-multi-account-sprint.md Security audit: ~/hq/clave/security-audits/2026-04-30-multi-account-pre-implementation.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final Phase 1 task. Closes the migration story by handling the
remaining legacy UserDefaults keys, adding reinstall-recovery for the
UserDefaults-wipe case, and per-pubkey profile image cache filenames.
Bootstrap extensions (Clave/AppState.swift):
- ADD migrateLegacyUserDefaultsKeys(to: pubkey) — moves legacy
global-scoped UserDefaults values into per-signer dicts and deletes
the legacy keys:
* cachedProfileKey → Account.profile (via legacyCachedProfile())
* bunkerSecretKey → bunkerSecretsKey[pubkey] (via getBunkerSecret(for:))
* lastContactSetKey → lastContactSetsKey[pubkey] (preserves kind:3
follow-diff continuity — losing this would break the next
kind:3 sign's "Followed @alice" diff summary)
* lastRegisterSucceededAtKey → lastRegisterTimes[pubkey][succeeded]
* lastRegisterFailedAtKey → lastRegisterTimes[pubkey][failed]
- ADD migrateLegacyProfileImageFile(to: pubkey) — renames
profile_image.jpg → cached-profile-<pubkey>.dat. If the per-pubkey
file already exists (rare race), drops the legacy file rather than
overwriting.
- bootstrapFromLegacyKeychainIfNeeded calls both migration helpers
after persisting the Account record.
Reinstall recovery:
- ADD recoverAccountsFromKeychainIfNeeded() — runs in loadState after
bootstrap and loadAccounts. If accountsKey is still empty AND
Keychain.listAllPubkeys() returns ≥1 entry, reconstructs minimum
Account records (no petname, profile = nil — refetches on next
foreground). Covers the "iOS Storage settings wiped UserDefaults
but Keychain persisted" case.
Cross-version cleanup:
- ADD migrateRemainingLegacyKeysIfNeeded() — runs every launch when
accountsKey is populated. Idempotent: re-runs the migration helpers
for any legacy keys that linger (catches users who upgraded to a
Task 5/7 build before Task 8 shipped).
Per-pubkey profile image cache:
- cachedImageURL becomes a function taking pubkey, defaulted to a var
for source-compat. Filename: cached-profile-<pubkey>.dat (was
global profile_image.jpg). Migration handled by
migrateLegacyProfileImageFile.
loadState pipeline (final shape):
1. bootstrapFromLegacyKeychainIfNeeded — build-31 → multi-account
2. loadAccounts — hydrate from accountsKey
3. recoverAccountsFromKeychainIfNeeded — UserDefaults-wipe case
4. cleanupOrphanLegacyKeychainEntry — defensive sweep
5. migrateRemainingLegacyKeysIfNeeded — cross-version cleanup
6. loadCachedProfileImage — populate profileImage from disk
Tests: AppStateMultiAccountTests gains 7 new tests (23 total, all
passing on iPhone 17 / iOS 26.4):
- testBootstrap_migratesLegacyCachedProfile_intoAccountProfile
- testBootstrap_migratesLegacyBunkerSecret_intoPerSignerDict
- testBootstrap_migratesLegacyLastContactSet_intoPerSignerDict
- testBootstrap_migratesLegacyRegisterTimestamps
- testReinstallRecovery_seedsAccountsFromKeychain
- testReinstallRecovery_doesNotRunWhenAccountsKeyAlreadyPopulated
- testMigrateRemainingLegacyKeys_idempotentlyCleansUpAfterPriorBootstrap
Full ClaveTests suite: 172 tests passing (was 165; +7 new). Zero
regressions.
Phase 1 (Stage B iOS) is COMPLETE end-to-end:
✅ Per-pubkey Keychain (Task 2)
✅ signerPubkeyHex on every record (Task 3)
✅ SharedStorage scoped variants + LightSigner per-signer routing (Task 4)
✅ AppState multi-account refactor (Task 5)
✅ NSE + foreground signing path pubkey-routing (Task 6)
✅ View callers scoped + record backfill (Task 7)
✅ Migration polish + reinstall recovery (Task 8 — this commit)
Plus Stage A (proxy `signer_pubkey` payload field) is committed on
feat/multi-account-proxy (Dell-side, b045d26) and deployed to
proxy-test.clave.casa.
Phase 1 is ready for end-to-end testing against the test proxy via
internal TestFlight.
Plan: ~/hq/clave/plans/2026-04-30-multi-account-sprint.md
Security audit: ~/hq/clave/security-audits/2026-04-30-multi-account-pre-implementation.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rness)
End-to-end testing harness for the multi-account sprint, gated behind
the existing 7-tap dev menu unlock. Lets a tester exercise account
lifecycle (add / switch / delete) on internal TestFlight without
waiting for the Stage C UI sprint, AND surfaces migration health at
a glance.
Three sections:
1. Current account inspector — pubkey, petname, profile name, addedAt;
tap-to-copy pubkey hex.
2. All accounts list — per-row Switch / Delete buttons. Delete uses
the canonical "If you didn't save your private key..." alert, same
string Stage C will use. Exercises real `appState.switchToAccount`
and `appState.deleteAccount` code paths.
3. Test actions:
- "Generate test account" — creates a random keypair via
`appState.generateAccount(petname: "Test HH:mm:ss")`. Auto-
registers with the configured proxy. Same code path the Stage C
onboarding will use; bypasses the not-yet-built UI.
- "Refresh profile (current)" — fires fetchProfileIfNeeded.
- "Rotate bunker secret (current)" — fires rotateBunkerSecret.
4. Migration diagnostics:
- accountsKey state, currentSignerPubkeyHex, per-signer dict
counts (bunkerSecrets, lastContactSets, lastRegisterTimes).
- Legacy-key presence detector (red text if PRESENT) for all the
keys Task 8 migration should have cleaned: bunkerSecretKey,
lastContactSetKey, lastRegisterSucceededAtKey,
lastRegisterFailedAtKey, cachedProfileKey, legacy Keychain
entry. Lets a tester verify migration ran cleanly without
parsing logs.
- Keychain pubkey count via SharedKeychain.listAllPubkeys().
5. L1 binding inspector — shows which pubkey L1 is currently bound
to + state. Includes a note that L1 in v1 doesn't auto-restart
on account switch (deferred to Stage C polish).
Wired into Settings → Developer (after the 7-tap unlock) alongside
"L1 Diagnostics". No pbxproj surgery needed — Clave/ is a
PBXFileSystemSynchronizedRootGroup that auto-discovers new files.
Stage C will replace this with a real account picker UX. This view
stays as a dev tool for ongoing diagnostics + cross-version
migration verification.
Tests: full ClaveTests suite (172 tests) still passes; this view is
a UI wrapper around methods already covered by
AppStateMultiAccountTests.
Plan: ~/hq/clave/plans/2026-04-30-multi-account-sprint.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
For internal-TestFlight build of feat/multi-account. Two changes: 1. pbxproj CURRENT_PROJECT_VERSION 31 → 32 across all 8 build configs (Clave + ClaveNSE × Debug/Release/Profile/Test). 2. SharedConstants.defaultProxyURL flipped from proxy.clave.casa → proxy-test.clave.casa. The test proxy on Dell already has Stage A (`signer_pubkey` payload field) deployed; production proxy does NOT yet (gated on Stage A PR #22 merging to main and being deployed).⚠️ This commit is TEMP. The defaultProxyURL flip MUST be reverted before merging feat/multi-account to main — at that point Stage A should already be live on production proxy, and external TestFlight builds need to point at the production URL. Suggested last-commit-on-branch: revert: flip defaultProxyURL back to production Reverts the test-proxy redirect. Stage A is now live on the production proxy; merging to main means external builds. Build: 32 (internal TestFlight only — do not promote to external without first reverting the proxy URL flip + verifying production proxy has Stage A deployed) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ive cleanup Build 32 had a regression where Task 4/5/7's caller updates didn't audit every constructor call site. Task 3 added `signerPubkeyHex` with a default of `""`; subsequent tasks updated readers but missed several constructors. New rows post-bootstrap got created with empty signer, then Task 7's filtered view reads (which skip empty-signer rows) made them invisible. Symptoms (build 32 internal TF): - "Client not found" when tapping a newly-paired client in HomeView - Empty Connected Clients section despite a successful pair + sign - New PendingRequest rows queued under empty signer (won't surface in the merged pending view's per-account grouping) 8 construction sites fixed: LightSigner.swift: - ClientPermissions for new bunker pair (line ~157) — now passes `signerPubkeyHex: signerPubkey` (derived from privateKey at top of handleRequest in Task 6) - PendingRequest queued for protected kinds (line ~250) — same SharedStorage.swift: - updateClient now requires `signer:` parameter; ConnectedClient construction inside uses it - setClientRelayUrls now requires `signer:` parameter; ConnectedClient construction inside uses it - Both functions match-on-replace via composite (signer, client) key so the same client paired with multiple accounts produces distinct rows AppState.swift: - handleNostrConnect's two ActivityEntry constructions (error path + connect-published path) — now pass `signerPubkeyHex: signerPubkey` - pairClientWithProxy / unpairClientWithProxy: PairOp constructions capture signer at function entry (`let capturedSigner = signerPubkeyHex`) so the URLSession failure closure enqueues under the correct account even if the user switched accounts during the in-flight request - setClientRelayUrls caller updated to pass signer ApprovalSheet.swift: - ClientPermissions for new nostrconnect pair — now passes `signerPubkeyHex: currentSigner` (already read from currentSignerPubkeyHexKey for the cap-check; reused here) Retroactive cleanup for build-32 testers: - ADD `cleanupEmptySignerRowsIfSafe()` — runs in loadState() every launch, stamps empty-signer rows with the current account's pubkey ONLY when accounts.count == 1. Multi-account testers (small set who saw build 32) need to manually re-pair affected clients. - Reuses Task 8's `backfillSignerPubkeyHex` (idempotent — already- stamped rows are skipped). Build: 33 (was 32). Internal TestFlight only — `defaultProxyURL` still flipped to proxy-test.clave.casa. Tests: full ClaveTests suite (172 tests) still passes. Construction- site fixes are mostly mechanical parameter additions; existing record-codable tests + storage-multi-account tests cover the roundtrip behavior already. Manual verification on real device (build 33 install over build 32): - Open dev menu → Multi-Account → confirm any pre-existing empty- signer rows now show the current account's pubkey (cleanup ran) - Re-pair Nostur (or whichever client showed "Client not found") → confirm new row has correct signer (ClientDetailView opens cleanly, HomeView Connected Clients section populates) Plan: ~/hq/clave/plans/2026-04-30-multi-account-sprint.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
HomeView's `refreshData` was calling the legacy zero-arg `SharedStorage.getClientPermissions()` which returns clients across ALL accounts, while `loadPermissions()` in ClientDetailView (line 110) used the signer-scoped `getClientPermissions(signer:client:)`. Mismatch caused "Client Not Found" when tapping a client paired with a non-current account from HomeView's connected-clients list. Surfaced during build 33 multi-account smoke test on real device. The adjacent comment already says "scope to the current account" — the code just wasn't updated to match. Task 7 oversight. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 / Stage B previously left `registerWithProxy()` registering only the current account's (deviceToken, signerPubkey) mapping with the proxy. Result: APNs push routing worked for the current account but silently failed for any other account — proxy received their kind:24133 events and dropped them with "no registered tokens, skipping". Surfaced during build 33 multi-account smoke test on real device. Refactor: - Extract per-signer body to private `registerSignerWithProxy(signer:)` - Keep `registerWithProxy()` as a thin wrapper for current-account callers (Settings manual button, Onboarding, addAccount post-switch) - Add `registerAllAccountsWithProxy()` that iterates accounts list and registers each pubkey independently - Add `ensureAllRegisteredFresh()` for scene-active throttled fan-out; per-signer cooldown means one account's recent failure does not block another's retry Caller updates: - AppState init device-token observer → registerAll - AppState.loadState belt-and-suspenders → registerAll - MainTabView scenePhase .active → ensureAllRegisteredFresh - addAccount stays single (just registers the new current account) - SettingsView Register button stays single (manual current-only intent) - OnboardingView stays single (single onboarding flow) Tests: existing 172-test suite passes. No new tests added — refactor is mechanical and the per-account loop is exercised end-to-end on device. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Build 33 (commit 788c495) was archived for internal-TF and burned the TestFlight build number. Bumping to 34 so we can re-archive with the HomeView scoping fix (0e2a0de) + registerAllAccounts refactor (14c7489). Internal-TF only — defaultProxyURL still points at proxy-test.clave.casa (commit 11ad90f). URL revert + another bump will be required before any external promotion, after PR #22 ships to production proxy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…t current deleteAccount(B) called while current=A previously hit two hardcoded-current bugs: - unregisterWithProxy() signed /unregister with A's nsec → proxy removed (token, A) instead of (token, B). The deleted account's mapping stayed registered as an orphan; APNs kept pushing for B's pubkey to a device that no longer had B's nsec to sign with → silent NSE drops. - unpairClientWithProxy(clientPubkey: X) signed /unpair-client with A's nsec → proxy looked up (signer=A, client=X) which didn't exist → returned 200 "no pair found" → real (signer=B, client=X) row stayed on proxy as an orphan, leaking secondary-relay subscriptions until 410-pruned. This also explains the unpair-from-HomeView UX bug (compounds with Bug C view-scoping miss): tapping unpair on a non-current account's client row silently failed at the proxy because of the same signer mismatch. Fix: both functions accept an optional `signer:` parameter, defaulting to current. deleteAccount passes the to-be-deleted pubkey explicitly. The nsec is still in Keychain at call-time per audit A2 ordering (Keychain delete happens at step 3, after these step-1/step-2 network calls). Other callers (HomeView swipe-unpair, ClientDetailView unpair button) keep the default — they're explicitly current-account UI flows, and with Bug C's view-scoping fix they only show current's clients, so current is correct. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…onnection) Adopting clearer terminology going forward: - account pubkey = user identity (Account.pubkeyHex / signerPubkeyHex) - connection pubkey = paired client's ephemeral wire keypair (ClientPermissions.pubkey) Three sites in LightSigner.handleRequest used the legacy unscoped `getClientPermissions(for: senderPubkey)` which scans rows across all accounts and returns the first match by connection pubkey alone. Effects: - Line 134 (bunker connect with valid secret): isExistingClient=true if the connection was paired with ANY account, so the create-row branch was skipped. Result: bunker-connecting a connection that was previously paired with account A to account B succeeded but never wrote a ClientPermissions(account=B, connection=K) row. HomeView's account-scoped client list missed the new pair, and per-connection activity lookups in ClientDetailView returned no matching entries. - Line 176 (reconnect without secret): same scope leak. A connection paired with account A could reconnect to account B without re-providing B's bunker secret. - Line 195 (non-connect method gate): same scope leak. A sign_event RPC with `p`-tag pointing at account B was permitted to sign with B's nsec even though only account A had approved this connection. This is a real authorization leak — discovered during build 34 multi-account testing when the user noticed activity entries appearing under accounts that hadn't explicitly paired the signing connection. Fix: all three call sites now use `getClientPermissions(signer: signerPubkeyHex, client: senderPubkey)` — strict (account, connection) consent. Side effect for existing testers: connections previously cross-pairing across accounts will require an explicit per-account re-pair via fresh bunker URI. That's correct security. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three related issues in the profile-image cache path: F1: cacheImage(from:) wrote to the no-arg `cachedImageURL` computed property, which resolves to `signerPubkeyHex` at write-time — i.e. the CURRENT account's path. fetchProfileIfNeeded captured a specific account pubkey when it started, but if the user switched accounts during the network fetch, the resulting image overwrote the new current's cache file with the old account's image. Visible as "test account's PFP suddenly disappeared / shows wrong avatar" after rapid account switching. F2: cacheImage call ran outside the "still on same account" guard. The guard at the MainActor.run block aborted the in-memory profile update correctly when the user switched mid-fetch, but the cacheImage call sat after the guard returned, so the file write happened unconditionally. F3: deleteAccount only removed the legacy global `profile_image.jpg`, never the per-account `cached-profile-<accountPubkey>.dat` file. Orphan files accumulated on disk for every deleted account. Fix: cacheImage takes an explicit `pubkey` parameter, writes to `cachedImageURL(for: pubkey)`, and only updates the in-memory image if the captured account is still current. deleteAccount removes the per-account cache file alongside the legacy file. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Build 34 shipped Bugs A/B/C/D fixes; build 35 adds: - Bug E: LightSigner permission gate scoped to (account, connection) - Bug F: PFP cache writes bound to fetch's account pubkey Internal-TF only (URL flip still active to test proxy).
Bug G: switchToAccount updated currentAccount and the persisted current- account-pubkey UserDefaults, but never refreshed `profileImage`. The property is loaded once at cold-launch via loadCachedProfileImage() — which uses the current account's path at that moment — and only updated afterward when cacheImage() runs after a successful kind:0 fetch. Effect: switching accounts updated the displayed npub/petname (those derive from currentAccount directly) but the PFP stayed bound to whichever account was current when the app cold-launched. Visible as "the wrong avatar persists across account switches". Build 35 actually made this more obvious because Bug F2 stopped the cross-account cache file clobbering — so each account's cache file is now correctly per-account on disk, but switchToAccount didn't reload the active image from the new account's cache. Fix: switchToAccount now clears profileImage and calls loadCachedProfileImage() (which reads from the new current's path) + fetchProfileIfNeeded() (1-hour cache cooldown — short-circuits if recent). addAccount also clears profileImage to avoid showing the stale previous- account avatar between the switch and the new account's first profile fetch landing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Quick interim affordance for testers: tapping the avatar/name/npub area on the Home identity bar opens a SwiftUI Menu listing all accounts. Tap an account → switchToAccount fires, the menu auto-dismisses. Current account is marked with a checkmark. A small chevron indicator appears next to the npub when 2+ accounts exist (hidden in single-account state to avoid clutter for build-31 users). Avoids the dev-menu round-trip when switching accounts during testing. The copy-npub button stays as a separate sibling so it still works without opening the menu. Stage C will replace this with the proper bottom-sheet account picker per the architecture plan (~/.claude/plans/doesnt-each-account-have-dreamy-journal.md), but the Menu is the smallest possible interim that keeps testing fluid without investing in the full Stage C UX upfront. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…Bug H) When the user switches account via the new identity-bar Menu (or any other path that mutates currentAccount), the npub label updated correctly because views read it directly from appState.currentAccount. But: - HomeView's `clients` and `activityLog` @State arrays are populated by refreshData(), which only fires on .onAppear, .onChange(scenePhase), .signingCompleted notification, and .sheet onDismiss. Account switch wasn't a refresh trigger. - ActivityView's `entries` array, same shape — refreshData reads currentSignerPubkeyHexKey but only on the same trigger set. Effect: switching accounts changed the npub but the Connected Clients list and Activity entries stayed bound to the previously-selected account. User had to navigate away and back for the lists to refresh. Fix: add `.onChange(of: appState.currentAccount?.pubkeyHex) { _, _ in refreshData() }` to both views. ActivityView also gets an `@Environment(AppState.self)` declaration since it didn't previously need it (refreshData reads UserDefaults directly). persistCurrentAccountPubkey() updates UserDefaults synchronously when currentAccount mutates, so by the time onChange fires the right account's pubkey is already in UserDefaults — refreshData sees the new value on the same tick. PendingApprovalsView and SettingsView don't need this fix: they read appState observable properties directly, which already trigger SwiftUI re-renders on switch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stripped-down Stage C scope locked via superpowers:brainstorming. Final design: C2 picker pattern (top avatar strip + slim text identity bar) with per-account hash-derived gradient theming. Visual mockups iterated through 4 versions in the brainstorm session; v4 approved (full-bleed gradient background, neutral chrome, theming pulled to slim-bar wash + active strip pill ring + active tab text only). Spec covers: picker behavior, gradient theming module (AccountTheme.swift), explicit account names in destructive copy, AddAccountSheet (replaces deferred OnboardingView .addAccount mode), AccountDetailView, Settings Accounts section, ApprovalSheet "Signing as" header, data flow refactor (extract fetchProfile(for: pubkey) helper), error handling, testing strategy, 8-commit implementation order. Out of scope (next sprint candidates documented inline): OnboardingView .addAccount mode parameterization, PendingApprovalsView per-account grouping, iOS notification body with account label, ConnectedClient row creation in bunker connect path, pending-approval badge per pill, user-customizable colors. Also: add .superpowers/ to .gitignore so brainstorm session scratch files (visual companion mockups) don't accidentally get committed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Discussed during spec review (2026-05-01). Letter-on-gradient stays the default for stripped-down Stage C. Robohash captured as a future enhancement in the deferred follow-ups list with its tradeoffs noted (network dependency, pubkey leak to robohash.org, cache mitigation, ~30 lines). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Companion to docs/superpowers/specs/2026-05-01-stage-c-multi-account-ux-design.md. 8 tasks with bite-sized steps, full Swift code blocks, exact file paths, build + test commands, commit messages. Task breakdown: 1. AccountTheme palette helper (TDD, 6 unit tests) 2. AccountStripView + SlimIdentityBar + HomeView gradient 3. AddAccountSheet + remove placeholder 4. AccountDetailView skeleton (banner + petname + delete) 5. AccountDetailView actions (profile / rotate / export / refresh) + AppState fetchProfile(for:) refactor 6. SettingsView Accounts section 7. ApprovalSheet SigningAsHeader + destructive-copy named accounts 8. pbxproj 37→38 + archive build 38 Self-review verifies: spec coverage (every approved element mapped to a task), zero placeholders, type consistency across tasks. Ready for either superpowers:subagent-driven-development (recommended, fresh context per task) or superpowers:executing-plans (batch with checkpoints). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… gradients Pure utility module: pubkey hex → SHA-256 → first 2 bytes → palette index. 12 curated gradient entries (start, end, accent), stable indices (never reorder). Empty/invalid hex falls back to palette[0] defensively. 6 unit tests cover: palette count, determinism, distribution, empty input, invalid input, case-insensitivity. All pass on iOS 17 simulator. Foundation for AccountStripView active pill ring, SlimIdentityBar wash, HomeView ambient gradient, AccountDetailView banner, ApprovalSheet SigningAsHeader.
… background Replaces the interim Menu identity bar (aa194a9) with the C2 picker: - AccountStripView: horizontal pills, active gets gradient ring, trailing + pill triggers AddAccountSheet, long-press pushes detail. Auto-hides when accounts.count == 1. - SlimIdentityBar: text-only @petname • npub• copy with 22% gradient wash. - HomeView: full-screen ambient gradient (35%→10% top-down) tied to active account's AccountTheme. NavigationStack path binding for programmatic detail push from long-press. AddAccountSheet sheet modifier with placeholder pending Task 3. Bug G + Bug H wiring already in place from build 36/37 — switching via strip triggers the existing refresh chain.
…edNpub argument + transition animation Three Important findings from code-quality review of 76740a8: I1: SlimIdentityBar.truncatedNpub(for:) accepted an Account argument but ignored it, reading appState.npub instead. The function only ever runs in the current-account branch of SlimIdentityBar's body, so the parameter was dead code that misled future callers about what the function does. Removed the parameter; truncatedNpub is now a computed property that honestly reflects "current account's truncated npub". I2: simultaneousGesture(TapGesture) + onLongPressGesture race — long-press on a non-active pill fired the long-press (set pendingDetailPubkey) AND the subsequent tap-up (called switchToAccount). Spec explicitly forbids the latter on long-press. Extracted accountPill to a child AccountPillView struct holding a local @State didLongPress flag; tap handler suppresses its action when a long-press just fired. AccountAvatarPlaceholder split out as a reusable helper struct. I4: HomeView's homeBackgroundGradient changed instantly on account switch, producing a visible color hard-cut. Added .animation(.easeInOut(duration: 0.3), value: appState.currentAccount?.pubkeyHex) keyed on the active pubkey so the transition only animates when the account actually changes. Build green; all 6 AccountThemeTests still pass.
…odal Reuses existing AppState.generateAccount / addAccount (idempotent). Two modes via segmented Picker: Generate (random keypair) and Paste (nsec). Optional petname field. Inline error on invalid nsec. Auto-dismiss + new account becomes active on success. Triggered from AccountStripView's + pill (Task 2). Will also be triggered by SettingsView Add Account row in Task 6.
…on C)
Identity-zone banner extends Home's per-account theme gradient
(56pt avatar, 18/22 padding per design-doc §5.5). Body Form sits on
Home's ambient gradient via .scrollContentBackground(.hidden) +
.listRowBackground(Color.clear) per row, and the same four-stop
LinearGradient Home uses (alphas 0.42/0.22/0.10/0.04 top→bottom).
Section headers switch from default ALL-CAPS small grey to
sentence-case .headline + .textCase(nil) (matches Home's
"Connected Clients" header).
Existing behavior preserved verbatim — Petname rename, Profile
display name, Security (rotate + export gated to current), Delete
with named alert + connection-count footer. New profile fields
(about/nip05/lud16/stat/clave.casa link) come in Tasks 5-7.
Adds .refreshable {} on Form for pull-to-refresh — calls
refreshProfileAsync(for:) (added in same commit).
Spec: docs/superpowers/specs/2026-05-03-account-detail-view-redesign-design.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…+ restore dismissal comment Two small fixes from code-quality review on commit 312653b: 1. refreshProfileAsync(for:) now guards against empty pubkey, matching the existing sibling refreshProfile(for:). Defensive — call site in AccountDetailView already guards, but the public AppState API should not allow an empty-pubkey REQ to fan out to relays. 2. Restored the inline comment in performDelete() explaining that dismissal happens via .onChange(of: account == nil) in body. The comment was silently dropped during the visual rewrite; restoring it helps future readers understand why performDelete doesn't call dismiss() directly. Also kept .foregroundStyle(.primary) on section headers (matches HomeView's "Connected Clients" header + design-system §3 functional-zone rule). The spec text said .white but that came from the dark mockup; .primary correctly adapts to light/dark mode and is the right SwiftUI behavior for a Form section header. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…d-clients stat Three new conditional kv-rows (Display name + NIP-05 + Lightning), each rendered only when the underlying CachedProfile field is non-empty. "N paired clients" stat row is always shown (SharedStorage.getConnectedClients count). Empty-state hint shows only when entire profile is empty (no displayName / nip05 / lud16) — never shown alongside actual content. Lightning value uses .system(.body, design: .monospaced) since lud16 addresses are technically formatted strings. About row + Edit-on-clave.casa row come in Tasks 6 + 7. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…unt in delete copy Two Task 5 review-fix refactors before Task 6 layers in another && clause: 1. Extracted profileIsEmpty computed property from the inline empty-state condition. The 4-clause OR-of-AND was already reading at the edge of precedence-clarity at 3 clauses; Task 6's about clause would push it over. Now Task 6 just adds the about clause to profileIsEmpty itself. 2. deleteAlertMessage now reuses the connectionCount property added in Task 5 instead of re-calling SharedStorage.getConnectedClients(...) independently. DRY + single source of truth + drops the redundant `guard let account` (connectionCount already guards internally). Plan doc updated to reflect the new Task 6 instruction (add about to profileIsEmpty rather than the inline condition). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
About renders as a stacked block (label above, body below) instead of kv-row treatment so multi-line bios don't break the section's vertical rhythm. Default lineLimit(2); tap anywhere on the block toggles to full. "Show more" / "Show less" affordance only renders when the text actually overflows two lines (heuristic: count > 80 chars; true text-measurement deferred to a future polish item). Light haptic on toggle. profileIsEmpty extended to include about so a profile with only about set still shows actual content rather than the placeholder. The aboutOverflowsCap heuristic lives near connectionCount + profileIsEmpty (same MARK group of profile-section helpers). Spec: docs/superpowers/specs/2026-05-03-account-detail-view-redesign-design.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… change Caught by code-quality review on 0a1a3e1: if a user expanded a long bio then the cached profile updates to a shorter bio (via pull-to-refresh or account switch), isAboutExpanded would stay true while the "Show less" toggle pill disappears (because aboutOverflowsCap goes false). The text renders identically since unlimited lineLimit on short text == 2-line cap on short text, but the state is semantically stale. Single .onChange modifier on account?.profile?.about resets expansion on every bio mutation. Co-located with the existing onChange(of: account == nil) dismissal handler. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Always-visible row in the Profile section opens https://clave.casa/edit#bunker=<URL-encoded-bunker-uri> via UIApplication.shared.open. Fragment never reaches a server; clave.casa parses client-side and either re-uses an existing pairing (matches signer pubkey in localStorage) or pairs fresh. clave.casa apex is now live (Cloudflare Pages, deployed 2026-05-03); AASA at /.well-known/apple-app-site-association validated. The /edit#bunker route is queued on the clave.casa BACKLOG for implementation; until it lands, the link reaches the apex landing page (or 404 if /edit isn't routed yet). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…fragment data Caught by code-quality review on c189162: .urlQueryAllowed (and also .urlFragmentAllowed) permit `&`, `=`, `?` per RFC 3986 sub-delims, but the bunker URI itself contains `?relay=...&secret=...` syntax. If clave.casa parses the fragment via URLSearchParams (`splitting on &` + `=`), the bunker value would be truncated at the first inner `&secret=` boundary. Switched to a JS encodeURIComponent-equivalent charset (alphanumerics + `-._~` unreserved per RFC 3986). The bunker URI is now treated as opaque data inside the fragment, recoverable via a single decodeURIComponent on clave.casa's side regardless of parsing approach. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the old icon-circle row (32pt theme.accent Circle + bold subheadline + caption + chevron — visually identical to a ConnectedClient row) with a HIG-standard inline action: 22pt tinted plus circle + accent-color medium-weight label + no chevron + no subtitle. Native iOS pattern (Mail "Add Mailbox", Settings "Add Account"). Cap pre-check in handlePairNewConnectionTap() unchanged. theme.accent threading preserved for per-account identity continuity. Spec: docs/superpowers/specs/2026-05-03-account-detail-view-redesign-design.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…onnect:// scheme squat Adds applinks:clave.casa entitlement and extends DeeplinkRouter to handle https://clave.casa/connect/?uri=<encoded-nostrconnect-uri>. Backstory: nostrconnect:// is a custom URL scheme. Primal also registers it, so dual-installed iPhones routed deeplinks to Primal instead of Clave (no user-facing override exists). Universal Links solve this — iOS verifies the AASA at the domain and routes the specific URL pattern exclusively to the registered app. Coordination: clave.casa apex went live 2026-05-03 (Cloudflare Pages). AASA at /.well-known/apple-app-site-association declares appIDs=["944AF56S27.dev.nostr.Clave"] with components scoped TIGHTLY to /connect/?uri=*. /edit, /, and any other path stays routed to Safari/clave.casa — verified by curl + the new testUniversalLink_wrongPath_routesToIgnore test. DeeplinkRouter extracted a routeNostrconnect helper to share the account-count branching between the nostrconnect:// case and the new https case. Five new tests cover single/multi-account routing, missing/malformed uri params, and AASA scoping (edit/landing reject). Implementation note: URL.path strips trailing slashes in Swift, so the path guard matches "/connect" (not "/connect/"). The AASA still declares /connect/ — iOS enforces the trailing slash at the OS level before delivering the URL to the app. Tests use a custom CharacterSet excluding ?&=# to correctly percent-encode the nostrconnect URI as a query parameter value (urlQueryAllowed leaves ? and & unencoded, which would fragment the outer URL's query string). Manual on-device verification deferred to user: install build with new entitlement on a device with both Clave + Primal, run `swcutil reset` to flush cached AASA, tap a https://clave.casa/connect/?uri=... link from Messages — should open Clave directly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…pty-uri test Two small test improvements from code-quality review on commit 1bac9a9: 1. Renamed testOtherScheme_routesToIgnore → testHTTPS_nonClaveCasaHost_routesToIgnore. The original name became misleading once Phase B added the `https` scheme branch. The test still passes for the right reason (host guard rejects example.com), but the framing was wrong; the new name + comment document that we now have an explicit https branch and the host guard is what stops external HTTPS URLs. 2. Added testUniversalLink_emptyURIValue_routesToIgnore covering the present-but-empty `?uri=` case. Distinct code path from testUniversalLink_missingURIParam (queryItems returns "" not nil for empty-value); the router's `!uriParam.isEmpty` guard catches this and the new test exercises that guard explicitly. Both new/renamed tests pass; full DeeplinkRouterTests suite green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Internal-only build pointing at proxy-test.clave.casa (URL revert to prod proxy + MARKETING_VERSION 0.1.0→0.2.0 deferred to a separate commit before the external archive). Includes the 8-task AccountDetailView redesign sprint + Phase B Universal Links wiring (applinks:clave.casa entitlement + DeeplinkRouter https case + 6 unit tests). Test proxy already has Stage A signer_pubkey routing (PR #22 deployed there only); prod proxy still pending PR #22 merge. Internal smoke on test proxy validates the full feat/multi-account stack independent of the prod-proxy switch. AASA cache caveat from clave.casa coordination: Apple CDN may still serve the old single-component AASA for ~few hours. Use trailing-slash form `https://clave.casa/connect/?uri=...` for Universal Links verification today — matches both old and new AASA. The no-trailing- slash form will work after CDN refresh (within ~6h). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
kind:0 has two distinct identity fields:
- display_name: long human-readable ("Alice Smith")
- name: short handle ("alice")
Pre-build-46 they were collapsed into a single `displayName ?? name` for
the displayLabel fallback. Build 46 surfaces both separately —
AccountDetailView's Profile section will gain a Username row showing
the `name` field (Task 2 of this sprint).
Updates the fetchProfile bail-out gate to treat displayName OR name as
"has identity" (was: displayName only after collapse). Resolves the
deferred revisit note from commit d486464.
Backward compat: pre-build-46 on-disk CachedProfile blobs decode
cleanly with `name` as nil. Same Codable-defaults-for-optionals pattern
that PR #19 used for ActivityEntry and that Task 1 of the AccountDetailView
sprint used for about/nip05/lud16.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User feedback on build 45: petname (user-editable account nickname) is confusing — the only editable field in an otherwise read-only screen where the rest of the profile is sourced from kind:0 and edits go through clave.casa. The "rename in Clave separately from your kind:0" model adds friction now that clave.casa is the editor. Changes: - Account.displayLabel chain drops petname: now displayName → name → prefix(8). The petname field stays nullable on the model for on-disk backward compat (existing accounts decode cleanly) but UI never reads or writes it as of build 46. - AccountDetailView Petname section deleted. - AccountDetailView Profile section gains a Username kv-row (between Display name and About) showing @<name> from kind:0's `name` field. Conditionally rendered like the other Profile rows. - profileIsEmpty extended to include the new name clause. - AddAccountSheet drops the petname input — sheet is now just generate/paste-nsec. - AccountModelTests updated: displayLabel tests assert the new chain AND that petname is ignored even when set. Mirrors clave.casa's existing displayLabel chain (per design-system parity rules); iOS catches up to web here. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User device-test feedback on build 45: the banner had the right size
but the squared-off full-bleed treatment felt out of place against the
rest of the polished surfaces. Solution: keep current sizing (56pt
avatar, .title3 name, 18/22 padding) but wrap in a rounded card with
the same chrome SlimIdentityBar uses on Home (cornerRadius + shadow).
Implementation:
- Banner moves from `Section { EmptyView() } header: { banner }`
full-bleed treatment to a regular Section with the banner as
content, listRowInsets adding 14h horizontal margin so it sits as
a card.
- bannerHeader drops the outer ZStack; uses
.background(LinearGradient) + .clipShape(RoundedRectangle 14) +
.shadow(theme.start.opacity(0.25), radius 8, x 0, y 1) — matches
SlimIdentityBar's chrome at design-doc §5.2 (radius 12 there;
banner uses 14 since it's a larger surface and 12 felt too tight
proportionally).
Visual continuity with Home identity-zone language. No content changes,
no behavioral changes — pure visual chrome refactor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User device-test feedback: tapping Edit on clave.casa with a fresh browser session (no existing pairing) creates a new bunker pair on the iOS side. Discussed adding a confirmation alert; landed on a lighter informational caption instead — keeps the common case (already paired → instant edit) frictionless while setting expectations for the fresh-pair case. Caption sits as a Section footer under the Profile section, picking up the existing footer styling pattern used by deleteSection (.caption + .secondary). Text: "Opens in Safari · pairs with clave.casa if needed" Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Internal-only build pointing at proxy-test.clave.casa. URL revert to prod proxy + MARKETING_VERSION 0.1.0→0.2.0 still deferred — both flip in the build 47 external rollout commit, after PR #22 ships to prod. What's new vs build 45: - CachedProfile.name extraction (48b4fa6) - petname dropped from UI; Username row added; displayLabel chain becomes displayName → name → prefix(8) (1a130bb) - AccountDetailView banner gets rounded corners + shadow matching SlimIdentityBar's chrome — same size, just no longer squared off (46989f3) - Caption "Opens in Safari · pairs with clave.casa if needed" under the Edit on clave.casa row (2db6fa9) Also surfaced + queued: ConnectedClient identity sprint (3 iOS-side gaps that prevent Clave.Casa name from showing in Connected Clients list). Documented in BACKLOG High Priority. Not blocking external — sprint targets v0.2.1 or v0.3.0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User reported on build 46 that AccountDetailView content felt shrunk compared to Home/Activity. Root cause: Form defaults to insetGrouped list style (~16pt extra horizontal margin); Home/Activity use List.plain (no extra margin). The banner Section had explicit 14pt listRowInsets so it sat at 14pt; the Profile/Security/Delete sections inherited Form's wider insetGrouped margin and looked narrower than the banner — visually inconsistent. Fix: swap Form → List + add .listStyle(.plain) to match HomeView's chrome. Section structure, listRowInsets values, listRowBackground modifiers, and all row content stay identical. The .textCase(nil) override on each section header keeps them rendering correctly in plain List style. Visual continuity with Home/Activity restored. Spec: docs/superpowers/specs/2026-05-03-account-detail-view-redesign-design.md Design system: docs/design-system.md §5 (List/Form style choice) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… single-account
User device-test feedback on build 46: when down to one account on
Home, the strip auto-hid (accounts.count > 1 gate) — which made the
"+" pill invisible too. Users frequently want to add a 2nd account
from Home (not Settings → Accounts → Add), so the "+" affordance
value outweighs the original "no UI noise at single-account" rationale
from the Stage C design.
One-line fix: drop the `if appState.accounts.count > 1` wrapper so
the strip always renders. At zero accounts ContentView routes to
OnboardingView before this view mounts (verified at
ContentView.swift:8 — `if appState.isKeyImported { MainTabView }
else { OnboardingView }`), so the empty-strip case never occurs in
practice.
Visual at single-account: 1 active pill + "+" pill. SlimIdentityBar
below still renders for npub display + copy. Slight redundancy with
the 28pt mini avatar in the slim banner, but the "+" affordance is
worth it.
Docstring updated to reflect the new behavior + reason for the change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…internal smoke Internal-only build for smoking the two device-test fixes from build 46: - ef1e250: AccountStripView always renders (no auto-hide at single-account) so the "+" pill stays accessible from Home for adding new accounts - 98e441c: AccountDetailView swap Form→List + .listStyle(.plain) to match Home/Activity (kills the insetGrouped extra horizontal margin) URL stays at proxy-test.clave.casa, MARKETING_VERSION stays 0.1.0. External promotion + URL revert + MARKETING_VERSION bump deferred to a later commit pending Dell→VPS prod proxy migration plan + PR #22 prod merge timing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User device-test feedback on build 47 internal: swipe-delete an account
from Settings → Accounts list, tap Delete in the confirmation alert,
sometimes nothing happens. No error, no haptic mismatch, just no
deletion. Intermittent.
Root cause: the alert's Delete button action did
`if let account = accountToDelete { ... }` — re-reading the @State
optional at tap-time. When `appState.accounts` mutated during the
alert window (very plausible: profile fetch landing, L1 RPC wake,
pull-to-refresh fire, any of the refresh-trigger paths), the Section
re-evaluated. SwiftUI's Binding-backed `.alert(isPresented:)` can
fire its setter `set(false)` during certain re-render scenarios — a
documented SwiftUI quirk — clearing `accountToDelete` to nil
*before* the user actually tapped Delete. Alert remained visible
(dismissal animation hid the state mismatch), user tapped Delete,
`if let` failed, silent skip.
Fix: switch to the iOS 15+ .alert(_:isPresented:presenting:actions:
message:) overload. The `presenting:` parameter captures the
optional's value at present-time and passes the unwrapped Account
into the action and message closures by-value. The captured
`account` survives even if `accountToDelete` is later nil'd by the
binding setter — race eliminated.
Same root-cause pattern as the swipeActions migration in the V2
proxy sprint (`.onDelete` → `.swipeActions` for unpair UI). Both
are state-vs-gesture race fixes; this one's at the alert layer
instead of the gesture layer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Build 47 was archived to ASC; TestFlight burns build numbers permanently. Build 48 includes the swipe-delete account race fix (1729ada) that wasn't in build 47. URL stays proxy-test.clave.casa, MARKETING_VERSION stays 0.1.0 — still internal-only smoke. External rollout deferred pending PR #22 prod merge / Dell→VPS prod migration timing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…udit-5)
User reported clave.casa staying on a stale edit page for ~45s after a
server-side unpair instead of recovering on the explicit
"Invalid or missing bunker secret" error iOS already sends in
LightSigner.handleConnect:197-211 (and "Client not paired..." at :222-243
for non-connect methods).
Diagnosis: the error responses were malformed per NIP-46 spec —
`{id, error}` instead of `{id, result, error}`. nostr-tools' BunkerSigner
(used by clave.casa) is permissive in places (accepts Clave's spec-
divergent `result: <secret>` connect response) but evidently not for
missing-result-on-error — the response is silently ignored, client falls
through to its 45s timeout, no useful UX.
Fix: both error-response construction sites now include `result: ""`:
- handleRequest main response path (Shared/LightSigner.swift:308) — uses
`responseResult ?? ""` so success keeps current behavior + error gets
the empty result the spec requires
- sendErrorResponse helper (Shared/LightSigner.swift:639) — was the
dedicated unpaired/invalid-secret path; same `result: ""` addition
Closes audit-5 from `~/hq/clave/security-audits/2026-04-17-pre-external-
testflight.md` (BACKLOG line 19). Symptom matches the audit's
"Strict parsers may reject" note exactly.
The clave-casa agent's earlier "iOS stays silent on unknown secrets"
diagnosis was wrong — iOS already sends both error paths. Real bug was
spec-noncompliance on response shape. ~3 lines of behavioral change
(Audit-5 was estimated at "~1 line"; the actual fix needed both sites).
Tests: full suite passes. No assertions on error-response JSON shape
to update.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Build 48 was archived to ASC for the swipe-delete race fix; TestFlight burns build numbers permanently. Build 49 includes the audit-5 spec- compliance fix (635bfac) for NIP-46 error responses — should resolve clave.casa's 45s timeout on stale-session recovery. URL stays proxy-test.clave.casa, MARKETING_VERSION stays 0.1.0 — still internal-only smoke. External rollout deferred pending PR #22 prod merge / Dell→VPS prod migration timing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User edits kind:0 metadata via clave.casa but Clave iOS keeps showing the old name/displayName/about even after AccountDetailView pull-to- refresh. Bypass throttle works correctly (refreshable -> refreshProfileAsync -> fetchProfile, no 1-hour gate); the bug was in the cross-relay merge. Root cause: AppState.fetchProfile(for:) violated NIP-01 replaceable-event semantics. The variable was named "newest" but the loop only updated it when the current entry had no picture — effectively "first relay response with a picture wins, regardless of created_at." The TODO comment at the old call site already flagged this. Trace with the test account's actual relay state today: - damus.io / nos.lol / primal.net -> 2026-05-03 21:52 (newest) - relay.powr.build -> 2026-05-02 04:06 (1 day stale) - purplepag.es -> 2026-04-25 03:16 (8 days stale) - nostr.wine -> no event relay.powr.build is the user's primary low-latency relay so it almost always responds first. Its 2026-05-02 kind:0 has a picture, so it won the race against the 2026-05-03 events on damus/nos.lol/primal. Fix: extract the merge into a pure static `mergeKind0(_:)` that picks max(created_at), and have the static fetcher return a `FetchedKind0` wrapper (profile + event created_at) instead of bare CachedProfile so the merge can compare timestamps. CachedProfile itself is unchanged (it's the persisted form; event time is a transient relay-merge concern). The per-relay hasIdentity || hasPicture gate remains as the empty-profile filter. Tests: 5 new unit tests in AppStateProfileMergeTests covering max- selection, the regression case (both events have a picture), nil inputs, partial-nil inputs, and order-independence. Full ClaveTests suite (incl. these 5) passes on iPhone 17 / iOS 26.4 simulator. Out of scope (deferred to a separate PR if needed): NIP-65 outbox lookup to derive user's actual write relays. The merge fix is required regardless of relay-set composition — outbox would help users who have a NIP-65 published, but the test account in this report has none so the existing 5-relay hardcoded set is what would be queried either way. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Build 49 was archived to ASC for the audit-5 NIP-46 result-field fix; TestFlight burns build numbers permanently. Build 50 includes the kind:0 cross-relay merge fix (ef3821c) for "edit profile on clave.casa but Clave iOS keeps showing old fields after pull-to-refresh." URL stays proxy-test.clave.casa, MARKETING_VERSION stays 0.1.0 — still internal-only smoke. External rollout deferred pending PR #22 prod merge / Dell→VPS prod migration timing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Flip defaultProxyURL: proxy-test.clave.casa → proxy.clave.casa now that PR #22 (Stage A signer_pubkey payload) is on prod. - Bump pbxproj 50 → 51. - Bump MARKETING_VERSION 0.1.0 → 0.2.0 — first external build with multi-account + Universal Links. Build 50 internal-only stays as the pre-flip diagnostic baseline. 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 free
to 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
Stage B of the multi-account sprint — full iOS plumbing for one device holding N Nostr identities. End-to-end multi-account works at the data + signing layer; new UI is intentionally deferred to Stage C.
Pairs with proxy-side Stage A: #22.
What's in this PR
8 task commits + a dev menu testing harness:
Architecture
kSecAttrAccount = pubkeyHex(was fixed"signer-nsec"). Bootstrap migrates the legacy entry transparently. Same protection class (AfterFirstUnlockThisDeviceOnly).signerPubkeyHexon every persisted record —ActivityEntry,PendingRequest,ConnectedClient,ClientPermissions,PairOp. Records backfilled with the migrated pubkey on first launch.SharedStoragescoped variants —getActivityLog(for:),getClientPermissions(signer:client:), etc. Per-signer bunker secrets, last-contact-sets, register timestamps. Composite-key writers prevent cross-signer clobbering.LightSignerderives signer fromprivateKey— kind:3 lastContactSet now writes to the per-signer dict, preventing PR feat: enrich activity detail with what was signed + njump link #19's cross-account corruption hazard.AppState.accounts: [Account]+currentAccount: Account?— full lifecycle (add / switch / delete / rename) with audit-driven security (delete ordering, petname sanitization).signer_pubkeyfrom APNs payload (Stage A), falls back tocurrentSignerPubkeyHexKey. Defense-in-depth pubkey-derive verification.Test plan
AccountModelTests,SharedKeychainMultiAccountTests,MultiAccountRecordCodableTests,SharedStorageMultiAccountTests,AppStateMultiAccountTests,NotificationServicePubkeyRoutingTests)grep -c listAllPubkeys ClaveNSE/returns 0 (security audit A1)loadNsec()remain — bootstrap + cleanup, both intentionalShip gate
signer_pubkeypayload field, NSE falls back tocurrentSignerPubkeyHexKey(single-account-correct for build-31 migration but fails silently for the 2nd+ account on a 2-account device).For internal TestFlight verification:
proxy-test.clave.casa) already has Stage A deployed.Shared/SharedConstants.swiftdefaultProxyURLshould be flipped to the test proxy on this branch BEFORE archiving an internal TF build (one-line change; revert before merge to main).Plans + audit
~/hq/clave/plans/2026-04-30-multi-account-sprint.md~/.claude/plans/doesnt-each-account-have-dreamy-journal.md~/hq/clave/security-audits/2026-04-30-multi-account-pre-implementation.mdWhat's NOT in this PR (Stage C)
New multi-account UI — account picker bottom sheet, AccountDetailView, ApprovalSheet "Signing as" header, OnboardingView
.addAccountmode, SettingsView Accounts section, PendingApprovalsView merged grouping, iOS notification body with account label. Single-account UX behaves identically post-merge.🤖 Generated with Claude Code