fix(nip46): make forgetting a client complete and immediate

Clearing a connected client on logout had two gaps:

- The user-facing "Forget this app" button only revoked the permission ledger;
  it never cleared the NIP-46 client store, so a forgotten app's metadata and
  relays lingered and were re-recovered on the next restart. Route NIP-46
  coordinates through the host's new forgetClient() so the store is cleared too.
- Neither logout path stopped the RUNNING session from listening on the app's
  relays — only the next restart picked up the change. extraRelays is now a live
  projection of the client store (recomputed on connect, on start, and on
  disconnect via a new onDisconnected hook), so a forgotten app's relays are
  dropped immediately.

onLogout and the UI Forget now share one authorizer.forget() path (revoke grant
+ clear store + clear throttle entry + signal the host), so client-initiated and
user-initiated disconnects behave identically. Adds tests for both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
This commit is contained in:
Claude
2026-07-17 15:18:26 +00:00
parent cd3e1353e7
commit 922a5841d0
4 changed files with 93 additions and 18 deletions
@@ -77,6 +77,12 @@ class Nip46PermissionAuthorizer(
val onConnected: (suspend (clientPubKey: HexKey, request: BunkerRequestConnect) -> Unit)? = null,
/** Persisted client metadata/relays; cleared on logout so a disconnected app leaves nothing behind. */
val clientStore: Nip46ClientStore? = null,
/**
* Invoked after a client is fully forgotten ([onLogout]/[forget]) so the host can react in the
* running session — e.g. stop listening on relays that only that client used, instead of waiting
* for the next restart.
*/
val onDisconnected: (suspend (clientPubKey: HexKey) -> Unit)? = null,
) : Nip46RequestAuthorizer {
// A high-throughput client can authorize many signs per second; last-used is display-only,
// so coalesce the DataStore write to at most one per client per LAST_USED_THROTTLE_SECS
@@ -136,12 +142,20 @@ class Nip46PermissionAuthorizer(
return allowed
}
override suspend fun onLogout(clientPubKey: HexKey) {
// The client asked to disconnect — drop its standing grant (so it must pair again) and its
// persisted metadata/relays (so we stop listening on its relays after the next restart).
override suspend fun onLogout(clientPubKey: HexKey) = forget(clientPubKey)
/**
* Fully disconnects [clientPubKey], from either the client's `logout` request or the user's
* "Forget" action: drops its standing grant (so it must pair again), its persisted metadata/relays,
* and its in-memory throttle entry, then signals [onDisconnected] so the running session can stop
* listening on relays that only this client used.
*/
suspend fun forget(clientPubKey: HexKey) {
val coordinate = coordinateFor(clientPubKey)
ledger.revokeAll(coordinate)
clientStore?.remove(coordinate)
throttleLock.withLock { lastUsedThrottle.remove(coordinate) }
onDisconnected?.invoke(clientPubKey)
}
companion object {
@@ -135,4 +135,43 @@ class Nip46PermissionAuthorizerTest {
assertEquals(null, ledger.store.loadPolicy(coordinate))
}
@Test
fun forgetClearsGrantAndStoreAndSignalsDisconnect() =
runTest {
val ledger = ledger()
ledger.setPolicy(coordinate, AppSignerPolicy.FULL_TRUST)
val store = InMemoryNip46ClientStore()
store.store(coordinate, Nip46ClientInfo(name = "X", relays = setOf("wss://relay.example.com")))
var disconnected: String? = null
val authorizer =
Nip46PermissionAuthorizer(
ledger,
signerPubKey = signer,
validateSecret = { _, _ -> true },
clientStore = store,
onDisconnected = { disconnected = it },
)
authorizer.forget(client)
assertEquals(null, ledger.store.loadPolicy(coordinate), "grant cleared")
assertEquals(null, store.load(coordinate), "stored metadata + relays cleared")
assertEquals(client, disconnected, "host notified so it can drop the relays this session")
}
@Test
fun logoutIsEquivalentToForget() =
runTest {
val ledger = ledger()
ledger.setPolicy(coordinate, AppSignerPolicy.REASONABLE)
val store = InMemoryNip46ClientStore()
store.store(coordinate, Nip46ClientInfo(name = "X"))
val authorizer = Nip46PermissionAuthorizer(ledger, signerPubKey = signer, validateSecret = { _, _ -> true }, clientStore = store)
authorizer.onLogout(client)
assertEquals(null, ledger.store.loadPolicy(coordinate))
assertEquals(null, store.load(coordinate))
}
}