Commit Graph
17235 Commits
Author SHA1 Message Date
Claude 1daa871d95 refactor: move MiniFhir/MedicalData parsing from Jackson to Kotlin Serialization
Replace the Jackson-based FHIR resource parser with kotlinx.serialization.
The `resourceType` polymorphism is now handled by a JsonContentPolymorphicSerializer
that dispatches to the modeled types (Practitioner, Patient, Bundle,
VisionPrescription) and falls back to a new UnknownResource for anything else,
so a Bundle mixing known and unknown resources still parses.

The reader is lenient (ignoreUnknownKeys, isLenient, explicitNulls=false,
coerceInputValues) so we parse what we can and tolerate the missing or extra
fields that many FHIR implementations add.

Adds MiniFhirTest covering the vision-prescription bundle, extra/unknown field
tolerance, unknown-resourceType fallback, mixed bundles, and garbage input.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUeR8YFNHvBELCaLRDTpCn
2026-07-17 21:27:31 +00:00
Vitor PamplonaandGitHub 56c99bb7b6 Merge pull request #3617 from vitorpamplona/claude/community-invite-nav-mr0qzv
Prevent re-joining Concord communities via old invite links
2026-07-17 16:20:36 -04:00
Claude e3e85994f2 fix: drop Concord invite screen from back stack after forwarding to the community
ConcordInviteScreen auto-redeems and forwards to Route.ConcordServer, but it did
so with nav.newStack(). Since the community route isn't in the back stack yet,
newStack's popUpTo(target) matches nothing and the invite screen stays underneath.
Pressing Back from the community then reveals the invite screen, whose plain
remember(link) state resets to Working, re-runs joinConcordViaInvite, and forwards
right back — trapping the user in a Back→forward loop.

Forward with popUpTo(ConcordServer, ConcordInvite::class) instead, matching the
sibling group-creation screens (CreateGroupScreen, RelayGroupMetadataScreen), so
the invite screen is removed and Back returns to wherever the invite was opened.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MeJJCDLdvi4KQEuozcMMBh
2026-07-17 19:47:17 +00:00
Claude 2ac9cc81f7 feat: skip re-join when redeeming a Concord invite for a community already joined
When redeeming a Concord invite link (`…/invite/<naddr>#<fragment>`) whose
resolved community is already in the joined list, return
`ConcordInviteResult.Joined` immediately instead of re-following and
re-announcing a Guestbook JOIN (kind 3306). The invite screen already forwards
to `Route.ConcordServer(communityId)` on `Joined`, so reopening an old invite
for a community you're already in simply takes you to it rather than spamming
the community relays with a fresh join every time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MeJJCDLdvi4KQEuozcMMBh
2026-07-17 19:33:43 +00:00
Vitor PamplonaandGitHub f380d92498 Merge pull request #3615 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-17 15:31:14 -04:00
Vitor PamplonaandGitHub d1f267a2a3 Merge pull request #3613 from vitorpamplona/feat/relay-req-refusal-suppression
feat(relay): stop re-sending REQs relays structurally refuse
2026-07-17 15:31:06 -04:00
davotoulaandgithub-actions[bot] f28b6381e3 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-17 19:26:39 +00:00
David KasparandGitHub bf3e0df24d Merge pull request #3614 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-17 20:24:40 +01:00
davotoulaandgithub-actions[bot] 17c014ac1b chore: sync Crowdin translations and seed translator npub placeholders 2026-07-17 19:19:50 +00:00
davotoula fd430ae429 update cz, se, pt, de 2026-07-17 20:16:55 +01:00
Vitor PamplonaandClaude Opus 4.8 10d88afea3 feat(relay): stop re-sending REQs relays structurally refuse
Relays that can't serve a request (a NIP-50 search-only relay pulled into
the feed, a write-only relay, a relay whose filter shape is rejected) were
being hammered with the same doomed REQs. Observed on a 40s cold start:
search.nos.today CLOSED 13-14x ("error: search filter is required") across
6 subscriptions, plus repeated "restricted: does not accept REQs" and
"unsupported: too many filters". The reconnect path replayed refused REQs on
every reconnect, and many different subscriptions kept hitting the same
capability wall.

Two complementary, purely-quartz mechanisms (so the app and Amy both benefit
with zero wiring):

- Per-subscription refusal memory (RequestSubscriptionState + PoolRequests):
  a filter a relay CLOSES is not replayed to that relay across reconnects
  until it meaningfully changes or a REQ succeeds (EOSE/event). Never applies
  to auth-required (the auth subsystem re-signs and replays) or rate-limited
  (the adaptive limiter spaces it out).

- Per-relay capability block (RelayReqRefusals): after 2 refusals, classify a
  relay SEARCH_ONLY (suppress only non-search filters; genuine search REQs
  still flow) or NO_READS (suppress all), from narrow substring markers that
  deliberately avoid auth-conditional messages. A fully-blocked relay is
  dropped from PoolRequests.desiredRelays so the pool disconnects it, closing
  the idle socket rather than keeping it open with every REQ suppressed. A
  SEARCH_ONLY relay stays connected while any subscription carries a search
  filter for it, so the separate search path is unaffected.

Adds UNSUPPORTED to MachineReadablePrefix (relays send "unsupported:"; parse()
returned null on it before).

Device-verified: search.nos.today now Connecting -> OnOpen -> 2x Closed ->
Disconnected; sendit.nosflare.com (NO_READS) -> 3x Closed -> Disconnected;
feed event volume unchanged (kind-1 from 17 relays) - no coverage loss.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 14:58:03 -04:00
David KasparandGitHub cf4ea766aa Merge pull request #3608 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-17 17:22:17 +01:00
davotoulaandgithub-actions[bot] e2128d2b6e chore: sync Crowdin translations and seed translator npub placeholders 2026-07-17 16:21:07 +00:00
davotoula 6dddb3b9c9 Code review:
- clarify no-op lifecycle callback comments
2026-07-17 16:57:27 +01:00
davotoula ef7c10cef9 fix(sonar): address maintainability smells in resource-usage and CLI code
- ForegroundTracker: document empty ActivityLifecycleCallbacks overrides
- ResourceUsageReportAssembler: extract duplicated markdown table separator into a constant
- GroupMetadataCommands: use shared File.deleteOrWarn helper for temp-file cleanup
- fold private deleteOrWarn into shared commons helper
2026-07-17 16:57:05 +01:00
Vitor PamplonaandGitHub 7b5ddd3637 Merge pull request #3612 from vitorpamplona/claude/amethyst-nip46-signer-njao2v
Add NIP-46 remote signer (bunker) support with consent UI
2026-07-17 11:37:04 -04:00
Claude 98c53ed907 docs(nip46): record the amy CLI interop gates in the signer checklist
Document this session's CLI additions in the living NIP-46 device checklist
(no separate plan doc for two flags — they're covered by `amy --help`):

- a "CLI interop driver (amy)" section covering amy on both sides — client
  (login bunker:// / --nostrconnect [--perms]) and signer (bunker --perms /
  --interactive) — as the reproducible interop harness.
- a repro command on the NostrConnect informed-consent item: `amy login
  --nostrconnect --perms sign_event:1,nip44_encrypt` emits a perms-carrying
  offer that drives the app's consent sheet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:23:42 +00:00
Claude 13bc33ad70 feat(cli): gate amy bunker with --perms and interactive terminal approval
Until now the CLI bunker auto-approved every request (it hosts the
operator's own key, so the pairing secret was the only gate). That left
two NIP-46 signer behaviors the harness couldn't exercise: a signer that
*rejects* disallowed ops, and a signer that asks a human live.

Add two opt-in gates to both `amy bunker` and `amy bunker connect`:

- `--perms sign_event:1,nip44_encrypt,…` restricts the signer to the
  listed ops; anything else is rejected. Fully scriptable/headless. This
  is the server-side mirror of the client's `--nostrconnect --perms`, so
  an interop run can now test a client against a rejecting signer.
- `--interactive` keeps the bunker listening and prompts `y/N` on the
  terminal for any op the policy doesn't already allow, so the operator
  approves/rejects each request live. TTY-guarded (errors on a piped
  stdin), default-deny, prompts serialized by a mutex because the service
  dispatches requests concurrently. Composes with `--perms` (auto-allow
  the safe ops, prompt for the rest) — mirroring Amethyst's Reasonable
  policy.

Neither flag → unchanged auto-approve behavior. Thin assembly: the perms
parsing and request→op mapping are reused from commons
(`Nip46PermissionAuthorizer.parsePerms` / `toSignerOp`), both already
unit-tested there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:38 +00:00
Claude 0f1ced8682 feat(cli): let amy login --nostrconnect request perms
The Amethyst signer now honors a nostrconnect offer's `perms=` for
informed consent, but the CLI client had no way to emit one — so the new
consent flow couldn't be driven from `amy`, the project's interop-test
harness.

Add a `--perms` flag to `amy login --nostrconnect`, threaded into the
offer through quartz's NostrConnectURI (which already builds/parses the
param). Also keep the parsed perms on NostrConnect.Offer and surface a
client's requested perms in `amy bunker connect`, so an operator can see
what an app-side signer would be asked to pre-grant (the CLI bunker still
auto-approves the operator's own key — perms is not a gate there).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:37 +00:00
Claude 1ebc4f0ada feat(nip46): informed consent for nostrconnect perms
Closes the one real gap from the "first-class" review. The nostrconnect flow
treated the paste/scan as blanket consent and, having no dialog, had to DROP the
app's declared sensitive perms to avoid a silent grant.

Now first contact via nostrconnect shows a connect sheet with the app's identity,
the account it would act as, the exact permissions it declared (rendered
human-readably — "Sign notes (kind 1)", "Encrypt messages", …), and a trust
picker — before anything is published or granted. Approving connects and
pre-grants exactly those declared ops (including sensitive ones the user just
reviewed) unless they pick Paranoid; Cancel/Block declines and nothing is
registered. A re-pair of a known app skips the sheet and keeps the user's prior
trust/per-op decisions.

Adds SignerConnectInfo.requestedPermissions + rendering, a
Nip46ConsentBridge.requestNostrConnectConsent path, and a ConnectResult.Declined.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:37 +00:00
Vitor PamplonaandClaude 5d9d75e2c4 fix(accounts): serialize Account construction so concurrent loaders can't build twins
The UI login path (AccountSessionManager) and the background preloaders
(RegisterAccounts, EventNotificationConsumer, loadAllWritableAccounts) race
loadAccount's check-then-create on cold start and after account switches.
Both saw an empty cache and both built an Account: the loser was never
cancelled, so its Nip46SignerState kept answering bunker requests with a
NostrSignerExternal no Activity ever registers an Amber launcher on — every
sign failed 'No activity to launch from' while the twin raced error replies
to NIP-46 clients and doubled every consent prompt. Reproduced on-device
(f8ff11c7 constructed twice 10ms apart); gone after the lock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 15:18:36 +00:00
Claude 3a85a4f982 feat(nip46): per-account consent sheets + expandable per-item preview
Two follow-ups on the batched-consent sheet:

One account per sheet. The consent coordinator is process-wide, so a batch could
bundle requests for different logged-in accounts. Instead of mixing them, the
Activity now renders only the oldest-pending account's group; when that clears,
the next account's requests render as their own sheet. The account moves to a
header (avatar + "signing as <name>") since every row in a sheet now shares it.
Dismissing denies only that account's group.

Expandable per-item preview. Each batch row is collapsed to app · operation +
a one-line excerpt; tapping it expands the full detail so the user can inspect
exactly what they're signing/encrypting/decrypting — the unsigned event rendered
as a NoteCompose (with the JSON toggle), or the encrypt/decrypt plaintext.
Extracted that rich content block into a shared SignerConsentPreview reused by
the single-request dialog and each expanded row.

Removed the now-unused denyAllPending (dismissal is per-group / per-request).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:36 +00:00
Claude 5f723bf607 feat(nip46): show the signing account on each batched-consent row
The consent coordinator is process-wide, so a batched sheet can bundle requests
for different logged-in accounts. The rows showed only the app + operation, not
which identity would sign — against the "make it clear which account is signing"
rule the single-request dialog already follows. Each row now carries the signing
account's avatar + name, so a mixed-account batch is unambiguous.

(The batch stays a compact triage list — it does not render a full NoteCompose
preview per item the way the single-request dialog does.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:36 +00:00
Claude 62c82aef68 fix(nip46): two batched-consent races found in review
An independent review of the concurrent-servicing changes found two real bugs
(the quartz/authorizer concurrency core reviewed clean):

- Notification TOCTOU. SignerConsentCoordinator did a non-atomic
  "if pending empty → cancel notification" in the resolving request's finally.
  A request arriving concurrently could post the shared full-screen-intent
  notification between another request's empty-check and its cancel, wiping the
  new request's only surface while backgrounded — it then sat unseen until the
  120s timeout denied it. Add/show and remove/empty-check/cancel now run under
  one surfaceLock, so a live request's notification can't be cancelled out.

- Batched selection re-seeded to all-selected on any pending-set change
  (fail-open). Because requests are serviced concurrently, the pending set
  changes under an open sheet; re-seeding silently re-checked deselected items
  and auto-checked newly-arrived requests, so "Allow selected" could grant ops
  the user deselected or never saw. Now seed once and reconcile incrementally
  (selected ∩ tokens): deselections survive and a new request is never
  auto-selected. Also default the batch "Remember" toggle off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:36 +00:00
Claude fd5556609f fix(nip46): audit fixes on the new signer code
Self-review of this session's changes surfaced four issues:

- Perms re-seeded on every re-pair. connectViaNostrConnect pre-granted the
  offer's declared ops outside the first-contact guard, so re-pairing an app
  overwrote per-op decisions the user had since changed (e.g. an op set to DENY
  came back as ALLOW). Now only on first contact.
- Perms could silently grant sensitive ops. The nostrconnect flow shows no
  dialog (scan = consent), so the declared perms are never surfaced — yet seeding
  pre-granted everything except decrypt/deletion, which would silently allow
  config-overwrite (kinds 0/3) and other sensitive kinds. Tightened to only the
  ops REASONABLE already auto-allows, so pairing never exceeds the default policy;
  sensitive kinds still prompt on first use.
- Batched-consent deny-all race. SignerConsentActivity.onDestroy denied every
  pending request when finishing; a request arriving as the sheet closed is owned
  by a freshly-launched instance, so it was wrongly denied. Removed — each
  dialog's onDismissRequest already fails closed, and the 120s bridge timeout
  backs it up.
- Redundant work: batched-selection state keyed on list size (a new request in a
  same-size swap was unselectable) → key on the token set; connected-apps loader
  re-read loadPolicy per app when allPolicies() already carried it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:35 +00:00
Claude 1d8b7d7c8f feat(nip46): batched consent via concurrent request dispatch
Third refinement from the Primal comparison — and the one that needed an
architecture change, not just UI.

Quartz: NostrConnectSignerService now fans each request into a child coroutine
under a Semaphore(maxConcurrentHandles=16) instead of handling them inline, so a
request awaiting a consent prompt no longer blocks other clients' auto-allowed
traffic and several prompts can be pending at once. Intake (dedup, staleness,
rate-limit, seen-id persistence) stays on the single consumer. Two guards keep
it safe: BunkerRequestProcessor serializes the actual crypto with a Mutex
(authorization — the prompt — runs unlocked, only sign/encrypt/decrypt holds the
lock) so an external NIP-55 signer never sees concurrent IPC ops; and
Nip46PermissionAuthorizer serializes first-connect consent so two connects can't
stack dialogs. Covered by BunkerRequestProcessorConcurrencyTest (crypto never
overlaps; a blocked prompt doesn't stall another client's signing).

Amethyst: SignerConsentCoordinator is now a shared pending StateFlow; one
SignerConsentActivity observes it and shows the rich single-request dialog (1
pending) or a batched checkbox list with select-all + a Remember toggle +
Allow/Deny selected (>1). Dismissing the sheet denies every still-open request
(fail closed).

Needs on-device validation (burst batching, no concurrent external-signer IPC,
fail-closed on dismiss) — see the device checklist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:35 +00:00
Claude a039f6adbb feat(nip46): security-icon trust picker + reconnect affordance
Two of the three refinements from the Primal comparison:

Trust picker as security icons: unify the connect dialog and the app-detail
picker on one open→shield→locked glyph set — LockOpen (Full trust, never asks),
Shield (Reasonable), Lock (Paranoid) — replacing the connect dialog's emoji and
the detail's heart so both surfaces read the same and the icon carries the
guard-level at a glance.

Reconnect affordance: when an app's relays show Offline (from the live status
added last commit), offer a one-tap Reconnect — on the connected-apps row and in
the detail's Relays section — that forces the relay pool to re-dial now, ignoring
backoff. Shared Nip46ReconnectPill.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:35 +00:00
Claude 98c18a9fcf feat(nip46): honor nostrconnect perms + per-app live relay status
Two gaps found comparing against Primal's NIP-46 signer:

Honor the offer's `perms`: we already parsed the `nostrconnect://?perms=` list
but ignored it. Now the declared ops are pre-granted at pairing (the deliberate
pair is the user's consent for what the app openly asked for), so a client that
declares its needs runs without prompting on first use. The two highest-risk
classes stay gated even when declared — decryption (private content) and
deletion (kind 5) still prompt on first use with full context. Adds
Nip46PermissionAuthorizer.parsePerms + tests.

Per-app live relay status: the connected-apps list shows a Connected/Offline dot
per app (judged on its own nostrconnect relays, or the inbox relays for a
bunker-flow app), and the detail Relays section shows a live dot per relay — so
"which relays is this costing me and are they up right now" is answerable at a
glance. Shared Nip46StatusDot/Nip46LiveStatus/nip46AppOnline helpers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:35 +00:00
Claude bb56bf6a73 feat(nip46): show a connected app's relays on its detail screen
The list row's "N relays" count wasn't inspectable, so a user debugging why the
signer holds a background relay connection couldn't see which relays an app
uses. Add a Relays section to the (already tap-through) detail screen listing
each relay URL, with a note that Amethyst keeps a background connection to each
while the app stays connected. Apps that brought no relays of their own (the
bunker flow) show that they ride the account's inbox relays and add no extra
connection.

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 e353467b6f feat(nip46): show the connected app's own icon when it declares one
A NIP-46 client's connect metadata can include an image (its app/site icon).
Render it — in a circle, with the Key glyph as the placeholder/error fallback —
for both the connected-app list row and the detail header, via a shared
Nip46AppIcon. We only draw an icon the app itself advertised; we never fetch a
site favicon from the main app, which would bypass Tor and leak the user's IP
(the same reason BrowserIconRegistry captures favicons in the sandbox instead).

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 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