Commit Graph
17205 Commits
Author SHA1 Message Date
Claude 0734fdb8de fix(nip46): show the same identity in the connected-app list and detail
The dedicated NIP-46 apps list always rendered the client's npub, while the
detail header showed the app's self-declared website (url) when it had one — so
an app that advertised a website looked like a bare pubkey in the list but a
website once opened. Extract one nip46ClientSubtitle(url, clientPubKey) helper
(website host when declared, npub otherwise) and use it in both places so a row
and the screen it opens never disagree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:34 +00:00
Claude 052459567c feat(nip46): rename "Nostr Signer" to "Remote Signer"
Renames the user-facing title (drawer entry + screen top bar). Search keywords
already include "remote" and "nostr", so discoverability is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:34 +00:00
Claude 565342d7bf feat(nip46): drawer entry, dedicated apps screen, foreground surfacing, idle prune
Move the Nostr Signer out of Settings into the left drawer's "You" section,
directly under Wallet (and available as a bottom-bar favorite). Removed the
Settings catalog entry.

Give NIP-46 remote-signer clients their own management screen, separate from
the napplet/nsite/browser Connected Apps screen — unlike those, each NIP-46 app
can carry its own relays that the signer keeps subscribed in the background, so
they need distinct visibility (name, npub, relay count, last-used, trust level)
and pruning. The shared Connected Apps screen no longer lists NIP-46 apps.

Auto-forget apps idle for 7+ days on signer start (Nip46PermissionAuthorizer.
pruneIdle), so an app paired once and abandoned stops leaking a background relay
subscription forever. last-used is stamped on connect and every serviced op, so
an app still in use is never pruned.

Surface the consent dialog when Amethyst is backgrounded: a bare startActivity
from the app context is silently dropped by Android 12+ background-activity-launch
restrictions, so the dialog never appeared and the request timed out. Add a
full-screen-intent notification fallback (the same mechanism CallNotifier uses
for incoming calls) on a high-importance channel; it no-ops when the app is
already in the foreground so there's no redundant heads-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:34 +00:00
Claude 2a1634bb07 feat(nip46): declutter the signer screen — bigger QR, drop noise
- Enlarge the bunker QR to fill the card width (responsive, easier to scan)
  instead of a fixed 232dp.
- Drop the raw bunker:// URI text under the QR — the QR + Copy button convey
  the address; the long hex string was just noise.
- Remove the "Signing as npub1…" line — on your own signer settings screen the
  account is already implied (that clarity belongs on the consent dialogs,
  where it now lives).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:33 +00:00
Claude 6151a2df25 fix(nip46): move allPolicies disk enumeration off the main thread
allPolicies() enumerates the datastore directory and reads each file (blocking
disk IO) but is invoked from Compose LaunchedEffects on the main dispatcher,
tripping StrictMode's DiskReadViolation. Wrap the File listing + reads in
withContext(Dispatchers.IO). Fixes both the NIP-46 signer screen and the
Connected Apps screen callers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:32 +00:00
Claude 99f0c61f72 feat(nip46): preview the event as a signed note in the sign dialog
The per-op consent dialog now renders a sign_event/publish request as a real
NoteCompose preview — what the note will actually look like once signed
(text + media + mentions, authored by the signing account) — instead of only
a quoted content snippet. The "Show event" JSON toggle stays as a fallback for
anyone who wants the raw payload.

Reuses the live AccountViewModel via CallSessionBridge (the same handle
CallActivity uses to render app UI from a standalone Activity), builds a
transient unsigned Note with RumorAssembler.assembleRumor + createTempDraftNote
(never persisted/verified — the same path the composer uses to preview an
unsent post), and passes EmptyNav so taps don't navigate out. SignerConsentInfo
carries the EventTemplate; both the NIP-46 bridge and the napplet
buildSignerConsentInfo populate it for Publish/SignEvent.

Falls back to the content quote + JSON when the AccountViewModel isn't
available (main Activity gone) or the op has no event (encrypt/decrypt).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:32 +00:00
Claude 4fa41f1a67 feat(nip46): show which account is acting on the consent dialogs
Both signer dialogs now render the account's avatar + display name instead of
a raw pubkey / coordinate hex, so it's clear which logged-in identity is
approving, signing, encrypting, or decrypting:

- Connect dialog: replaces the client transport-pubkey line with the account
  being connected to (avatar + name).
- Per-op dialog: replaces the meaningless coordinate hex with the account that
  would sign/encrypt/decrypt.

The account is resolved from the coordinate's signer pubkey
(Nip46PermissionAuthorizer.signerPubKeyOf) via LocalCache, and rendered with a
shared ConnectedAccountRow (RobohashFallbackAsyncImage + name, robohash
fallback). SignerConnectInfo/SignerConsentInfo carry the account
name/picture/pubkey; the napplet/browser paths leave them null and keep their
existing domain line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:32 +00:00
Claude 00ec826e8b feat(nip46): refuse requests when the account can no longer sign
Reject sign/encrypt/decrypt when the identity signer is not writeable — the
account was logged out, is read-only, or lost its external (NIP-55) signer —
returning an `account unavailable` error instead of prompting the user or
hanging on a key that can't be used. Checked before authorization, so no
dialog is raised for a key we can't sign with. Public reads (get_public_key,
ping, get_relays) stay ungated.

Test: a non-writeable signer refuses a sign request without invoking the
signer or the authorizer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:32 +00:00
Claude 5fa8a0a2aa feat(nip46): persist serviced request ids so a restart never re-signs replays
The 30s window shrank the restart re-sign problem but couldn't close it: a
relay replays stored ephemeral requests on re-subscribe, and the in-memory
dedup set is wiped on restart, so anything within the window came back.

Persist the recently-serviced kind-24133 event ids (bounded to 128) and seed
the service's dedup set from them on start, so a replay after an app restart
is dropped by EXACT event id. Chosen over a created_at high-water mark on
purpose: a global timestamp floor would wrongly drop a second connected app
whose clock lags behind another's, whereas id-matching is immune to client
clock skew. The `since` filter still bounds how far back relays replay.

- AccountSettings.nip46SeenRequestIds (persisted via putStringSet) + host-side
  bounded LinkedHashSet, fed to NostrConnectSignerService.initialSeen and
  advanced through onHandledId.
- Tests: a fresh request whose id was serviced last session is not repeated;
  the serviced id is reported for persistence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:31 +00:00
Claude 9a0af15f36 fix(nip46): tighten stale-request window to 30s
An app restart re-subscribes with `since = now - window`, so relays replay
(and the signer re-signs) anything created within the window. 120s was wide
enough that a request made a minute before restart still came back; 30s keeps
that replay window small while still tolerating normal NTP clock skew.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:31 +00:00
Claude cdf43db0eb fix(nip46): ignore stale/replayed sign requests by age
Users on multiple relays were being asked to sign the same request repeatedly,
some minutes old. Root cause: kind-24133 is ephemeral, but many relays store
and REPLAY it every time the signer re-subscribes (a relay-set change,
reconnect, toggle, or rotation), and the in-memory dedup set is scoped to one
run() call so it's wiped on restart — the old requests then get signed again.

Gate requests by created_at (default 120s window):
- a `since` on the subscription filter so compliant relays never replay old
  stored events, and
- a receive-side staleness drop for relays that ignore `since`.

The window must exceed realistic client/relay clock skew so a genuinely fresh
request is never dropped. Also corrected the seenCap KDoc, which called the
event-id dedup set a "request-id" set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:31 +00:00
Claude e69b59a255 test(nip46): verify a real-world Ditto kind-1 signs through the bunker
Runs the exact payload (kind 1 + `client` tag + fixed created_at) through the
processor/authorizer with a REASONABLE policy and asserts: it signs with no
prompt (kind 1 is auto-allowed), created_at/content/tags are preserved, the
event is authored by the identity key (not the transport key), and the
signature + id verify.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:31 +00:00
Claude 2976afc83b docs(nip46): record audit findings + known limitations in the checklist
Independent review confirmed the first-connect wedge (fixed) and flagged the
inline-consent head-of-line blocking and relay-restart cancellation as
architectural limitations to address with a device-tested subscription/
concurrency redesign, plus three low-severity items. Documented so they are
not lost.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:30 +00:00
Claude 31cd6c8bb7 fix(nip46): time-out the first-connect consent prompt; drop dead code
Audit fixes:

- requestConnect had no timeout while requestOp did. Since authorize()/
  onConnect() run inline in the signer service's single-consumer loop, an
  ignored first-connect dialog blocked every other client's requests forever.
  Both consent prompts now fail closed on the shared 120s timeout (per-op →
  deny-once, connect → declined) so a stuck dialog can't hold the loop hostage.
- Remove the now-unused Nip46ActivityLog.forClient() (the detail screen filters
  the collected flow instead).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:30 +00:00
Claude 5c73a7c1f7 refactor(consent): move the shared signer-consent UI out of napplet
The per-operation consent dialog and the first-connect trust picker are not
napplet-specific — napplets, sandboxed browser origins, and now the NIP-46
remote signer all prompt through them. Rename and relocate them out of the
napplet package into a neutral home so the shared plumbing reads honestly:

  napplet/NappletSignerConsent{Info,Coordinator,Activity,Dialog}
    → connectedApps/consent/SignerConsent{Info,Coordinator,Activity,Dialog}
  napplet/NappletConnect{Info,Coordinator,Activity,Screen}
    → connectedApps/consent/SignerConnect{Info,Coordinator,Activity,Screen}

The genuinely napplet-specific pieces stay put: the capability-consent flow
(NappletConsent*), and the napplet→info builders (buildSignerConsentInfo /
buildConnectInfo, which resolve a napplet manifest identity) now return the
relocated generic render models. Manifest activity names and all references
updated; no behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:30 +00:00
Claude ac6697330e test(nip46): consent integration test + device verification checklist (Tier 4)
- Nip46ConsentIntegrationTest: end-to-end through the real dispatch path
  (BunkerRequestProcessor → Nip46PermissionAuthorizer → opConsent/connectConsent)
  with a real NostrSignerInternal — proves an ASK sign prompts and returns a
  signed event on allow, "unauthorized" on deny, and that a FULL_TRUST app
  signs even a dangerous kind (0) without prompting.
- Device checklist (amethyst/plans/) for the interactive/background/interop
  behavior JVM tests can't cover: pairing paths, consent variants, rotation,
  activity feed, relay health, boot restart, and the reference-client matrix.

Notification polish was deliberately skipped: the always-on notification is
shared with the relay/DM service, and consent uses its own dialog Activity, so
neither retitling nor notification actions are warranted. Documented in the
checklist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:29 +00:00
Claude 684ce26f63 feat(nip46): surface relay-connection health on the signer screen (Tier 3)
The live status card now reflects whether the signer's listening relays are
actually connected, so silently-missed requests become visible: it reads the
client's connectedRelaysFlow(), intersects with the signer's listening set,
and shows "Listening on N relays, all connected" or "X of N relays connected"
when some are down.

Boot-restart needs no change: the existing BootCompletedReceiver already
restarts the foreground service whenever isEnabled() is true, and that check
honors nip46SignerEnabled — so an enabled signer resurrects after a reboot or
app update.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:29 +00:00
Claude 4998d7d460 feat(nip46): tailor the connected-app detail screen for remote signers (Tier 2)
The shared Connected-App detail screen mis-parsed a nip46:<signer>:<client>
coordinate and rendered it through the napplet manifest path (showing a
truncated coordinate). Add a NIP-46 branch that:

- heads the screen with the client's self-declared name + url (from the stored
  Nip46ClientInfo) and a key badge, and titles the top bar with the app name;
- shows that client's recent serviced-request history (reusing the shared
  Nip46ActivityCard, extracted so the signer screen and this screen share it).

The existing trust-level picker, per-op overrides, and NIP-46-aware Forget
action render below unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:29 +00:00
Claude f9d9691017 feat(nip46): activity feed + account clarity on the signer screen (Tier 2)
Give the user visibility into what the signer is doing:

- Nip46ActivityLog: a bounded, newest-first, in-memory feed of serviced
  requests (method + kind + client + ok/denied), fed from the service's
  onServiced hook (enriched to pass the full BunkerRequest so the event kind
  is available). Survives service restarts; not persisted (it's a live feed).
- The signer screen shows a "Recent activity" card (last 8, friendly labels
  like "Signed an event (kind 1)", green/red status dot, relative time) and a
  "Signing as npub1…" line so it's clear which account is the bunker.

onServiced now hands callers the BunkerRequest instead of just the method
string (CLI updated to match).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:28 +00:00
Claude 4676d176ec feat(nip46): live per-request + first-connect consent (Tier 1)
Wire the NIP-46 remote signer into the same interactive consent surface the
napplet/browser signer path uses, so requests that aren't pre-granted prompt
instead of silently failing.

The ledger already returns ASK for the risky operations (profile 0, contacts
3, deletion 5, decryption, DMs are excluded from REASONABLE_SIGN_KINDS; a
PARANOID app asks for everything) — the only reason it didn't work was that
authorize() treated ASK as "unauthorized". Now:

- authorize(): ALLOW proceeds, DENY refused, ASK consults an in-memory session
  grant then calls opConsent (the shared per-op dialog). The returned
  SignerOpGrant is recorded via a new NostrSignerPermissionLedger.record()
  helper (allow-for-op / until / all / deny-for-op persisted; once/session not),
  mirroring the broker. No opConsent wired → ASK fails closed (CLI/tests).
- onConnect(): first contact asks connectConsent for the trust level
  (AppConnectResult) instead of silently granting REASONABLE; Blocked/Cancelled
  reject the connection. Falls back to defaultPolicyOnConnect when no prompt.
- forget() also clears the client's in-memory session grants.

Nip46ConsentBridge (amethyst) implements the two prompts by reusing the
existing NappletConnect/NappletSignerConsent coordinators + dialogs + ledger,
building the render info from the bunker request (op label, event JSON
preview, client metadata/icon). A 120s timeout fails a stuck per-op prompt
closed so it can't wedge the signer's single-consumer loop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:28 +00:00
Claude d8d185b928 feat(nip46): register nostrconnect:// deep link to the signer
Add a VIEW/BROWSABLE intent filter for the `nostrconnect` scheme on
MainActivity so a website's "Connect with Amethyst" link (or any app
firing a nostrconnect:// URI) hands off into the same pairing flow as a
scanned QR. The incoming intent.data already flows through uriToRoute,
which now maps a nostrconnect:// offer to Route.Nip46Signer(connectUri);
both the cold-start and onNewIntent paths navigate there and pair on open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:28 +00:00
Claude 3ed8779fff feat(nip46): scan nostrconnect:// from anywhere and un-gate the connect flow
Two UX gaps made "scan an app's code to connect it to my signer" hard to
reach:

- The signer screen's Connect section (Scan a code / paste) lived behind
  the enabled gate, so a first-time user saw only the "Turn on signer"
  hero and couldn't scan until they had enabled. Since a successful
  nostrconnect pairing already calls setEnabled(true), the gate was
  pointless. The Connect section now shows in both states; the success
  toast spells out the consequence ("Amethyst now signs for it in the
  background").
- The app's general QR scanner (uriToRoute) only understood NIP-19
  entities, so scanning a nostrconnect:// code did nothing. It now routes
  a nostrconnect:// offer to the signer screen, which pairs the app on
  open. Route.Nip46Signer carries an optional connectUri for this.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:28 +00:00
Claude cceac4478a feat(nip46): rotate the bunker transport key as the anti-spam remedy
Make the "regenerate" action mint a brand-new transport keypair (plus a
fresh pairing secret) instead of only rotating the secret, so a spammed
user can burn down the old bunker:// address. Anyone holding the old
address — a spammer included — can no longer reach the signer, and every
app talking to the old transport pubkey is dropped. Legit apps re-pair by
re-scanning; their trust survives because the Connected-Apps coordinate
keys off the stable identity pubkey, not the transport key.

For rotation to actually take effect, the cached `by lazy` transportSigner
is replaced with a per-call rebuild from the persisted key, and the
service-restart trigger now includes AccountSettings.nip46TransportKey so
the running NostrConnectSignerService re-subscribes under the new key.
setEnabled() settles the secret and transport key before flipping the flag
to avoid a throwaway double-start on first enable.

The UI gates rotation behind a confirmation dialog (it disconnects every
connected app) and relabels the action "New address".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:27 +00:00
Claude 2332384897 feat(nip46): use a dedicated transport key so the bunker doesn't reveal the user
NIP-46 lets the remote-signer (transport) key differ from the user's identity
key. Previously the bunker advertised and wrapped everything with the identity
key, so anyone watching the inbox relays could see kind-24133 traffic addressed
to the user's real npub and infer "this npub runs a bunker".

Now each account gets a dedicated, persisted transport keypair:

- NostrConnectSignerService wraps/unwraps the kind-24133 envelope with a
  `transportSigner`; BunkerRequestProcessor keeps the identity signer for the
  actual sign/encrypt/decrypt and answers get_public_key with the real npub
  (disclosed only to a connected client, over the encrypted channel).
- The host mints + persists the transport key lazily (accounts that never
  enable the signer mint nothing), advertises it in bunker:// and the
  nostrconnect ack, and listens p-tagged to it.
- AccountSettings/LocalPreferences persist nip46TransportKey so the advertised
  address stays stable across restarts.

Bonus: because the envelope is now wrapped with a LOCAL key, external NIP-55
(Amber) accounts no longer round-trip the external app for envelope crypto —
only the genuine signing request does. A new test asserts get_public_key
returns the identity, never the transport key. Unreleased feature, so no
migration needed. The CLI bunker keeps using the operator's own key (dev tool).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:27 +00:00
Claude 922a5841d0 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
2026-07-17 15:18:26 +00:00
Claude cd3e1353e7 docs(nip46): correct stale coordinate format in KDoc
Two doc comments still described the pre-namespacing coordinate
`nip46:<clientPubKey>`; the actual key is `nip46:<signerPubKey>:<clientPubKey>`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:26 +00:00
Claude c20cc2b514 refactor: move the generic signer-permission layer out of napplet/
The per-app signing-authorization plumbing was named/located under `napplet/`
for historical reasons, but it is not napplet-specific — it already gates
napplets, the sandboxed browser, and (now) NIP-46 remote clients through one
shared ledger. The package name mislabelled what the code is, so:

- commons: `napplet/signers/` (generic) → `connectedApps/signers/`
  (AppSignerPolicy, NostrOpDecision, NostrSignerOp, NostrSignerConsentPrompt,
  NostrSignerPermissionLedger/Store). The NIP-46-specific bridge moves to
  `connectedApps/nip46/` (Nip46PermissionAuthorizer, Nip46ClientStore), so the
  feature is no longer split across unrelated packages.
- The `NappletRequest.toSignerOp()` extension — napplet protocol leaking into
  the generic layer — moves back to `napplet/protocol/`.
- amethyst: `napplet/DataStoreNostrSignerPermissionStore` → `connectedApps/`,
  `napplet/DataStoreNip46ClientStore` → `connectedApps/nip46/`.

Pure move + repackage: all 27 import sites updated, no behaviour change.
Napplet-specific code (broker, capabilities, consent, :nappletHost) and the
Connected Apps UI folder are untouched — those really are napplet/UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:26 +00:00
Claude d06b1c7f2c feat(nip46): rate-limit flooding clients + clean up on logout
Follow-ups to the audit:

- Abuse protection: the signer service now bounds its event queue
  (DROP_LATEST) and rate-limits per author BEFORE decrypting — decryption can
  be an external-signer (NIP-55) IPC round-trip, so a looping or hostile client
  can no longer force one per event or grow the queue without limit. Fixed
  window (default 40 requests / 10s per author, oldest authors evicted). The
  limiter is touched only by the single consumer coroutine, so it needs no
  locking. Covered by a headless test.
- logout now clears the client's persisted metadata/relays too (not just the
  ledger grant), so a disconnected app stops being listened for after restart.

Not changed: get_public_key/ping stay ungated — gating them behind a prior
connect risks breaking clients that discover the pubkey at connect time, and
the pubkey is already public, so the enumeration leak is negligible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:25 +00:00
Claude fe4e881df6 fix(nip46): audit fixes — data race, cancellation, write amplification
Findings from an audit of the signer, all verified against the code:

- Data race: NostrConnectSignerService deduped request ids inside onEvent,
  which the relay pool invokes CONCURRENTLY from each relay's socket thread
  (PoolRequests dispatches listeners outside its lock). Two relays delivering
  the same subscription could mutate the LinkedHashSet at once → race / CME.
  Move dedup into the single consumer coroutine; onEvent now only does the
  thread-safe channel send.
- Swallowed cancellation: broad `catch (Exception)` around suspend calls in the
  processor, the service's decrypt + publish, and connectViaNostrConnect caught
  CancellationException too, breaking structured cancellation when the service
  restarts. Rethrow it first (matching the AccountCacheState convention).
- Write amplification: the ledger wrote last-used to that client's DataStore
  file on EVERY authorized request (unthrottled, unlike the relay-auth store).
  Coalesce to at most one write per client per 60s in the authorizer.
- Redundant resubscribe: the enable/relays collector lacked distinctUntilChanged,
  so a duplicate inbox-relay emission tore the subscription down and re-opened
  it on every relay for nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:25 +00:00
Claude 994bdae2d1 feat(amethyst): keep the signer answering in the background
The signer's relay subscription lives in the account scope and needs the shared
NostrClient connected and the process alive to keep working while Amethyst is
backgrounded or closed. Hook it into the existing always-on foreground service
(and its five restart layers) instead of building a new one:

- AlwaysOnNotificationServiceManager now starts/stops the layers when EITHER the
  notification service or nip46SignerEnabled is on (combined flow).
- NotificationRelayService.isEnabled (the auto-restart guard) honors the signer
  flag too, so START_STICKY / watchdog / boot restart keep the signer up.
- The signer screen notes that a background connection (ongoing notification)
  is what lets it answer requests while closed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:24 +00:00
Claude 9667168a6a feat: persist connected NIP-46 client metadata + relays
Add a Nip46ClientStore (commons interface + InMemory + a single-file Android
DataStore) keyed by the same signer-namespaced coordinate as the permission
ledger, holding each connected client's self-declared name/url/image and the
relays it reaches us on.

- The host persists metadata on connect (bunker + nostrconnect) and, for the
  nostrconnect flow, the app's own relays. On startup it re-adds those relays
  to the listen set, so a nostrconnect-paired app stays reachable across app
  restarts instead of silently going dark until it re-pairs.
- Connected Apps now shows the app's real name (falling back to the generic
  label + npub) for remote-signer clients.
- Wired the store through AppModules → AccountCacheState → Account → host.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:24 +00:00
Claude dea711596e feat: namespace NIP-46 grants by signer + revoke on logout
The Connected Apps signer store is app-global, so a remote client keyed only by
its own pubkey would share one trust level across every local account. Namespace
the coordinate as `nip46:<signerPubKey>:<clientPubKey>` so the same client paired
with two accounts on one device gets independent grants.

- Nip46PermissionAuthorizer takes the user's signerPubKey; coordinateFor/belongsTo
  encode + match the namespace; clientPubKeyOf reads the trailing segment.
- onLogout now revokes the client's grant (wired through the new quartz hook).
- Connected Apps lists only the active account's remote clients (napplet/browser
  grants stay app-global); the signer screen counts the same way.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:24 +00:00
Claude d46ac987e8 feat(quartz): harden signer service, add logout + end-to-end loopback test
- NostrConnectSignerService now bounds its request-id dedup set (LinkedHashSet
  with an evicting cap) so a long-lived bunker can't leak memory on the ids it
  has seen.
- Add NIP-46 `logout`: the processor recognises the method, acks it, and calls
  a new Nip46RequestAuthorizer.onLogout hook (default no-op) so a host can
  revoke the app's grant when it disconnects.
- New NostrConnectSignerServiceTest drives full request→reply round trips
  (connect/sign/logout + drop-if-not-addressed) through the service over a fake
  relay client with passthrough signers — headless proof of the subscribe →
  decrypt → dispatch → publish wiring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:23 +00:00
Claude 9f32f72a41 feat(amethyst): redesign the Nostr Signer screen around a scannable hero
Rework the signer settings screen from a plain toggle list into a purpose-
built pairing surface:

- A disabled-state hero (key medallion, headline, one big "Turn on signer"
  call to action) that reads as a feature intro rather than a setting.
- A live status card with an animated pulsing dot summarising "signing for N
  apps · listening on M relays".
- A QR hero: the bunker:// address rendered as a large scannable QR
  (QrCodeDrawer) on a white surface — pairing is scan-first, with copy and
  new-secret as tonal actions and the raw string kept as a caption.
- Scan-to-connect: a primary "Scan a code" button opening the QR scanner for
  nostrconnect:// offers, with paste-a-link as a revealable fallback.
- A connected-apps row showing the live count and linking into Connected Apps.

Reuses the existing QrCodeDrawer / SimpleQrCodeScanner composables.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:22 +00:00
Claude c3a1762637 feat(amethyst): act as a NIP-46 signer for other apps
Adds the "Nostr Signer" feature: Amethyst can now be a remote signer (a
"bunker") for other apps, listening on the user's inbox relays and signing
through whatever signer the account uses — a local key or a NIP-55 external
app — gated by the shared Connected Apps trust ledger.

- Nip46SignerState: account-scoped host that runs the quartz
  NostrConnectSignerService on the inbox relays whenever the feature is on,
  restarting on relay/toggle changes. Builds the bunker:// advertisement,
  handles nostrconnect:// paste pairing, and manages the pairing secret.
  Requests are authorized through Nip46PermissionAuthorizer (the Connected
  Apps ledger), so a remote client is a connected app under nip46:<pubkey>.
- AccountSettings: persisted nip46SignerEnabled toggle + nip46BunkerSecret,
  wired through LocalPreferences.
- Account/AccountCacheState/AppModules: build the signer ledger from the
  app-global Connected Apps store and construct the host per account.
- UI: a Nostr Signer settings screen (enable toggle, listening status,
  copyable bunker address with secret regeneration, nostrconnect:// connect
  box, link to Connected Apps) + settings-catalog entry + route. Connected
  Apps renders nip46: clients as remote-signer cards with their trust chip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:21 +00:00
Claude 81aea57ddb feat(commons): ledger-backed NIP-46 authorizer for Connected Apps
Nip46PermissionAuthorizer implements the quartz Nip46RequestAuthorizer by
routing every remote-signer request through the shared Connected Apps
permission ledger (NostrSignerPermissionLedger). A NIP-46 client becomes a
connected app under the coordinate `nip46:<clientPubKey>`, so it reuses the
same per-app trust levels and per-op overrides as napplets and web origins:

- sign/encrypt/decrypt requests map to NostrSignerOp and are allowed only when
  the ledger's standing decision is ALLOW (ASK/DENY are refused — a background
  signer cannot prompt, so access is granted ahead of time in the UI).
- connect validates the pairing secret, then registers the app at a default
  REASONABLE policy (never downgrading a level the user already set) and echoes
  the secret back.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:20 +00:00
Claude dc0840264d refactor(cli): drive amy bunker through the shared quartz signer core
Replace BunkerCommand's hand-rolled subscribe/decrypt/dispatch/publish loop
with NostrConnectSignerService + BunkerRequestProcessor, and route bunker://
/ nostrconnect:// URI parsing+building through NostrConnectURI. Behaviour is
unchanged (the CLI bunker still hosts the operator's own key and auto-approves
every request via a small CliAuthorizer that only checks the pairing secret);
this removes the duplicated protocol logic now that it lives in quartz.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:20 +00:00
Claude b1227fa7cb feat(quartz): add signer-side NIP-46 processor, service and URI codec
Adds the bunker/signer half of NIP-46 as reusable, signer-agnostic quartz
components so Amethyst can act as a remote signer for other apps:

- BunkerRequestProcessor: turns a decrypted BunkerRequest into the
  BunkerResponse the client expects, performing the work through whatever
  NostrSigner the account uses (local keypair or NIP-55 external app).
  Signing/encryption/decryption are gated through a Nip46RequestAuthorizer;
  public reads (get_public_key/ping/get_relays) are not.
- Nip46RequestAuthorizer: the permission boundary the host app plugs its
  own trust model into (connect validation + per-op authorization).
- NostrConnectSignerService: subscribes to kind-24133 requests on a relay
  set, decrypts, dispatches to the processor, and publishes the reply.
- NostrConnectURI: KMP-safe parse/build for bunker:// and nostrconnect://
  pairing URIs (percent-encoded), shared by CLI/desktop/Android.

Unit tests cover the dispatch/authorization matrix with a fake signer (no
crypto) and the URI round-trips.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:20 +00:00
Vitor PamplonaandGitHub 3d99f10058 Merge pull request #3611 from vitorpamplona/claude/search-settings-icon-expand-zxnm00
Refactor settings search field to use Material 3 TextField
2026-07-17 10:35:17 -04:00
Claude 6f86c035b0 feat: filter settings in place with a persistent search pill
Replace the Material 3 DockedSearchBar with a persistent, pill-shaped
search field that filters the settings list in place instead of opening
a results dropdown. The docked component is aimed at a search surface
with its own results view (and the tablet/desktop form factor); an
in-page filter — as in the Android system Settings app — is a better fit
here.

- Rounded, tonal search field pinned above the list (Material 3 look).
- Typing narrows the categorized list directly; a blank query shows all.
- Clear (X) resets the query; "no results" state when nothing matches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQYnKXff1esD1bdPGNzyHp
2026-07-17 14:12:04 +00:00
Vitor PamplonaandGitHub 86cecc7038 Merge pull request #3610 from vitorpamplona/claude/save-button-switcher-colors-7vnmm5
Fix onPrimary contrast and add color scheme preview tool
2026-07-17 10:00:17 -04:00
Vitor PamplonaandGitHub b39db9345d Merge pull request #3609 from vitorpamplona/claude/post-opening-issue-iyrmrs
Tolerate bare URL icons and keyless channels in CORD-05 invites
2026-07-17 09:54:42 -04:00
Claude a3f42ee45b refactor(theme): converge filled controls on default colors, drop filledAccent
With onPrimary contrast-picked, the default filled Button/FAB/Switch already
reads correctly on both themes, so the dedicated deep-fill path is redundant.
Remove it everywhere for one uniform look:

- Revert the Save/Post/Send/Create top-bar button and standalone SaveButton to
  plain Button (no colors override).
- Revert all 27 AmethystSwitch call sites back to Switch and delete the
  AmethystSwitch wrapper + amethystSwitchColors.
- Drop the filledAccent / onFilledAccent ColorScheme getters.
- Update the color-pairs preview: primary/onPrimary now covers the whole
  filled-control surface.

The entire filled-control fix is now the single onPrimary = onAccent(primary)
line in the scheme builders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EVwY2Qzth2EREWg8qLhyzF
2026-07-17 13:52:39 +00:00
Claude 8b3dfafc20 chore(theme): trim color preview to the by-role view, taller
Drop the old-vs-new normalization comparison now that the color direction is
settled; keep ThemeColorPairsPreview (fg/bg pairs by ColorScheme role, dark |
light) and bump it to heightDp=980 so all rows render. Revert darkColors/
lightColors back to private (the removed preview was the only external caller).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EVwY2Qzth2EREWg8qLhyzF
2026-07-17 13:37:32 +00:00
Claude 7d7fe0899e fix(concord): open CORD-05 invites with keyless channels and string icons
A live relayop.xyz kind-33301 invite bundle (vsk=6) decrypted correctly but
failed to open: its JSON diverged from quartz's CommunityInvite model on two
CORD-05 wire details, so decodeOrNull returned null and the invite reported
Unreadable ("This invite link can't be opened...") instead of joining.

- InviteChannel.key was required; a public channel (e.g. an unencrypted
  `general`) carries no delivered grant key and some reference clients omit
  the field. Default it to "" so a keyless channel no longer rejects the
  whole bundle.
- icon was modeled strictly as an ImagePointer object; relayop emits a bare
  public URL string for an unencrypted icon. Add LenientImagePointerSerializer
  (a JsonTransformingSerializer) that lifts a string into ImagePointer(url=...)
  on read while still serializing the canonical object form on write.

Adds a regression test driving the real live bundle + its fragment token,
asserting it now classifies as Live. This is a distinct interop case from the
existing vsk=8 mis-posted-registry test: here the sub-kind, token, and crypto
are all correct — only the JSON schema was too strict.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018iLfo68yVtG2ALkKB8tpAa
2026-07-17 13:33:37 +00:00
Claude 9c7cc8b971 fix(theme): contrast-pick onPrimary so filled buttons/FABs stop washing out
Revert onPrimary from a hardcoded white back to onAccent(primary) in both
schemes. On the dark theme primary is a light pastel, so white content on it
was only ~2.6:1 — every filled Button and FAB (which default to
primary/onPrimary) rendered washed out. onAccent picks by contrast: black on
the dark theme's light accents (~7.9:1), white on the light theme's deep
primary (unchanged). One theme-level change fixes all ~90 filled-control sites
without touching a single element. Save button + switches keep their dedicated
filledAccent fill, left as an on-screen A/B against the lighter default look.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EVwY2Qzth2EREWg8qLhyzF
2026-07-17 13:11:42 +00:00
Claude d11fb74e4f feat: use Material 3 DockedSearchBar for settings search
Switch the Settings search from a custom expand-into-the-top-bar icon to
the Material 3 DockedSearchBar component, matching the modern in-page
search convention (e.g. Android Settings).

- A persistent search pill sits below the "Settings" title bar.
- Tapping it expands the docked results dropdown, listing the filtered
  settings (a blank query lists everything, narrowing as the user types).
- The back arrow, the system back gesture, and picking a result all
  collapse the bar and clear the query.
- The full categorized settings list shows below the pill while collapsed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQYnKXff1esD1bdPGNzyHp
2026-07-17 13:04:48 +00:00
Vitor PamplonaandGitHub c9fbe07d37 Merge pull request #3607 from davotoula/fix/live-hls-behind-window-recovery
Keep live HLS streams playing; cache only proven on-demand HLS
2026-07-17 08:47:51 -04:00
David KasparandGitHub 0479c8f3ec Merge pull request #3603 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-17 09:02:17 +01:00
davotoulaandgithub-actions[bot] c7d4a72a34 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-17 07:57:11 +00:00