Commit Graph
254 Commits
Author SHA1 Message Date
Claude 5683c74629 perf(amethyst): mine on half the device's cores in the Android app
Each PoW job now races PoWPolicy.minerWorkers(cores) = cores/2 parallel
searches (at least 1) instead of a single thread, so a post on an 8-core
phone mines ~4x faster while the other half of the cores stays free for
the UI, and the total CPU budget matches the old 2-job x 1-thread pool.

- PoWPolicy.minerWorkers(availableProcessors) is the single definition of
  the per-job worker budget.
- PoWPublishQueue gains minerThreads (used by its built-in miner and
  exposed for custom mine lambdas); AppModules wires maxConcurrent = 1 +
  minerThreads = half the cores.
- PoWNostrSigner gains a workers param (default 1), covering reactions,
  reposts and reports via Account.miningSigner; the anonymous-post paths
  in the short-note and comment composers pass the same budget.
- Gift-wrap envelope mining stays single-threaded: the template
  conversion is a non-suspend hook inside the synchronous NIP-59 build.
- UI time estimates (settings picker, composer chip, broadcast banner)
  now benchmark at the queue's worker count via deviceHashesPerSecond(),
  and PoWEstimator caches one rate per worker count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhaF5scnAvhP9wr9R7bTWM
2026-07-11 12:13:32 +00:00
Vitor PamplonaandGitHub e60003063b Merge pull request #3527 from vitorpamplona/claude/nip13-pow-publishing-queue-1takp1
Add NIP-13 Proof-of-Work mining queue with persistence and UI
2026-07-10 21:48:03 -04:00
Claude 6dd1e4d31d fix(pow): resolve audit findings — durability, coverage, perf and l10n
Durability & correctness:
- Keep the job entry + disk checkpoint alive until the publish continuation
  completes (was: dropped at mining-complete); failed publishes keep their
  checkpoint for restart retry and surface through a failures SharedFlow
- Move draft deletion into the publish continuations in every composer so a
  cancelled or process-killed mining job can't destroy the only copy of a post
- Persist gift-wrap mining: split NIP17Factory into createSeals (signer
  interaction, runs inline) + wrapSeal (pure CPU, runs on the queue), new
  REPLAY_WRAPS records restore pending DM wraps after process death
- Clamp synced NIP-78 difficulty (PoWPolicy.MAX_DIFFICULTY), require a sane
  target in PoWMiner, bound PoWRankEvaluator against short ids
- Reaction double-tap while mining now toggles (dedupeKey + cancelByKey)
  instead of publishing duplicates
- Restore checkpoints for every loaded account, not just the active one
- logOff purges the account's checkpoints and cancels its queued jobs
- Private notes: composer chip now gates on the gift-wrap kind and the
  per-post override reaches sendPrivateNote

Coverage:
- Public/live chat (kinds 42 + 1311) and voice replies (1244 + kind-1 audio
  replies) now route through the mining gate

Perf:
- FGS start() dedupes with a running flag; PendingIntents built once
- Banner 1 Hz clock only ticks while a job shows elapsed time
- PoWEstimator benchmark is single-flight behind a Mutex

UX / l10n:
- Post-mining failures toast with retry information
- Count strings converted to <plurals>; elapsed time via DateUtils; settings
  estimate uses localized units; shared powKindLabelRes replaces three
  duplicated kind→label maps; dead pow_chip_* strings removed
- CLI: pow mine lowercases the pubkey before mining (uppercase hex mined an
  id that never matches the signed event) and validates via quartz Hex

New queue tests: checkpoint lifetime, failure reporting, cancelByKey toggle,
per-owner cancellation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
2026-07-11 00:31:05 +00:00
Claude 3a96554c8d feat(relayauth): replace the policy list with Always / Never / Custom + toggles
Restructure the global auth control into a top-level mode — Always authenticate,
Never authenticate, or Custom — where Custom reveals independent per-situation
toggles instead of the confusing single sub-toggle:

- My relays and venues (own relays + joined/subscribed/favorited venues) — on
- Read posts from people I follow — on
- Message people I follow (DMs, replies, notifications) — on
- Message anyone / strangers — off by default (you're asked each time instead)

RelayAuthPolicy is now {ALWAYS, NEVER, CUSTOM}. The resolver takes a
RelayAuthCustomToggles plus split serves-facts (followed-read, followed-write,
stranger-write, own-relay, venue) and, under CUSTOM, allows if any enabled
category matches — else falls through to a prompt. There is deliberately no
"read strangers' posts" category, so that always prompts.

Account settings replace the single delivery flag with four persisted booleans
(default policy CUSTOM; no migration, unreleased). The contextual DM/notification
prompt button now switches to CUSTOM and enables both message toggles. Resolver
tests rewritten per-toggle, including that reading a stranger is never
auto-allowed. Old IF_IN_MY_LIST / TRUSTED_FOLLOWS policies and their strings are
removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:13 +00:00
Claude 9ca1ba2f5c feat(relayauth): reframe follows trust as relationship vs. message delivery
Restructure the two TRUSTED_FOLLOWS controls so they read as independent ideas
instead of a confusing read/write sub-toggle:

- The "My relays and people I follow" policy now trusts a followed user as any
  counterparty — reading their posts AND reaching them (DM/notification) — plus
  your own relays and joined venues. Reading your follows is no longer gated.
- The sub-toggle is repurposed to "Also log in to deliver my messages": trust a
  relay to send DMs, replies or notifications to anyone you're talking to, even
  people you don't follow.

Resolver: replace servesFollowed{Write,Read}Counterparty with a single
servesFollowedCounterparty, add servesWriteCounterparty (an inbox of anyone
you're messaging), and gate the latter behind the new
messageDeliveryTrustEnabled input. Rename the account setting
relayAuthTrustFollowsForReads -> relayAuthTrustMessageDelivery (+ pref key;
no migration needed, unreleased). The contextual prompt button moves from
read-post prompts to DM/notification prompts and now enables message delivery.
Resolver tests updated for the new inputs, including that the delivery toggle
is write-only and never auto-allows reading a stranger.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:13 +00:00
Claude 3526a14ebe feat(relayauth): venue-aware auth for public chats, communities & live streams
The purpose model was person-centric, so an auth-required NIP-28 channel,
NIP-72 community, or NIP-53 live stream broke: reads filter by #e/#a with
no authors, so nothing was attributable -> silent DENY (chat wouldn't
load); top-level posts (no p tags) also silently failed; replies were
mis-attributed as "notify" and trusted on the wrong signal.

Add venue purposes (POST_VENUE / READ_VENUE) carrying the venue id
(channel event-id, or community/live `kind:pubkey:dTag` address). The
deriver recognizes kind-42 channel posts (root `e`), community/live posts
and reads (`#a` 34550/30311), and channel reads (`#e`). Venues are trusted
under TRUSTED_FOLLOWS when you've joined them (publicChatList /
communityList) or their owner — the pubkey in the address, e.g. a live
stream's host — is someone you follow; trusted venues auto-auth for both
reading and posting.

Safety net: any active use we still can't attribute yields an OTHER
purpose so the relay is prompted about instead of failing silently.
Prompt copy gains venue-aware titles/consequences ("Post to this room?",
"If you don't, your message won't be posted."). Default policy already
TRUSTED_FOLLOWS, so joined venues just work. Unit-tested across resolver
and deriver.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:11 +00:00
Claude 4bd73596e3 feat(relayauth): add purpose-aware AUTH policy core + TRUSTED_FOLLOWS
Introduce the platform-neutral decision core for contextual NIP-42 auth
in commons/relayauth:
- AuthPurpose / AuthPurposeKind / RelayAuthContext describe *why* a relay
  wants auth (send DM, deliver notification, read outbox, own relay).
- RelayAuthVerdict adds an ASK outcome (runtime-only; never persisted,
  unlike the two-value RelayAuthDecision override).
- RelayAuthResolver is a pure, unit-tested precedence ladder: blocked
  list > per-relay override > policy > ASK-if-attributable-else-DENY.
- New TRUSTED_FOLLOWS policy: auto-auth for relays serving a followed
  counterparty on a write purpose (DMs/notifications), and — behind a
  read sub-toggle — read purposes; strangers fall through to ASK.

Wires the new enum value through the existing settings screen (new
option + strings, reusing the Group symbol) and the URL-only ledger path
(degrades to the my-list check until challenge context is plumbed).
Live prompt UI, grant-rationale persistence, and quartz challenge-context
plumbing are follow-up steps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:09 +00:00
Claude 97fc801294 fix: NIP-13 compliance — refresh created_at at mining start, clean nonce tag
Findings from a full NIP-13 review:

- The spec recommends updating created_at while mining. Queue jobs now
  re-stamp the template to "now" when a worker picks them up (a post can
  wait behind other jobs, and a job restored after process death could be
  hours old); the restorer does the same. Scheduled posts are exempt —
  their future created_at is intentional. Anonymous posts re-stamp before
  mining against the throwaway key.
- PoWTag.assemble(nonce, null) serialized the literal string "null" as
  the third tag entry; a missing commitment now omits the entry entirely.
- New tests: PoWTagTest pins the NIP-13 example tag shape and the
  no-commitment round trip; a queue test asserts the created_at
  re-stamp at mining start.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
2026-07-10 22:22:04 +00:00
Claude 24ea909221 fix: remove trailing whitespace and newlines when rendering RichText
Trailing spaces and newlines in a note's content produced empty trailing
paragraphs after the line split, each rendered as a blank FlowRow line
between the last visible word and the end of the component. Trim the
content's tail before segmenting, and return no paragraphs for
whitespace-only content.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZET7ig8MvR5vSCSxvcURi
2026-07-10 22:09:11 +00:00
Claude 35647d0a5c feat: persist the PoW queue and shield it with a shortService foreground service
Backgrounding the app no longer pauses or loses mining:

- commons: PoWPublishQueue checkpoints template jobs through a
  PoWJobPersistence hook (saved on enqueue, removed on finish/cancel,
  deduped by id so restore is idempotent) and fires onQueueActive on
  every enqueue. PoWReplay flattens the live continuation into a
  PersistedPoWJob record (broadcast / to-relays / schedule) that can be
  replayed headlessly. Opaque enqueueWork jobs (reactions, reposts, gift
  wraps) and anonymous posts stay in-memory on purpose — a throwaway key
  and its content must never touch disk.
- amethyst: PowJobStore (atomic-rename JSON file, single-lane writer,
  3-day staleness purge) and PowJobRestorer, which re-enqueues an
  account's checkpointed jobs on login and finishes them via the replay
  descriptor. Composers now pass the matching PoWReplay for the public,
  group-thread and scheduled paths.
- PowMiningForegroundService: a shortService-type FGS started on every
  enqueue (~3 min guaranteed budget, no special permission) that keeps
  the process out of the cached-apps freezer while mining. Stops itself
  when the queue drains; on onTimeout it exits cleanly since the jobs
  are checkpointed. The notification is a live progress card
  (NotificationCompat.ProgressStyle): one track segment per post filling
  as jobs finish, indeterminate for a single post, per-kind text, a
  cancel-all action, and Live Updates rendering on Android 16+.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
2026-07-10 21:08:51 +00:00
Claude 8988c98308 feat: add NIP-13 proof-of-work publishing with a fire-and-forget mining queue
Implements user-facing PoW publishing (#3317) on top of the existing
quartz miner:

- quartz: PoWMiner.run gains a cooperative isActive cancellation hook
  (checked every ~VALID_BYTES^2 hashes); PoWNostrSigner decorator mines
  kind-scoped templates pre-signature so it composes with every signer;
  GiftWrapEvent.create/NIP17Factory can mine the outer ephemeral-key
  wrap (never the seal/rumor); NostrSignerWithClientTag exposes
  prepareTags so mining runs over the final tag set.
- commons: PoWPublishQueue (FIFO, capped worker pool on
  Dispatchers.Default, per-job cancel, in-memory only — unmined posts
  are lost on process death, logged) and PoWPolicy (kind-group
  categories with a hardcoded NEVER list: auth, zap requests, NWC and
  bunker RPC, HTTP/Blossom auth, drafts, metadata and lists, OTS).
- amethyst: per-account synced settings (difficulty Off/16/20/24/28 or
  custom, per-category checklist) in Compose Settings; Post enqueues the
  template and returns immediately; reactions, reposts, reports, private
  notes, DMs and long-form route through the same shouldMine gate at
  their existing choke points; per-post PoW override chip in the
  composer options row; "Mining proof of work… (N in queue)" phase with
  per-job cancel in the broadcast banner.

Scheduled posts and anonymous posts mine against the correct key
(scheduled posts skip mining in v1); the client tag is applied to the
template before mining so signing never invalidates the nonce.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
2026-07-10 20:24:45 +00:00
Vitor PamplonaandGitHub 525d3ef189 Merge pull request #3514 from vitorpamplona/claude/armada-nip29-integration-lwqard
Relay Groups: NIP-29 relay-based group chat (Armada interop)
2026-07-10 13:03:48 -04:00
Claude ace7a7f476 fix(relay): enforce blocked relays centrally on every REQ/COUNT/publish
Relay targeting is fully distributed: every feed, loader, finder and
broadcast path builds its own relay set and hands it to the shared
INostrClient. Only the follow-outbox flows and the top-nav feed filters
subtracted the NIP-51 kind:10006 blocked list, so blocked relays still
leaked in through the event/thread loaders (FilterMissingEvents /
FilterMissingAddressables), the user-metadata finder
(pickRelaysToLoadUsers), channel finder, DM targeting, the one-shot
fetch helpers, and the publish path (Account.computeRelayListToBroadcast)
— none of which consulted the blocked set.

Add BlockedRelayFilteringClient, a thin INostrClient decorator that
strips the active account's blocked relays from subscribe, count and
publish right before they reach the pool. Because the one-shot fetch
helpers route through subscribe/count, wrapping the client covers them
too. The blocked set is read per-call so account switches and list
edits apply with nothing to invalidate.

Wire it around the shared app client (blocked set from the logged-in
account) and around the per-account crawl client used by Event Sync and
Cashu discovery. Add commonTest coverage for the filtering, pass-through,
fully-blocked, and per-call-read behaviors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JNMPdC2eGUwTrt3XkQefuf
2026-07-09 21:50:55 +00:00
Claude 2eb7ff2ef2 Merge remote-tracking branch 'origin/main' into claude/armada-nip29-integration-lwqard
# Conflicts:
#	cli/tests/.gitignore
2026-07-09 21:48:04 +00:00
nrobi144 4ce21e7034 test(commons): AUTH end-to-end exercising policy → signer round-trip
Four tests covering the lambda shape that DesktopAuthCoordinator's
signWithAllLoggedInUsers calls into for every NIP-42 challenge:

  build RelayAuthEvent template → classify via policy → sign or
  block → return List<RelayAuthEvent> for RelayAuthenticator

- tier-1 own-inbox auto-signs a valid kind:22242 event with the
  right challenge + relay tags
- tier-2 unknown surfaces a PendingAuthApproval; ONCE resolution
  produces a signed event (no persistence)
- tier-2 BLOCKED returns null AND persists the rejection
- tier-2 ALWAYS persists and skips the prompt on subsequent calls

Concurrency: the policy.classify call inside the lambda suspends on
the CompletableDeferred when prompting; tests use coroutineScope +
async + yieldUntilNotNull to model the banner-resolving-from-outside
pattern, mirroring how DesktopAuthCoordinator.resolve() drives the
deferred from a UI click.

Together with the existing PoolEventOutboxStateTest (auth-required
carve-out), AuthApprovalPolicyTest (classifier), and
GiftWrapRelayHintTest (NIP-17 hint placement), this completes
unit-level coverage of the AUTH pipeline. The websocket-level
round-trip stays covered by geode/.../KtorRelayTest.kt against a
real Ktor mock relay; that infra is reusable for a future
desktopApp integration test that combines mock relay + this stack.
2026-07-09 07:33:42 +03:00
nrobi144 2240d64ae8 test(commons): cover DmInboxRelayResolver three-layer lookup + cache
Eight tests covering the resolver contract:

- localLookup hit short-circuits indexer fan-out
- empty indexer set returns empty
- empty local + empty indexer (no events arrive) yields empty
- cache hit within TTL skips indexer
- cache expiry triggers fresh indexer call
- clear() wipes all entries
- invalidate(pubkey) removes only the named entry
- localLookup returning an EMPTY list falls through to cache/indexer
  (the takeIf { isNotEmpty() } guard — emptyList from localLookup
  means "I don't know", not "I know they have nothing")

Uses EmptyNostrClient so RecipientRelayFetcher.fetchRelayLists returns
no events — covers the canonical "indexer found nothing" path without
needing a real mock relay. Tests for the populated-indexer path will
land with the Phase 4 wire-up commit when a Ktor-based mock relay is
plumbed through.
2026-07-09 07:33:22 +03:00
nrobi144 2ba051a952 feat(commons): add AuthApprovalPolicy classifier for tiered NIP-42 AUTH
The current Android-only AuthCoordinator signs every NIP-42 AUTH
challenge from every relay unconditionally (and across every logged-in
account). For desktop there is no AUTH wiring at all — challenges are
ignored, so AUTH-walled relays silently drop DMs.

Both behaviours fail the security review: unconditional signing lets
any relay the user reads (or any malicious relay they touch) extract an
identity-key signature with timestamp, and signing across all accounts
links them under one relay observer.

This commit adds the substrate for a tiered classifier — wire-up will
follow with the desktop AuthCoordinator (P2.5) and SQLite-backed
persistence (P2.4). The policy itself is platform-agnostic and lives in
commons so Android can adopt the same design later.

Two tiers, no third silent-drop path:
- auto-allow when the relay is in the user's own outbox/DM-inbox set,
  or has a persisted ALWAYS grant (subject to BLOCKED override)
- prompt-and-suspend via CompletableDeferred for everything else, with
  the user's `[Once] [Always] [Never]` choice driving the deferred

Includes InMemoryAuthApprovalStore for tests + the ONCE session cache;
SqliteAuthApprovalStore lands in P2.4 with the sibling outbox.db.

Eight unit tests cover tier-1, persisted ALWAYS, persisted BLOCKED
(including BLOCKED overriding tier-1), unknown-prompt-then-cache,
re-eval of selfApprovedRelays on Account changes, and store.clear().
2026-07-09 07:03:55 +03:00
Claude 2b11dbd7d9 test(nip29): cover weird apostrophe placements + guard relay-URL possessives
Reject a single-character group id (except the default `_`) so a possessive
glued to a bare relay URL — `wss://relay.damus.io's uptime` — no longer
linkifies group "s". Real ids (relay29/Wisp/0xchat) are all longer.

Adds coverage proving only genuine ws/wss relay URLs are peeked: apostrophes
after http, nostr:, blossom:, email and bech32 tokens never become group links;
plus ws:// (insecure), second-apostrophe boundary, and multi-link cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 17:21:15 +00:00
Vitor PamplonaandGitHub 50ce22e49e Merge pull request #3495 from nrobi144/feat/desktop-wallet-privacy-lock
feat(desktop): apply the privacy lock to the Wallet column
2026-07-08 12:52:54 -04:00
Claude f9b24156e8 feat(nip29): linkify group invite links inline and via deep links
Recognise the de-facto `<relay>'<groupId>[?code=<code>]` NIP-29 group invite
link format used by Wisp and 0xchat, both inside rendered note content and as
an external deep link, so tapping one opens the group.

The URL detector correctly stops a host at the apostrophe (host names can't
contain `'`), so the group id is torn off before classification. Rather than
loosen the shared URL grammar — which would swallow prose possessives like
`example.com's` — group links are recovered by peeking just past each relay
URL the detector already found. This is cache-miss-only and costs nothing on
notes without a `wss://` link.

- quartz: GroupInviteLink.parse / suffixLength (+ tests)
- commons: Urls.groupLinks, UrlParser peek, RichTextParser plumbing
  (atomic span through fixMissingSpaces, new RelayGroupLinkSegment) (+ tests)
- amethyst: ClickableRelayGroupLink renderer + uriToRoute deep-link branch

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 16:15:31 +00:00
nrobi144 d0daf786b1 feat(commons): scope-parameterise PrivacyLockState for multi-route lock reuse
Genericises the messaging privacy-lock state holder so a single master
`lockEnabled` flag can drive multiple gated routes independently:

- `LockScope { Messages, Wallet }` enum added.
- `MessagesLockState` → `PrivacyLockState(scope, settings, coroutineScope)`.
  Each scope keeps its own StateFlow<LockState> + idle-timer Job; both
  scopes share the same `PrivacyLockSettings` so failed-attempt counters
  and lockout schedule stay device-global (brute-force protection).
- `LocalMessagesLockState` (single instance) → `LocalPrivacyLockState`
  (Map<LockScope, PrivacyLockState>) + `lockStateFor(scope)` accessor.
- `redactionLevel` → `dmRedactionLevel` (Kotlin-side rename; persisted
  prefs key `redaction_level_ordinal` unchanged).
- `setPasswordHashed(null)` cascades to `setLockEnabled(false)` so a
  master lock cannot stay armed without a credential to verify against.

MessagesLockGate, DesktopMessagesLockGate, MessagesFirstRunBanner,
SetPasswordDialog, and RedactionCard now read `lockStateFor(Messages)`
— behaviour-preserving. Ships 3 new PrivacyLockStateTest cases:
independent per-scope state, shared failed-attempt counter, and the
password-clear cascade.

Plan: docs/plans/2026-07-07-feat-wallet-privacy-lock-reuse-plan.md
2026-07-07 13:18:56 +03:00
Vitor PamplonaandGitHub c90c1b4768 Merge pull request #3459 from vitorpamplona/claude/dispatchers-thread-caps-s8yp4c
Add lock-free concurrent collections and fix UDP socket threading
2026-07-03 10:37:00 -04:00
nrobi144andClaude ecedc4affe feat(desktop): notifications redesign — inbox UX, native OS toasts, shared filter
Rework the Amethyst Desktop notification experience end-to-end.

**In-app inbox** (`desktopApp/…/ui/NotificationsScreen.kt`)
- Dedicated Notifications entry in the sidebar and a new
  `DeckColumnType.NotificationSettings` overlay reachable from a ⚙ button
  in the column header — back button renders automatically via
  `navState.hasBackStack` in deck mode and via body Back in single-pane.
- Redesigned column: filter tabs (All / Mentions / Replies / Reactions /
  Zaps / Reposts / DMs) with per-kind counts, grouped cards (reactions
  and reposts collapse to "N reactions on your post" per day), unread
  dots driven by a persisted `lastReadAt` per pubkey, freshest-first
  ordering via `compareByDescending { timestamp }`.
- User metadata: avatars + display names on every row (including reactor
  strip inside grouped cards) resolved from `LocalCache`, with
  `metadataVersion` observation. Zap sender is the actual zapper (via
  `NotificationItem.effectiveAuthorPubKey` unwrapping
  `LnZapEvent.zapRequest.pubKey`), not the LNURL provider.
- Reaction/repost group cards are clickable → thread; DM cards click →
  Messages column; expandable to show note preview + reactor list.
- `NotificationSettingsScreen`: master toggle, 7 per-kind toggles,
  manual-DND dropdown, preview-privacy switch, per-platform status
  card, "Send a test toast". Permission-aware button adapts across
  NotRequested → Granted / Denied / BundleRequired with a macOS System
  Settings deep-link. State syncs with OS-level changes via
  `LocalWindowInfo.isWindowFocused` regain refresh.

**Native OS notifications** (`commons/…/moderation/notifications/`)
- `NotificationDispatcher` interface + `PermissionState` sealed
  hierarchy in `commonMain`. JVM impl `NucleusNotificationDispatcher`
  routes through Nucleus (three per-OS artifacts: macOS
  `UNUserNotificationCenter` via Swift/JNI, Windows WinRT toast via
  JNI, Linux libnotify via D-Bus). Falls back to `AwtTrayNotifier` when
  native lib fails to load. Async `requestPermission` +
  `refreshPermission` bridge Nucleus's callback API to `suspend`.
- `DesktopNotificationAutoDispatcher` subscribes to
  `DesktopLocalCache.eventStream.newEventBundles` and fires OS toasts,
  applying a 9-check suppression pipeline: kind allow-list, master
  toggle, per-kind toggle, DND, window-focused, cold-boot
  (event.createdAt < sessionStart or >30s stale), macOS permission,
  semantic accept, 30s per-(kind,event-id) dedupe. Wired in Main.kt
  with DisposableEffect(loggedIn.pubKeyHex); window focus tracked via
  LocalWindowInfo → StateFlow.
- Adds `windows { menu = true; shortcut = true }` to
  `desktopApp/build.gradle.kts` so AUMID persists and Windows toasts
  survive reboot.

**Shared notification filter** (`commons/…/moderation/notifications/NotificationKinds.kt`)
- Extracted from Android's `NotificationFeedFilter`. Exposes
  `SUBSCRIPTION_KINDS` (13 kinds: text, DMs kind 4 + 14 + 1059
  gift-wrap, encrypted-file-header, comments 1111, reactions, reposts,
  generic reposts, channel messages 42, nutzaps 9321, zap receipts
  9735, onchain zaps 8333), `subscriptionFilter(pubKey, since, limit)`
  builder that `FilterBuilders.notificationsForUser` delegates to, and
  `tagsAnEventForUser(event, myPubKey, isTargetAuthoredByMe)` semantic
  gate. Reactions/reposts require target-author-match; other kinds
  require `p=me`. Fixes a bug where the helper defaulted to accept and
  let cache-seed leak "mentioned you" notifications from unrelated
  text notes.
- Android's `NotificationFeedFilter.NOTIFICATION_KINDS` now spreads
  `SUBSCRIPTION_KINDS` + Android-only extras (badges, git, highlights,
  polls, videos, voice, live-activities), so a change on either side
  propagates. Downstream push consumers (`NotificationDispatcher.kt`,
  `EventNotificationConsumer.kt`) read the resulting set transparently.
- Content sanitizer strips control chars, RTL overrides, zero-width
  chars, and URLs from toast titles. DM cards never render ciphertext
  body (decryption pipeline deferred).

**Tests** — `NotificationKindsTest` covers 17 scenarios: reactions
target-author-mismatch rejection, own-event rejection except zap kinds,
p-tag routing for text/DMs/zaps/nutzaps/gift-wraps/channel messages,
`SUBSCRIPTION_KINDS` sanity + `subscriptionFilter` shape.

**Testing constraint**: macOS OS notifications require a bundled
process. `gradle run` will always show BundleRequired — use
`gradle :desktopApp:runDistributable` and open the resulting
`Amethyst.app`. First permission grant surfaces the app in
System Settings → Notifications.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 15:19:18 +03:00
Claude c85280259c feat: add reports, torrents, and addressable content to the reasonable set
Complete the "Let's be reasonable" content set:
- reports (1984) and torrents (2003/2004) — additive public events whose
  reputational weight is no greater than the arbitrary kind-1 notes an app
  can already publish.
- long-form articles (30023), wiki (30818), and the legacy addressable
  video kinds (34235/34236) — addressable content. Re-signing with the same
  d tag replaces the app's own prior version; accepted as no worse than the
  arbitrary posting a kind-1 grant already permits.

Only replaceable *configuration* (profile 0, contacts 3, 10000-range lists)
stays ASK, since a bad write there can silently wipe account settings —
distinct from addressable content.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMrHZfwN5tM4ecvdz7xigo
2026-07-02 18:13:34 +00:00
Claude e4c35523a7 feat: expand reasonable set with more public content/engagement kinds
Add the remaining regular, additive, public, plaintext content and
engagement kinds that sit in the same risk class as notes/pictures:
relay chat (9), threads (11), public messages (24), poll votes (1018) and
polls (1068), file metadata (1063), voice messages (1222) and replies
(1244), live-stream chat (1311), and code snippets (1337).

Documents the borderline kinds left at ASK on purpose: reports (1984) and
torrents (2003/2004) carry reputational/legal weight, and
addressable/replaceable content (long-form 30023, wiki 30818) can overwrite
prior versions. Test pins several of these exclusions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMrHZfwN5tM4ecvdz7xigo
2026-07-02 18:08:48 +00:00
Claude 49e0fee162 perf: add expect/actual ConcurrentSet, use for EventDeduplicator
Part B of the dispatchers/thread-caps audit.

EventDeduplicator is fed from relay subscription callbacks
(AdvancedSearchBarState.trackRelayEvent), which arrive on multiple threads
concurrently. It backed a plain mutableSet with a single KmpLock, so every
delivery from every relay thread serialized on one monitor.

Add ConcurrentSet<E> as a KMP expect/actual util:
- jvmAndroid actual: ConcurrentHashMap.newKeySet() — lock-striped writes,
  lock-free reads, no single cross-thread monitor.
- iOS actual: a KmpLock-guarded set (no lock-free set in the K/N stdlib) —
  same behaviour as before, no regression. The win lands on JVM/Android,
  which is where the high-throughput event paths run.

Point EventDeduplicator at it. Covered by ConcurrentSetTest (commonTest,
behaviour) and ConcurrentSetConcurrencyTest (jvmTest, exactly-one-add-per-key
under 8 threads).

Scope note: the other two commonMain sites the audit flagged were left as-is
on purpose. The compose subscription managers' KmpLock is a deliberate,
documented KMP choice on a single (main-thread) writer where the lock is
effectively free; EOSECache is a bounded LRU with compound value mutation on
a per-subscription (not per-event) path. Neither is a clean fit for a
concurrent set, and converting them would fight a documented decision for
negligible gain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ANuUziXKRafSTBxbh4SMoq
2026-07-02 16:53:43 +00:00
Claude a99ee8d09e feat: auto-approve video posts and NIP-42 relay auth under "Let's be reasonable"
Add video posts (kinds 21 normal, 22 short) as direct siblings of picture
posts (20) — additive, public, non-destructive content in the same risk
class as the original note set.

Also auto-approve NIP-42 relay auth (22242): an ephemeral proof-of-key
bound to a single relay + challenge (unreplayable elsewhere) that
Amethyst's own client already auto-signs for every logged-in account, so
treating it as background noise for napplets matches existing behavior.

Deliberately still ASK: NIP-98 HTTP auth (27235). Unlike relay auth it
authorizes an arbitrary HTTP request as the user — including destructive
NIP-96 blob deletes and NIP-86 relay-management admin calls — so its blast
radius is too broad to sign silently. Test pins the 42-vs-98 contrast.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMrHZfwN5tM4ecvdz7xigo
2026-07-02 16:04:12 +00:00
Claude 20a9622f27 feat: auto-approve zap requests (9734) under "Let's be reasonable"
Signing a Lightning zap request moves no money — it only fetches an
invoice. The payment itself is the separately-gated value.payInvoice
capability, which prompts on every use regardless of policy. So adding
9734 to the reasonable set drops a redundant signature prompt while the
meaningful payment prompt stays.

Nutzaps (9321) remain excluded: publishing one *is* the payment, since
the event carries the spendable ecash proofs. Test pins the contrast.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMrHZfwN5tM4ecvdz7xigo
2026-07-02 15:49:07 +00:00
Claude b02b5439d5 feat: expand "Let's be reasonable" napplet auto-approve set
Add more additive, public, non-destructive event kinds to the REASONABLE
signer policy so common apps stop prompting for every action. New kinds:
16 (generic repost), 20 (picture post), 42 (public chat message),
1111 (NIP-22 comment), 9802 (highlight), and 30315 (user status) — all in
the same risk class as the original 1/6/7 set.

Kinds that can spend money, overwrite account config (profile, contacts,
relay/mute/bookmark lists), delete content, or leak private data still
prompt. Decryption also stays ASK.

Refactors reasonableDecision() to a documented REASONABLE_SIGN_KINDS set
backed by quartz KIND constants, and adds NostrSignerPermissionLedgerTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMrHZfwN5tM4ecvdz7xigo
2026-07-02 15:14:59 +00:00
Vitor PamplonaandGitHub 13f65dec26 Merge pull request #3450 from vitorpamplona/claude/compose-signature-field-u7rbx6
Add compose signature setting to auto-append custom text to posts
2026-07-02 08:42:20 -04:00
nrobi144 33bb81dddb Merge remote-tracking branch 'upstream/main' into feat/desktop-privacy-lock
# Conflicts:
#	desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt
2026-07-02 07:20:44 +03:00
Claude b0834b8d8a feat: add compose signature pre-filled in text-based post screens
Adds a Signature field to Compose Settings (global UI settings, DataStore
persisted). When opening any text-based composer — new note, reply, quote,
poll, NIP-22 comment (reply/hashtag/geohash/url), or a new long-form
article — the signature is appended to the message with a blank line,
keeping the cursor at the start so the user types above it.

Drafts, forks, and version edits are skipped since their content already
carries (or deliberately omits) a signature, and an untouched
signature-only message is treated as blank so closing the composer never
auto-saves a junk draft.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vq4JQPB9m62nJ8Vdp7xVLN
2026-07-02 03:41:00 +00:00
nrobi144 72c3dff870 feat(privacylock): P0 security hardening — 600k iterations + backoff
Addresses the P0 items in the security review at
docs/plans/2026-07-01-privacy-lock-security-review.md.

## PBKDF2 iterations 100k → 600k (M1) via versioned hash format (M2)

- New PasswordHasher storage format: `v1$saltB64$hashB64` (600k
  iterations, matches OWASP 2023 Password Storage Cheat Sheet for
  PBKDF2-HMAC-SHA256).
- Legacy `saltB64$hashB64` (100k iterations) format still verifies
  correctly — no user gets locked out by the bump.
- `hash()` always produces `v1$…`; users migrate to v1 opportunistically
  when they Change or Set a new password.
- New `PasswordHasher.isLegacyFormat()` helper for callers that want
  to force-migrate on next successful unlock.
- Verify cost goes from ~50ms → ~250ms on a modern laptop — well
  within tolerable UX for a lock users open a handful of times per
  session.

## Exponential backoff on failed unlock (M3)

- `PrivacyLockSettings` gains `failedUnlockAttempts: StateFlow<Int>`
  and `lockedUntilEpochMs: StateFlow<Long?>`, both persisted via
  java.util.prefs so a reboot cannot reset the backoff.
- `MessagesLockState.onFailedUnlockAttempt(nowMs)` implements the
  schedule: no lockout for first 4 fails, then 30s / 60s / 120s /
  300s (capped at 5 min).
- `MessagesLockState.onUnlockSuccess()` transparently clears the
  attempt counter and any active lockout (also called from the
  banner-enable path).
- DesktopLockScreen shows a countdown ("Try again in 27s") in the
  supportingText, disables the password field and Unlock button
  during lockout, ticks every 500ms via a LaunchedEffect.
- RemovePasswordDialog inherits the same protection — Settings can't
  bypass the throttle by disabling the lock.
- 4 new unit tests cover threshold behavior, base trip, doubling +
  cap, reset on success. All 13 tests green.

## Not in this commit

- L1/L2 (String/CharArray memory retention) — out-of-tree fix in
  Compose; accepted per threat model.
- L3 (post-uninstall prefs) — release-notes item.
- M4 (Limitations copy update) — deferred; existing "does not
  protect against filesystem access" line already covers.
2026-07-01 12:06:45 +03:00
nrobi144 d216d22c3e feat(privacylock): Messages first-run discovery banner (Desktop)
Adds an inline banner at the top of the Desktop Messages deck column
that nudges users to enable the privacy lock. Fires only when
!lockEnabled && !firstRunCardSeen; dismissal is sticky across
restarts + lock enable/disable cycles.

- MessagesFirstRunBanner: AnimatedVisibility(expandVertically + fadeIn)
  wrapper around a Surface + Row with a padlock icon, title, body,
  and Enable / Not now buttons. Modeled on OfflineBanner.kt.
- SetPasswordDialog extracted from PrivacyLockSettingsScreen.kt into
  a shared desktop/security/ file so the banner and the settings pane
  both point at the same composable.
- MessagesLockState.onUnlockSuccess() relaxed to accept Disabled as a
  valid previous state, so enabling from the banner keeps the user
  Unlocked and doesn't flash the lock screen. New unit test covers
  this path; all 9 tests green.
- DesktopMessagesScreen wraps its two-pane / compact layout in a
  Column with the banner on top and a Box(weight(1f)) around the
  panes so fillMaxSize propagates correctly.
2026-07-01 11:35:52 +03:00
nrobi144 1c0141aba1 feat(privacylock): Desktop wiring — gate, password unlock, settings
Phase 5 (Desktop-only). Wraps the Messages deck column behind a
PBKDF2-hashed password gate; drops the Android-app slice.

- PrivacyLockSettings gains passwordHashed field + setter (salt$hash,
  base64). Backed by java.util.prefs on desktop.
- PasswordHasher: PBKDF2-HmacSHA256, 100k iterations, 16-byte salt,
  256-bit key, constant-time compare. Same primitive family as
  SecureKeyStorage.
- DesktopMessagesLockGate: synchronous branch select in composition
  (no LaunchedEffect guard) — closes the deep-link race per plan
  §Security Hardening H1. Renders content when Disabled/Unlocked;
  renders inline password TextField when Locked. Fires
  MessagesLockState.onLeaveRoute() in DisposableEffect onDispose so
  navigating away from the Messages column re-locks immediately.
- DesktopMessagesLockGate handles the "no password set" edge case
  with a Disable-lock affordance.
- LocalPrivacyLockSettings CompositionLocal + LocalMessagesLockState
  (from commons) both provided once at the App composition root in
  Main.kt. Constructed with the existing windowScope so the state
  holder's idle timer coroutines are lifecycle-scoped to the Window.
- DeckColumnContainer: DesktopMessagesScreen wrapped in
  DesktopMessagesLockGate for the Messages column.
- Desktop PrivacyLockSettingsScreen: Column + Card layout matching
  LocalRelaySettingsScreen (no Scaffold). Toggle, "Change password"
  affordance with a full set/change dialog (old + new + confirm),
  inactivity timer dropdown (1m / 5m / 15m / 1h / Never), redaction
  level dropdown (Hidden / Full), honest limitations copy. Auto-opens
  the set-password dialog if user toggles ON with no password set.
- Slotted into the existing Settings pane in Main.kt right after
  LocalRelaySettings.
2026-07-01 11:35:52 +03:00
nrobi144 c4f647d01a feat(privacylock): foundation — state holder, settings, gate composable
Phase 1 of the messaging privacy lock. Headless cross-platform spine in
commons; no UI wiring yet.

- LockState sealed interface (Disabled / Locked / Unlocked)
- InactivityTimer enum (1m / 5m / 15m / 1h / Never; default 5m)
- DmRedactionLevel enum (Generic / Full)
- PrivacyLockSettings interface (StateFlows + mutators)
- PreferencesPrivacyLockSettings backed by java.util.prefs in jvmAndroid
  (shared by Desktop + Android; node com/vitorpamplona/amethyst/privacylock)
- MessagesLockState — app-global state holder; initial value seeded
  synchronously from prefs to close the deep-link race; LocalMessagesLockState
  CompositionLocal provided at App root
- CredentialPrompter interface + PromptResult enum +
  LocalCredentialPrompter CompositionLocal
- MessagesLockGate composable — synchronous branch select (no
  LaunchedEffect guard); LockScreen with biometric button
- IdleTimerModifier — pointerInput Initial-pass, non-consuming
- 8 unit tests covering cold-start seed, idle expiry, leave-route,
  Never timer, user-interaction reset, settings cascade,
  credential-unavailable disable. All green.
2026-07-01 11:35:51 +03:00
nrobi144 d0646acf3c Merge upstream/main into feat/desktop-hashtag-spam-filter 2026-07-01 09:53:09 +03:00
nrobi144 7a18e30fc4 feat(desktop): hashtag-spam filter with collapse-with-reveal
Damus-inspired content filter that collapses notes abusing `t` hashtag
tags into a compact reveal-on-click placeholder. Ships default ON with a
threshold of 5 (adjustable 1–20 in Settings → Content Filters, or off).

Scope
- Pure check (`HashtagSpamCheck`) + settings interface
  (`HashtagSpamSettings`) live in `commons/moderation/`, callable by
  Desktop, `amy` CLI, and (future) Android.
- JVM-backed `PreferencesHashtagSpamSettings` writes to the shared
  `java.util.prefs` node `com/vitorpamplona/amethyst/filters`, so `amy`
  and Desktop observe the same value automatically.
- `CollapsedSpamNote` placeholder in `commons/ui/note/` takes only
  primitive scalars so Android can adopt it without touching commons.
- Desktop wraps every `NoteCard` call site (FeedNoteCard, QuotedNoteEmbed,
  BookmarksScreen, 5 SearchResultsList sites) with a shared
  `SpamCheckedNoteRender` helper. Thread root notes auto-expand via
  `forceReveal=true`; replies still respect the filter.

Exemptions
- Long-form articles (kind 30023)
- Authors in the follow list plus self
- Repost wrappers check the inner event's tags via precomputed
  `note.replyTo`, falling back to `containedPost()`

Search UX fixes bundled in
- Removed the `#hashtag` → "Direct lookup" card. `QueryParser` already
  extracts `#xxx` into the query's hashtag filter, so typing `#bitcoin`
  now goes straight to filtered results.
- Search-result rows now trigger metadata loading via
  `subscriptionsCoordinator.loadMetadataBatched(authors)` and observe
  each user's metadata flow via a new `rememberDisplayData` helper, so
  display names + avatars refresh when kind-0 arrives from index
  relays. Same helper reused in Bookmarks.

Tests + docs
- 19 unit tests (check × 10, displayed-event unwrap × 4, prefs × 5),
  all green.
- Manual testing sheet with 16 scenarios at
  `desktopApp/plans/2026-06-29-hashtag-spam-filter-manual-testing-sheet.md`.
- Plan at `docs/plans/2026-06-29-feat-desktop-hashtag-spam-filter-plan.md`.
- Cross-client desktop feature backlog reference at
  `desktopApp/plans/_desktop-feature-backlog.md`.
2026-07-01 09:46:43 +03:00
Claude 8b4f5e28f4 fix(napplet): let users re-trigger NIP-07 connect after cancelling
Pressing Back on the "Connect to Nostr" first-connect dialog resolves to
AppConnectResult.Cancelled, which added the app's coordinate to an in-memory
`sessionCancelled` set. That set suppressed every future connect prompt for the
entire broker lifetime, so a later request — e.g. the user re-clicking "login"
in the in-app browser — was silently denied and the dialog never reappeared.
Because a Cancel persists nothing, the app also never showed up in Connected
Apps, leaving the user with nothing to clear to recover.

Replace the permanent suppression with a short, self-clearing cooldown
(`cancelledUntil` map): a Cancel suppresses re-prompts only briefly so the
burst of requests a page/napplet fires on load doesn't relaunch the dialog per
request, while a deliberate retry seconds later prompts again. The clock is
injectable for deterministic tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDcts4HzwSff4fy6oSVA6D
2026-06-30 00:16:31 +00:00
Claude 52f1b8723c feat(napplets): require Amethyst consent for all signer types, auto-approve encrypt/decrypt
Remove the signer self-gating bypass that allowed external (Amber/NIP-55)
and remote (NIP-46) signers to skip Amethyst's per-napplet consent UI.
All signer types now go through Amethyst's consent dialogs first; the
external signer then adds its own approval on top (double-prompting).
This lets users differentiate signing requests by app inside the external
signer, since Amethyst itself is the requesting app.

Also expand the REASONABLE policy to auto-approve Encrypt and Decrypt
operations, matching the intent that common/private-key operations that
apps routinely need are pre-approved at the "reasonable" trust level.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-27 22:58:51 +00:00
Claude 7a03a47d1d feat: omnibox autocomplete, visit history, and in-page address bar for the browser
Refines the browser URL-bar experience across the launcher and the in-page
browser chrome:

- Shared URL normalization (commons OmniboxInput): dedupes the logic that was
  copied between BrowserScreen and NappletBrowserService, recognizes bare
  domains/localhost/IPs, falls back to a (configurable) DuckDuckGo search, and
  flags .onion as Tor-only so the launcher forces Tor for it.
- Omnibox suggestions (commons OmniboxSuggestions): ranks favorites + visit
  history by prefix/substring match, favorite boost, and frecency; deduped by
  host. The launcher body turns into a suggestion list as you type.
- Inline ghost-text completion in the address field (TextFieldValue selection),
  completing a typed host fragment to the top-ranked host.
- Visit history (BrowserHistoryRegistry, main process): a device-local,
  bounded, DataStore-backed store. Pages are recorded ONLY on a clean
  main-frame load — relayed from the keyless :napplet browser host over a new
  MSG_RECORD_HISTORY IPC — so misspelled/unresolved addresses never enter it.
- In-page editable address bar (websites only) in NappletControlSheet, showing
  the live URL + a security glyph (Tor/https/plain) and loading what the user
  types. nsite/napplet hosts pass no navigate callback, so they never get one.

Pure logic is covered by unit tests in commons.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LyxWy2k3AT1LiZSvMsiDx
2026-06-24 15:02:01 +00:00
Claude 2b80bf49e3 feat(napplet): broker the NAP inc topic bus
Adds the `inc` pub/sub bus so napplets that declare it boot and can exchange
topic events: `inc.subscribe {topic}` / `inc.unsubscribe {topic}` register
interest, `inc.emit {topic, payload}` fans out an `inc.event {topic, payload,
sender}` to OTHER subscribed napplet sessions (never echoing the sender) — the
kehto runtime's inc contract.

- Router edge ops (gated on the INC declaration alone, like identity.watch —
  no per-call consent): SubscribeInc/UnsubscribeInc/EmitInc outcomes.
- Protocol: readTopic/readPayloadRaw + encodeIncEvent.
- NappletIncBus in the broker service routes across the live napplet sessions
  (the one service every sandbox binds), keyed by reply Messenger.
- Tests for inc routing + declaration gating; updated capability/router tests
  that asserted the old "inc/theme/notify are unknown" behavior.

NOTE: napplets run foreground-only/one-at-a-time, so cross-napplet delivery is
usually a no-op in practice; the bus is correct if sessions ever overlap. It is
app-wide (not author-scoped) — a future refinement could namespace topics by
author. Unblocks feed/profile-viewer/chat/bot. See the plan doc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-23 00:37:44 +00:00
Vitor PamplonaandClaude Opus 4.8 07d25c02db fix(napplet): serialize consent prompts so concurrent requests don't drop
When a napplet issued several consent-gated calls at once (the common case: it
reads relays + storage + identity on load), each launched a NappletConsentActivity
concurrently. The host can only show one, so the rest were delivered to the
single-top activity and silently dropped — their broker calls hung forever
(storage stuck pending; a subscription's consent lost, yielding 0 events).

Gate the consent-prompt path behind a Mutex on the (per-account, reused) broker
so prompts queue one at a time. After taking the lock, re-read the ledger so a
sibling request for the same capability honors the just-recorded grant instead
of prompting again. Only the prompt is serialized — execute() and already-granted
paths stay parallel. Per-use capabilities (payments) still re-prompt every time.

Adds a regression test asserting 5 concurrent same-capability requests yield
exactly one prompt and never two dialogs at once.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:28:33 -04:00
Claude df15414424 Merge remote-tracking branch 'origin/main' into claude/awesome-pasteur-xwiwad
# Conflicts:
#	amethyst/src/main/res/values/strings.xml
2026-06-22 14:50:16 +00:00
Claude cd2bae081e feat(napplet): live subscription tail, multi-filters, resource.cancel
Implements the remaining tractable conformance follow-ups:

- Live subscription tail: relay.subscribe now opens a real
  client.subscribe whose SubscriptionListener streams relay.event
  (stored + live), relay.eose, and relay.closed pushes keyed by subId,
  instead of a one-shot snapshot. relay.close unsubscribes (tracked in
  liveSubs, torn down in onDestroy). The broker only authorizes the
  subscription (RELAY consent) and returns Subscribed; the host owns the
  live stream. Shim dispatches relay.closed too.
- Multi-filters: relay.query/subscribe honor every filter in filters[],
  not just the first (decodeFilterList; gateway query(List<Filter>);
  queryEvents unions across filters; max limit applied).
- resource.cancel: accepted at the host edge as a no-op Done.

Conformance tests extended (multi-filter decode, relay.closed push);
commons:jvmTest and the amethyst napplet suite pass.

Still open: identity getList/getZaps/getBadges + onChanged (shapes
underspecified), the keys.action push (needs a host trigger UI), the
resource nostr: scheme, inc + the niche domains, and on-device
verification.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-21 21:43:36 +00:00
Claude 24638cc50c feat(napplet): implement the identity read API
Wire the upstream identity.* read methods (beyond getPublicKey) to the
active Account, returning JSON gated by the IDENTITY consent:

- getProfile  -> kind-0 metadata content
- getRelays   -> NIP-65 { "<url>": { read, write } } map
- getFollows  -> kind-3 followed author pubkeys
- getMutes    -> NIP-51 mute-list user pubkeys (decrypted)
- getBlocked  -> NIP-51 block-list user pubkeys (decrypted)

getList/getZaps/getBadges route through but degrade to Unsupported for
now; onChanged stays a client-side no-op until the live push channel
lands. Reads are public data only — never key material — and remote/
external signers still self-gate the consent.

Adds NappletRequest.IdentityRead, NappletResponse.Json, a
NappletIdentityGateway collaborator, codec round-trip for identity.*,
the shim methods, and unit tests. commons:jvmTest and the amethyst codec
test pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-21 14:26:27 +00:00
Claude b43c1c9eda fix(napplet): align shell to verified @napplet/shim 0.15+ contract
Reverse-engineered the authoritative @napplet/shim (npm v0.16.0) and
corrected the napplet host to its real wire contract. The prior commit
carried guessed method names that would break real ecosystem napplets.

- Signing model: napplets never get a sign() (per upstream "signing and
  encryption are mediated by the shell"). Dropped keys.signEvent /
  keys.nip04* / keys.nip44*. relay.publish now takes an UNSIGNED template
  and the broker signs it as the user, returning the signed event;
  added relay.publishEncrypted (shell encrypts + signs + publishes).
- keys -> keyboard/command actions (registerAction/unregisterAction/
  onAction), client-side no-op stubs (not yet wired to the host keyboard).
- storage: get/set/remove -> getItem/setItem/removeItem; added storage.keys
  end-to-end (protocol, broker, DataStore, shim).
- resource.bytes returns a Blob (shim builds from {bytes, mime}).
- shell.supports gains optional protocol arg; added shell.ready/onReady/
  services stubs.
- relay.subscribe wired (initial matches; live tail still a follow-up).
- value.payInvoice and upload.blob kept as clearly-marked Amethyst-specific
  extensions (no upstream equivalent; real napplets never call them).

Broker now defers per-signature consent to remote/external signers via a
signsAsUser flag (publish/publishEncrypted) instead of an IDENTITY/KEYS
capability check. Tests updated; commons:jvmTest and the amethyst codec
round-trip test pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-20 19:39:36 +00:00
Claude 5ca44e277f feat(napplet): align with the upstream napplet SDK (envelope, namespaced API, domains)
Acts on the ecosystem audit so real napplets built against @napplet/web can run.

Wire: switch to the upstream envelope {type:"<domain>.<action>", id} →
{type:"…​.result", id, ok, …} across the JS shim, NappletProtocolJson, and the host
shuttle (host forwards the verbatim envelope, injects id on the reply).

API: rewrite the injected window.napplet.* to the namespaced SDK surface —
shell.supports, identity.getPublicKey (+onChanged stub), keys.{signEvent,nip04*,
nip44*}, relay.{publish,query,subscribe}, storage.{get,set,remove}, value.payInvoice,
resource.{bytes,bytesAsObjectURL}, upload.blob. subscribe currently returns the
initial matches via query (live tail is a follow-up).

Capabilities: split to the domain model — SHELL, IDENTITY, KEYS, RELAY, STORAGE,
VALUE, RESOURCE, UPLOAD (was IDENTITY/RELAY/WALLET/STORAGE/NET). shell.supports is
answered with no consent, reflecting declared+brokered domains; keys (signing) split
from identity (pubkey); signer-self-gating now covers both.

New ops: resource.bytes (https/data, broker-fetched, Tor-routed, consent-gated).
upload is wired end-to-end but its Android Blossom gateway is left unprovided
(Unsupported) pending the Uri + auth-event + server-selection integration.

Codec moved to java.util.Base64 (real on minSdk 26 and in JVM tests). Permissions
screen + capability labels updated to the 8 domains; new consent/label strings
localized. Broker + codec + capability tests updated/added.

:commons:jvmTest, :amethyst:testPlayDebugUnitTest (codec), and
:amethyst:compileFdroidDebugKotlin pass; spotless clean. See
plans/2026-06-20-napplet-ecosystem-audit.md (Update section) for the remaining gaps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-20 16:54:06 +00:00
Claude c999d2ac73 feat(napplet): permissions management screen
A modern Material3 screen to review and revoke the permissions napplets hold.

Data layer (commons, tested): NappletPermissionStore gains all() (enumerate
persisted grants by coordinate) and remove(coordinate, capability); the ledger
gains allPersistedGrants() and revoke(identity, capability). DataStore actual
implements both (capability is the final space-delimited token of each key).

UI (amethyst): NappletPermissionsScreen renders one ElevatedCard per napplet —
resolved title + author, and a row per capability with an icon, label, and a
control: a Switch (Allowed/Blocked) for normal capabilities, or a "Blocked"
indicator for per-use ones (payments only ever persist a DENY). Each row has a
revoke action; each card a "Forget this napplet" (revokeAll). Empty state with a
shield. Reads/writes the same DataStore the broker uses, so changes take effect
immediately. Reached via a "Manage permissions" action on the Napplets top bar
(Route.NappletPermissions).

commons ledger tests added for allPersistedGrants + single-capability revoke.
:commons:jvmTest and :amethyst:compileFdroidDebugKotlin pass; spotless clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-20 15:28:28 +00:00
Claude 916a624ddb feat(napplet): capability-aware consent — per-payment, signer-aware identity, foreground-only
Refines the uniform consent model now that wallet + identity carry different risk.

Payments (WALLET): NappletCapability.requiresPerUseConsent — every payInvoice
re-prompts (with the decoded sats amount); the dialog drops "Always allow" and the
broker downgrades any always/session grant to one-shot, so a payment grant is
never persisted. No silent spend.

Identity: gated by us only when Amethyst holds the key (NostrSignerInternal). For
remote (NIP-46) / external (NIP-55) signers the broker defers to the signer's own
per-request consent instead of double-prompting — while still honoring a standing
per-napplet DENY and the requires declaration. The sign prompt shows a kind +
content preview.

Foreground-only execution: NappletHostActivity pauses the WebView's JS/timers in
onPause and resumes in onResume, so a backgrounded applet can't fire a
sign/decrypt/pay request whose prompt would be confused with Amethyst's own UI.
This is the precondition that makes deferring identity to an external signer safe.

commons broker tests added: wallet prompts every time and is never persisted;
external signer defers identity without prompting but still honors a standing DENY.

:commons:jvmTest and :amethyst:compileFdroidDebugKotlin pass; spotless clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-20 15:15:10 +00:00