Add `amy nsite publish <dir>` and `amy napplet publish <dir>` so a static-site
or napplet directory can be shipped to Nostr in one command, building on the
new CLI/Blossom infrastructure.
- commons (jvmMain) StaticSitePublisher: the reusable "upload a tree" half —
walks a directory (or single file), content-addresses each file, BUD-02
signed-uploads it via BlossomClient, and maps it to an absolute web path
(/index.html, /assets/app.js, …). Returns the NIP-5A path→sha256 tags.
- cli StaticSitePublish: thin shared flow — uploads via the commons publisher,
hands the path tags to a kind-specific builder, signs with the account key,
and broadcasts. nsite builds 15128/35128 (+ x aggregate); napplet builds
15129/35129 (aggregate + requires already added by the quartz builder).
- nsite/napplet `publish` verbs wired into their routers.
Test harness README now recommends `amy napplet publish tools/napplet-test`,
keeping publish.sh as a no-amy fallback. Unit test covers the path mapping;
cli + commons build green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Inbound NIP-61 nutzaps (kind:9321) are messages other people send *to*
the user, so per the NIP-65 outbox model they must be read from the
user's inbox-side relays, not their outbox. The Cashu subscription used a
single relay set (outbox) for both the user's own NIP-60 events and
inbound nutzaps, so a sender following NIP-61 correctly (publishing to
the relays advertised in the recipient's kind:10019, or to the
recipient's NIP-65 inbox) could be missed.
Split the subscription relay sets per filter:
- own NIP-60 wallet/token/history events keep reading from outbox,
where the user published them (needed to restore on a fresh device);
- inbound kind:9321 nutzaps now read from the union of the user's
NIP-65 inbox + DM relays + the `relay` tags in the user's own
kind:10019. The last one is NIP-61's source of truth for "where to
send me nutzaps" and may be written by another client to a relay set
unrelated to our NIP-65 lists, so we listen there too.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHcZ2gv8ro9Q2bEiTSiHD8
Bind a napplet's registered keyboard/command actions to real hardware-key
combos and fire them back as keys.action pushes, so napplet.keys.onAction
actually triggers (previously registration was acked but never fired):
- protocol: RegisterAction / ActionRegistered carry the key combo (binding,
from the SDK's action.defaultKey); the codec decodes defaultKey and echoes
binding in the result; encodeKeysAction push envelope added.
- broker: registerAction returns the honored binding (still no key access for
the applet; KEYS stays a declared-only, no-prompt capability).
- NappletKeyActions (host): a registry that parses combos like "Ctrl+Shift+S"
/ "Cmd+Enter" / "F2" and matches them against KeyEvents.
- NappletHostActivity: binds an action only after the broker authorizes it
(from the keys.registerAction.result), unbinds on keys.unregisterAction, and
overrides dispatchKeyEvent to turn a matching combo into a keys.action push
via the shell bridge. Unmatched keys fall through to the WebView, so the
applet's own text inputs keep working. Touch-only devices simply never match.
shim already passed the full action (incl. defaultKey) and wired onAction to
the keys.action push, so no shim change was needed. Conformance test now
covers the defaultKey decode + binding round-trip; all napplet suites green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Wire napplet.identity.onChanged end to end so an applet is notified when the
active user's public key changes (account switch / connect / disconnect):
- shim: onChanged registers a handler and opens a watch (identity.watch) on the
first handler; closing the last one stops it (identity.unwatch). identity.changed
pushes are dispatched to the handlers with the new pubkey.
- router: identity.watch (gated on the IDENTITY declaration) / identity.unwatch
become WatchIdentity / UnwatchIdentity outcomes — a push subscription, like
relay.subscribe, that never reaches the broker.
- NappletIdentityWatch (host): collects the active account's pubkey from the
session manager and pushes identity.changed on each subsequent change (the
current value is dropped — the applet already has it via getPublicKey). Torn
down on unwatch and on service destroy.
- codec: encodeIdentityChanged push envelope.
Router unit tests cover watch (declared/undeclared) and unwatch; commons tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Fill in the identity reads that previously fell through to Unsupported, and
add nostr: resolution to resource.bytes — both reading from what Amethyst
already has locally, matching the @napplet/nap shapes.
identity (AccountIdentityReader):
- getList(listType): public tag values (e/a/p/t/word/r/emoji) of the user's
NIP-51 replaceable list of that type (bookmarks 10003, pins 10001, mute
10000, interests 10015, communities 10004, channels 10005, emojis 10030).
- getZaps: ZapReceipt[] {eventId, sender, amount, content?} from kind-9735
receipts p-tagging the user in the cache.
- getBadges: Badge[] {id, name?, description?, image?, thumbs?, awardedBy}
from kind-8 awards p-tagging the user, resolved against their kind-30009
definitions in the cache.
resource.bytes (NappletResourceFetcher):
- nostr: URIs (NIP-19) resolve to the referenced event's JSON
(application/json). nembed carries it inline; note/nevent/naddr resolve
from the cache then a bounded relay fetch; npub/nprofile resolve the
author's kind-0. Still no direct network for the applet.
The wire codec already routed these (generic IdentityRead + identityResultField,
ResourceBytes), so no protocol change was needed. Commons napplet tests stay green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Two new nak-parity commands:
- `amy admin RELAY METHOD [args]` — NIP-86 Relay Management API over NIP-98
HTTP auth. Full method set: ban/unban + allow/unallow pubkey, ban/allow
event, allow/disallow kind, block/unblock IP, change name/description/icon,
and all list-* queries. Reuses quartz's Nip86Client (request build + NIP-98
auth + parse) and the Nip86Retriever HTTP path — extracted from amethyst to
commons/jvmAndroid so amy and the Android relay-management screen share it.
- `amy serve [--host --port --path --db --admin]` — runs a Nostr relay by
embedding geode (the standalone Ktor relay on quartz's relay-server code).
In-memory by default (ephemeral, like nak serve); --db FILE for SQLite. The
account's own pubkey is always an admin, so `amy admin` works against it out
of the box. cli gains a :geode dependency (geode depends only on :quartz) and
kotlinx-serialization-json (to render NIP-86 JSON results).
Verified end-to-end: `amy serve` + `amy admin ws://127.0.0.1:PORT
supported-methods|change-name|ban-pubkey|list-banned-pubkeys` round-trip
cleanly over real HTTP + NIP-98 against the live geode relay.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Two shared extractions so the future desktop host reuses the exact same
sandbox and feed UI as Android, with no chance of drift:
Shared web contract:
- Move shell.html + shim.js into commons composeResources
(files/napplet/), read via Res.readBytes on any platform.
- New NappletWebContract (commons/commonMain) single-sources the whole
web contract: the shell/shim loaders plus the internal origin/host/URLs
and both Content-Security-Policies (SHELL_CSP, APP_CSP). The Android
host preloads the bytes in onCreate and reads every origin/CSP constant
from NappletWebContract instead of its own duplicated constants and
assets.open() calls.
Shared feed card:
- New StaticWebsiteCard (commons/.../ui/note) renders the inert NIP-5A /
NIP-5D preview card: self-contained with commons compose-resource
strings, LocalUriHandler for links, and inlined card chrome. It takes
an isNapplet flag and an onOpen launch slot, so it never executes applet
code itself.
- amethyst's note/types/StaticWebsite.kt becomes thin event->card
adapters that supply the sandboxed onOpen launch.
Card strings move to commons strings.xml. Both napplet test suites
(commons jvmTest + amethyst) stay green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Move the decode → broker → encode orchestration out of Android's
NappletBrokerService.handleMessage and into a pure, transport-free
NappletRequestRouter in commons/jvmAndroid. It returns a small Outcome
(Ignore / Reply / OpenSubscription / CloseSubscription / Push) that each
host acts on, so the Android service and the future desktop host share
the routing brain and can't drift on wire behavior.
The service now resolves the broker and dispatches on the Outcome,
supplying only the Messenger transport and the live relay subscription.
openLiveSubscription takes the decoded filters from the router instead of
re-decoding the payload, and the now-redundant process() is removed.
Unit-tested in commons/jvmTest (NappletRequestRouterTest).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Add the offline Cashu command tier to amy, all driven by the shared
commons wallet code so amy exercises the same path as the Android app:
amy cashu wallet create [--mint URL] [--mints a,b] [--privkey HEX] [--relay r1,r2]
amy cashu wallet show
amy cashu wallet export-key
amy cashu wallet destroy
amy cashu mint ping URL (stateless)
amy cashu mint info URL (stateless)
amy cashu balance [--mint URL]
- create/destroy reuse commons CashuWalletOps.publishWalletEvents /
deleteWallet; show/balance reuse the CashuWalletReader projection over
the local event store; mint ping/info hit quartz's MintHttpClient.
- Context gains cashuOps() (wired to the file NUT-13 counter store + a
per-run seed cache) and cashuSnapshot(); DataDir gains cashu.json.
- Extraction D: CashuKeysetCounterStore contract in commons +
FileCashuKeysetCounterStore (atomic ~/.amy/<account>/cashu.json).
PRs 4 of cli/plans/2026-05-28-cashu-cli.md (extractions A–D + offline
tier). receive/send/maintenance/mint-rec + interop harness still pending.
Verified end-to-end against mint.minibits.cash and live relays.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Add a pure, stateless CashuWalletReader in commons that projects a stream
of NIP-60/61/87 events into a WalletSnapshot (wallet/nutzap-info events,
decrypted mints, unspent token entries, history, pending quotes, nutzaps,
recommendations, plus balance + per-mint balances).
Android's CashuWalletState keeps its incremental dirty-tracking and
StateFlow plumbing but now delegates the two tricky computations —
del-rollover over decrypted tokens (computeUnspent) and the
destroyed/expired pending-quote filter (computePending) — to the shared
reader instead of carrying its own copies. amy will call project() once
per command over its event store.
Extraction C of cli/plans/2026-05-28-cashu-cli.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Move the NIP-60/61 wallet orchestration layer (CashuWalletOps + its
result types: TokenEntry, MintQuoteStarted, MintCompleted, MeltCompleted,
SendTokenCompleted, RedeemCompleted, NutzapSent, RestoreOutcome,
MigrationResult, CreatedWallet, describeMintError) out of amethyst into
commons/jvmAndroid/cashu/ops.
The class already had zero Android dependencies — it takes signer,
publish, okHttpClient, secretFactory, and the NUT-13 counter callbacks as
constructor params. It lands in the jvmAndroid source set (not commonMain)
because it composes quartz's jvmAndroid CashuMintOperations/MintHttpClient
and uses ConcurrentHashMap. Both Android (amethyst) and the JVM CLI (amy)
can now drive the exact same wallet code path.
This is Extraction B of cli/plans/2026-05-28-cashu-cli.md. Android
callers (CashuWalletState, the wallet ViewModels, AccountViewModel)
updated to the new package; no behavioral change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Set the desktopApp up to host napplets/nsites by maximizing the shared
core and documenting the edge it must build.
- Moved NappletProtocolJson (the wire codec) from amethyst to
commons/jvmAndroid (package ...commons.napplet.protocol), next to the
NappletRequest/Response types it marshals. It depends only on quartz +
kotlinx.serialization + java.util.Base64 (Android 26+/JVM), so a future
desktop host marshals through the identical object — request/result/push
shapes can't drift between platforms. amethyst host/service/tests updated
to import it; tests stay in amethyst and still exercise it.
- Added desktopApp/plans/2026-06-21-napplet-desktop-host.md: what's already
shared (broker, protocol, codec, resolver, the shell.html/shim.js web
contract), what desktop must build (KCEF/JCEF engine, custom-scheme
serving, isolation, transport, gateways, UI), the decisions to make, a
security-parity checklist, and recommended further extractions
(NappletRequestRouter, shared web assets, the inert feed card).
commons:jvmTest and the amethyst napplet suite pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
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
Closes the 🔴 items from the conformance audit so stock @napplet/shim
napplets interop:
1. Shell handshake: the host answers shell.ready with shell.init
{capabilities:{domains,protocols},services} built from the declared
domains (NappletProtocolJson.encodeShellInit), so supports() works.
2. Id-less messages: onShellMessage no longer drops messages without an
id — shell.ready is answered locally and fire-and-forget messages get
a synthetic id so they reach the broker.
3. keys: keys.registerAction/unregisterAction decode and the broker acks
them (declared-gated, no consent) so registerAction() resolves; the
shim dispatches the keys.action push. (Global-key binding is a
follow-up — keys.action isn't emitted yet.)
4. upload: realigned to upload.upload{request:{data,mimeType,filename}} →
rich UploadResult{ok,uploadId,status,url,sha256,size,mimeType};
shell.html inlines the request Blob as base64 so it survives the
bridge; the gateway uploads via the app's BlossomUploader to the
user's kind:10063 server with a signed auth event.
NappletSdkConformanceTest's gap guards flip to conformance assertions for
shell.init/keys/upload; inc stays the one documented gap. commons:jvmTest
and the amethyst napplet suite pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Warms the next/previous few feed notes off the main thread so media and
link previews are ready before the user scrolls to them, and pre-parses
rich-text bodies into the shared cache so scroll-time composition is a
cache hit instead of a UI-thread parse.
Per upcoming note (off Dispatchers.Default, deduped via a per-feed set,
cancelled on a new visible range):
- Pre-parses TextNote/Comment bodies through CachedRichTextParser using the
renderer's exact key, so the composition reads a cached parse. Other kinds
still get their media/links discovered, just without the render-cache warm.
- Prefetches images into Coil — inline content images, NIP-92 imeta blobs,
NIP-94 file-header url tags, and video poster frames — and records each
decoded aspect ratio in MediaAspectRatioCache so the box is reserved on
first layout (no jump). Video poster ratios seed the video URL's box too.
- Warms OpenGraph/link previews via UrlCachedPreviewer.
Gated on showImages()/showUrlPreview() so it honors data-saver/Wi-Fi-only.
Video bytes are deliberately not prefetched (large, HLS, player pool already
starts fast).
Wired centrally at the two feed dispatchers (RenderFeedContentState,
RenderFeedState) so every list feed routed through them is covered without
per-screen wiring, plus direct hooks for the custom-render feeds (hashtag,
profile notes) and a LazyGridState variant for the grid feeds (gallery,
products, discover).
CachedRichTextParser is now content-addressed (memoized contentHash on
ImmutableListOfLists) so an off-thread pre-parse maps to the same entry the
renderer looks up, and its cache grows 50 -> 500 to hold the prefetch +
multi-feed working set.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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
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
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
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
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
Closes the highest-leverage gaps from the completeness report (items 2–5).
Capability enforcement (#2):
- The broker now refuses any request whose capability is not in the manifest's
`requires`. The host resolves `requires` to a declared capability set and sends
it with every IPC request; the broker denies undeclared capabilities before any
consent prompt. Per-operation consent summaries added for the new ops.
Read capability (#3):
- New QueryEvents request (RELAY capability) → NappletRelayGateway.query, answered
from LocalCache (account.cache.filter). window.napplet.queryEvents(filter) added.
nsite host wiring (#4):
- NappletLauncher generalized to launch any NIP-5A site from paths+servers, so
nsites (kinds 15128/35128) open in the sandbox too. The nsite card
(StaticWebsite) gets an "Open" button; nsites declare no capabilities, so the
broker refuses everything and they render as inert static content.
Storage + wallet (#5):
- STORAGE fully implemented: StorageGet/Set/Remove + DataStoreNappletStorage,
namespaced per applet coordinate. window.napplet.storage.{get,set,remove}.
- WALLET modeled with PayInvoice + NappletWalletGateway, but kept Unsupported (no
gateway provided) — no money path ships until verified end-to-end.
- Inter-applet messaging and live (non-cache) relay query remain v2.
commons broker tests cover declaration enforcement, query, and storage round-trip.
: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
The URL detector stripped a single trailing punctuation char unconditionally,
which dropped the closing ")" from legitimate URLs such as
https://en.wikipedia.org/wiki/Bitcoin_(disambiguation).
Make the trailing strip balance-aware: a trailing ")", "}" or "]" is kept when
the URL contains its matching opener (balanced), and only stripped when it is
unbalanced wrapping/sentence punctuation (e.g. "(see example.com)" or
"http://test.com)"). Commas without surrounding spaces were already kept inside
paths; this also adds "]" to the begin/end punctuation sets so an unbalanced
bracket is handled symmetrically with parens and braces.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AzZzVcMcuzjSdhD3xqCE87
Adds the platform-agnostic core for hosting untrusted napplet (NIP-5D) /
nsite (NIP-5A) web content behind a hard trust boundary, so applet HTML/JS
can never reach the nsec, app storage, or LocalCache.
The Android host runs the WebView in a separate OS process (:napplet) that
holds no secrets and brokers every dangerous operation over IPC to the main
process. This commit lands the verifiable heart of that boundary in commons
commonMain (KMP-pure, fully unit-tested):
- NappletCapability + NAP-domain mapping (default-deny on unknown domains)
- NappletIdentity keyed by addressable coordinate (grants survive updates)
- NappletPermissionLedger / GrantState / store (persistent vs session vs once;
standing DENY is authoritative)
- NappletRequest/NappletResponse wire protocol (no response carries key bytes)
- NappletBroker: the only holder of the signer; enforces consent, signs as the
user only, refuses to publish foreign or unsigned events
Architecture, process model, IPC schema, WebView hardening, and consent UX are
documented in amethyst/plans/2026-06-19-napplet-sandbox-host.md. The Android
:napplet process, WebView host, AIDL broker, and consent UI are the next phase.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
The Blossom auth-header encoding (`Nostr <base64-event>`), the `/upload`
endpoint path, and the `X-Reason` failure header were each re-derived in
both the commons JVM `BlossomClient`/`BlossomAuth` and the Android
`BlossomUploader`, using two different Base64 APIs. Move these
protocol-level facts into the quartz `nipB7Blossom` package, where the
rest of the Blossom protocol lives:
- `BlossomAuthorizationEvent.toAuthorizationHeader()` / `rawToken()` +
`AUTH_HEADER_SCHEME`, mirroring NIP-98's
`HTTPAuthorizationEvent.toAuthToken()` that Blossom auth reuses.
- new `BlossomServerUrl` with `upload()` / `blob()` endpoint builders and
the `REASON_HEADER` constant.
Both transports now call these helpers instead of hand-building strings.
No behavior change for upload (existing desktop BlossomClientTest still
green); the Android delete URL now omits the trailing dot when no file
extension is known, matching BUD-02's `DELETE /<sha256>`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJgwV4Y99brVa97v7p3jJb
Wires the quartz NIP-5A resolver end-to-end so it can be exercised against
real manifests (interop / agents), without building the security-sensitive
WebView shell yet.
- commons BlossomClient: add download(url) — a Blossom GET returning raw bytes
(null on non-2xx; connection failures propagate so callers try the next
server). Does not verify the hash; that is the resolver's job.
- cli NsiteCommands: `amy nsite fetch AUTHOR [--d ID] [--path P] [--server …]
[--relay …] [--out FILE] [--timeout SECS] [--max-inline-bytes N]`. Fetches
the manifest (kind 15128 root, or 35128 named with --d) from relays, then
resolves one path through StaticSiteResolver, downloading from the manifest's
Blossom servers (plus any --server fallbacks) and accepting only the first
blob whose sha256 matches the manifest pin. Emits the verified path's bytes
(inlined for small text, or written to --out) with hash/server/content-type,
or a structured not_found / path_not_found / unresolvable error.
Thin-assembly only: all resolution + verification stays in quartz, the byte
fetch in commons. Smoke-tested offline: bad-args, help, and a dead-relay run
that resolves cleanly to not_found in both text and --json modes.
Also converts the StaticSitePathLookup file-overview KDoc to a plain block
comment to satisfy ktlint no-consecutive-comments.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdAJMbnHJfiMY7UcS99T6C
Follow-up to the V4Encoder move. Quartz could encode a cashuB string but
could not parse cashuA/cashuB back, and the parsing lived in amethyst even
though it is pure NUT-00 wire-format protocol. Worse, commons.RichTextParser
already detects cashuA/cashuB words while the parser sat up in the app, so
Desktop (its own rich-text viewer) could not parse a received token at all.
Consolidate the legacy out-of-band redeem stack onto quartz:
- new quartz CashuTokenB64Parser parses cashuA (standard-Base64 JSON, rewritten
off Jackson onto kotlinx.serialization to satisfy quartz's no-Jackson rule)
and cashuB (Base64URL CBOR, reusing the V4Token models), returning quartz
types. It is the inverse of V4Encoder.
- move the CashuToken container model from commons to quartz, switching its
proofs from the duplicate commons Proof (field C, amount Int) onto the
canonical quartz CashuProof (field c, amount Long). The duplicate Proof
type is deleted.
- delete amethyst V3Parser/V3Token/V4Parser; CashuParser/CachedCashuParser
stay as thin amethyst adapters (off-main-thread guard + GenericLoadable +
LruCache) over the quartz parser.
- this removes the manual Proof -> CashuProof conversion shims that
MeltProcessor and CashuWalletViewModel previously carried.
Tests: full cashuA + cashuB vector coverage moves to quartz commonTest
(CashuTokenB64ParserTest, runs on JVM) plus an encode/parse round-trip; the
superseded amethyst CashuV4ParserTest is removed and CashuBTest stays as the
adapter integration test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UMNKix4qEfiAPP9s2a4gB
Compose Multiplatform string resources don't use Android res/values
escaping, so \' rendered literally as didn\'t on the DM history card.
Use a plain apostrophe to match the sibling new_key_continue_button string.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Strips the BgRelayTrace instrumentation added while diagnosing the
background relay-count issues and restores the production grace period.
- LifecycleAwareKeyDataSourceSubscription: UNSUBSCRIBE_GRACE_MILLIS back to
30s, drop the per-subscription label + logs, refresh the doc to describe
the LifecycleEventObserver detection.
- RelayPool: drop updatePool trace logs and the now-unused Log import; keep
the _connectedRelays prune (with a trimmed comment).
- BaseEoseManager: drop the per-assembler relay-count log + Log import.
- SubscriptionController: drop activeRelays(), which only fed that log.
The actual fixes stay: lifecycle-observer teardown detection, the
connected-set prune, and the notification-count throttle + fg/bg wording.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ukw6FJPFh3JKGXL532p3ae
PublicChatChannel.relays() was `info.relays?.toSet() ?: super.relays()`.
An empty (non-null) declared-relay list — `emptyList()?.toSet()` — yields an
empty set and short-circuits the elvis, so the channel reported zero relays
instead of falling back to the relays it was actually observed on. Both the
message-send path and the broadcast path (computeRelaysForChannels /
wantsBroadcastRelays) read relays(), so the message was published to nowhere
while a manual broadcast still reached the user's personal relays — matching
the reported symptom.
Treat an empty declared list like "no declared relays" via ifEmpty, so it
falls back to observed relays. Adds PublicChatChannelRelayTest covering the
declared-relay round-trip, message->channel resolution, and the empty-list
fallback regression.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYtHYEob2THu74inTxZxCh
A device log showed the foreground feeds (and ~150 relays) staying
connected for a full ~60s after the app was paused, then collapsing to
the 11-relay floor all at once:
11:26:02 HomeOutboxEventsEoseManager — keys=2, relays=344 (paused here)
… 60s of silence …
11:27:02 grace-start(HomeFilterAssembler) — lifecycle=CREATED
11:27:02 updatePool done — flowConnected=9, inPool=11
The lifecycle-aware subscription detected ON_STOP by collecting
lifecycle.currentStateFlow on Dispatchers.Default. Backgrounded, that
collector wasn't resumed until the next NostrClient keep-alive tick
(KEEP_ALIVE_INTERVAL_MS = 60s), so teardown — and the relay disconnects
it drives — lagged a minute behind the actual pause.
Switch detection to a main-thread LifecycleEventObserver, which fires
synchronously during onStop. Only the grace delay still runs on the
background scope (so it isn't gated by the stopped UI frame clock).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ukw6FJPFh3JKGXL532p3ae
After fixing the stale connected-count, the background footprint settles
at ~25 relays (desired=22) — higher than the inbox+DM target. Add a
per-EoseManager log (assembler name -> key count + distinct relay count)
so we can attribute the 25 to specific always-on loaders (metadata/drafts
on homeRelays, gift-wrap history, marmot groups, notifications) and trim
precisely instead of guessing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ukw6FJPFh3JKGXL532p3ae
Phase 2 of relay-latency-health: hook the Phase 1 tracker into the existing
RelayHealthStore lifecycle and persist its rings via the existing
PreferencesRelayHealthPersistence so samples survive restarts.
commonMain:
- RelayLatencyProvider: small interface so the store can drive a tracker
that lives in jvmAndroidMain (the impl needs ConcurrentHashMap).
- RelayHealthSnapshot: optional `latencySamples` field. Default empty;
older saved snapshots load cleanly without it.
- RelayHealthStore now takes optional `latencyTracker` / `nip11Provider`
/ `authProvider` constructor params:
* exposes `latencySnapshots: StateFlow<ImmutableMap<Url, RelayLatencySnapshot>>`
— MutableStateFlow updated inside the existing 60 s reclassify tick
(one timer, not two — the tracker is scope-less and gets
`sweep(now)` called from reclassify).
* exposes `slowRelays: StateFlow<ImmutableMap<Url, SlowReason>>` —
derived via `_latencySnapshots.map(classifySlowRelays).stateIn(
scope, SharingStarted.Eagerly, persistentMapOf())`. The classifier
reads `nip11Provider()` / `authProvider` live, so paid/auth-only
relays only join the cohort once their auth completes.
* `init {}` restores persisted samples into the tracker; the
existing `schedulePersist()` now bundles `tracker.samplesForPersistence()`
into the saved snapshot via a new private `snapshotForPersist()`
helper. The same helper feeds the final flush in `close()`.
No new dispatcher / scope / timer — everything piggybacks on the
existing infra (single SupervisorJob, 5 s persist debounce, 60 s tick).
jvmAndroidMain:
- RelayLatencyTracker now implements RelayLatencyProvider. Overrides drop
the inline `System.currentTimeMillis()` default; callers from commonMain
pass `TimeUtils.nowMillis()` explicitly.
desktopApp (jvmMain):
- PreferencesRelayHealthPersistence persists per-relay latency rings in
separate keys (`lat_<account-prefix>_<sha256(url)[..16]>`) so the 8 KB
Preferences ceiling on the main `health_<account>` key isn't blown by a
user with many relays. Each key holds one relay's four metric rings as
`wss://relay.url\tok:csv|eose:csv|fr:csv|ping:csv`. On save, keys for
relays no longer in the snapshot get removed so the prefs node doesn't
grow unboundedly across account churn.
Notes:
- Persistence still uses the existing 5 s debounce path. The deepened plan
called for 30 s for `lat_*` keys; deferring that micro-optimization
until we observe write thrash in practice. The cap on writes is
one-rewrite-per-5s-of-activity which matches what the existing snooze
persistence already does, so latency adds zero new flush events.
- Tracker is wired only when a `RelayLatencyProvider` is passed to the
store. Existing tests / Android continue to compile and run with
latency unconfigured — `latencySnapshots` stays empty and `slowRelays`
derives to empty. Desktop wiring lands in Phase 3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 of the desktop relay-latency-health feature: add a rolling-window
latency tracker that decorates the quartz RelayConnectionListener, plus a
pure classifier that flags relays whose per-metric p50 exceeds 2× the cohort
median. No store integration or UI yet — those come in follow-up commits.
commons commonMain (CLI-safe, no Compose runtime, no JVM-only deps):
- LatencyMetric: OK_ACK / EOSE / FIRST_RESULT / PING
- MetricSample: @Immutable (p50Ms, count)
- RelayLatencySnapshot: @Immutable, backed by ImmutableMap so strong
skipping engages when unchanged rows are re-emitted
- SlowReason: @Immutable (metric, relayP50, cohortP50, multiplier)
- HealthReason sealed interface: Unresponsive(gap) | Slow(SlowReason)
- classifySlowRelays(): pure. Honors NIP-11 auth_required /
payment_required (paid/auth-only relays are excluded from both cohort
and target until auth completes — otherwise they'd be perpetually
flagged while CLOSED'ing anonymous queries).
commons jvmAndroid (ConcurrentHashMap is JVM-only):
- LatencyRingBuffer: fixed-capacity (default 50) IntArray ring,
synchronized push, snapshotMedian / snapshotSamples / restore.
- RelayLatencyTracker: pending-eventId / pending-subId / firstResultSeen
maps + per-(relay, metric) ring buffers. Handles every pairing rule
the deepened plan called out:
* onSent EventCmd → record eventId timestamp
* onSent ReqCmd → record subId timestamp; clear firstResultSeen
* onSent CloseCmd → drop pending subId (no sample) — prevents
ComposeSubscriptionManager's sub-id reuse from pairing late
events with a new REQ
* success=false → no-op (websocket buffer was full)
* OkMessage → pair by eventId, push OK_ACK
* EventMessage → first-only, push FIRST_RESULT
* EoseMessage → pair by subId, push EOSE
* ClosedMessage → drop pending (fast negative response, not a
latency signal — was previously recording 300s TTL samples for
any auth-required relay)
* onConnected → push PING
* onDisconnected → drop all pending (no TTL samples)
* sweep(now) → TTL-expire pending entries (60s OK / 300s REQ),
record TTL value as the sample
AUTH retries: the second onSent overwrites the timestamp, so samples
reflect the retry leg — matches the user's mental model of "speed of
the actual publish". Pending maps are size-capped at 256 entries per
relay as a safety net against adversarial relays. Tracker owns no
CoroutineScope — RelayHealthStore drives sweep + snapshot from its
existing 60s reclassify tick (Phase 2).
- RelayLatencyListener: thin RelayConnectionListener decorator,
installInto / uninstallFrom paralleling RelayHealthListener.
Tests:
- LatencyRingBufferTest (7): wrap, median odd/even, restore from larger
or smaller arrays, chronological snapshotSamples.
- RelayLatencyTrackerTest (13): OK pairing, EOSE + FIRST_RESULT pairing,
success=false ignore, CloseCmd drops pending, ClosedMessage drops
pending, AUTH retry overwrites timestamp, disconnect drops all,
sweep TTL semantics (OK vs REQ), FIRST_RESULT only sampled when not
yet seen, ping, per-relay isolation, 256-entry cap, restore
round-trip.
- ClassifySlowRelaysTest (9): empty, Tor short-circuit, cohort < 2,
2× flag, count-below-min excludes from cohort, NIP-11 auth_required
excludes / includes once auth complete, payment_required excludes,
worst-metric-multiplier wins when multiple flag, exact-2× does not
flag (strict greater-than).
Note: a pre-existing RelayHealthStoreCloseTest case on the base branch
(fix/relay-health-threading-and-sleep-resume) hangs in advanceUntilIdle.
Not related to this commit; new tests pass cleanly with a tighter test
filter. Will revisit when integrating Phase 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tapping a reply in the thread view now collapses it instead of opening it
as a new thread. A collapsed reply renders only its author and the first two
lines of its content, and all of its descendant replies are hidden. An
ExpandMore indicator on the collapsed row (or tapping the row) reopens it
and restores its children.
- LevelFeedViewModel tracks the collapsed reply ids and exposes toggle/query
helpers; collapsing also flags the thread as interacted so it stops
auto-scrolling to the focused note.
- RenderThreadFeed filters out descendants of collapsed replies (the feed is
depth-first ordered, so descendants are the contiguous deeper-level items)
and renders the compact CollapsedNoteCompose for collapsed entries.
- NoteCompose gains an optional onClick override so the thread view can
intercept the tap for collapsing without changing default navigation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hwQiQbgboo8LScPdDHDJn
Investigating why backgrounding the app on the all-follows feed leaves
~172 outbox relays connected when only inbox + DM relays (~8) should
remain. The static teardown chain (lifecycle ON_STOP -> unsubscribe ->
client.unsubscribe -> PoolRequests.remove -> RelayPool.updatePool
disconnect) is correct, so this adds runtime tracing at the two decisive
hops to find where it stalls on-device:
- LifecycleAwareKeyDataSourceSubscription: log subscribe/grace-start/
unsubscribe/dispose with the assembler name (tag BgRelayTrace).
- RelayPool.updatePool: log desired/inPool/toRemove/connected counts.
Also drops UNSUBSCRIBE_GRACE_MILLIS 30s -> 0 as an experiment: if the
grace delay() was being starved on Dispatchers.Default once backgrounded
(Doze/app-standby suspends timers), unsubscribing immediately on ON_STOP
both proves and fixes the leak. To be reverted to a wakelock-safe grace
once confirmed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ukw6FJPFh3JKGXL532p3ae
Chatroom.addMessageSync evaluated `activeSenders + author` but discarded the
result, so `activeSenders` stayed permanently empty and
`Chatroom.senderIntersects()` always returned false. The Known-rooms filter is
`senderIntersects(follows) || hasSentMessagesTo(room)`, so with the follow path
dead a room only counted as Known once the user's own self-addressed NIP-17
gift wrap decrypted. Incoming DMs from followed contacts were misrouted to New
Requests, and the Known tab sat on the "Loading Feed" spinner (empty feed shows
the spinner until gift-wrap history exhausts — minutes with many relays/Tor).
Assign the new set. Prune/remove intentionally do not recompute activeSenders so
a room never flips Known->New when old messages are pruned.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
recordIncoming_after_close_does_not_schedule_persist called advanceUntilIdle()
while RelayHealthStore's init ticker (`while(true){ reclassify(); delay(60s) }`)
was still live on the shared StandardTestDispatcher scheduler. advanceUntilIdle()
chases that periodic delay forever, so the test spun at 100% CPU and never
returned — wedging :commons:jvmTest at "373 tests completed" and hanging the
pre-push hook (and leaving orphaned, CPU-pegging Gradle test workers behind).
Advance just past PERSIST_DEBOUNCE_MS and runCurrent() instead, so init's
debounced save fires for the baseline while the 60s ticker stays parked. The
post-close advanceUntilIdle() calls are fine — close() cancels the ticker.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to #3186, addressing the unresolved review feedback:
- RelayHealthStore.schedulePersist() wrapped the blocking save() in withContext(ioDispatcher)
so prefs.flush() no longer sits on the Compose composition thread on Desktop.
- close() now fires the final save on a detached IO-bound scope instead of blocking
the composition thread for ~50ms during account switch / app exit.
- @Volatile on persistJob/tickJob and a closed-flag guard so the relay-network thread
and composition thread no longer race on plain vars (and post-close work is dropped).
- desktopApp/Main.kt passes Dispatchers.IO to RelayHealthStore so persistence flushes
land on the IO dispatcher instead of Dispatchers.Default.
Plus a separate-but-related fix to the offline-banner-stuck-after-Mac-sleep issue:
NostrClient.keepAliveJob now tracks wall-clock overshoot of its scheduled tick.
If the OS suspended us (laptop lid closed, system sleep), delay() returns far
past its deadline and the OkHttp websockets we held are dead even though
BasicRelayClient.isConnected() still reads true until the next ping fails.
On a >5x interval overshoot, force relayPool.disconnect() + connect() instead
of trusting needsToReconnect(), so feeds resume without an app restart.
UrlParser.parseValidUrls filtered every detected URL through
isValidTopLevelDomain(), which requires the TLD's first character to be
an ASCII letter. IPv6 literal hosts are bracketed (e.g. [2001:db8::1])
and have no dotted TLD, so the whole bracketed host became the candidate
"TLD", starting with '[' and failing the check. As a result, valid
IPv6 URLs like http://[302:68d0:f0d5:b88d::bdb]/<hash> were dropped and
rendered as plain text instead of links.
The UrlDetector already validates the bracketed address as syntactically
correct IPv6, so accept bracketed hosts directly in isValidTopLevelDomain.