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
Feature-by-feature audit of our edge layer against the canonical
@napplet/nap@0.15.0 / @napplet/core@0.15.0 message types, plus a test
suite that pins the codec to the SDK's exact wire so drift fails CI.
Audit (plans/2026-06-21-napplet-sdk-conformance-audit.md) catalogs every
domain with the verified wire shapes and ranks the inconsistencies found:
- 🔴 shell handshake missing: SDK uses shell.ready -> shell.init{capabilities,
services} and answers supports() locally; we model a shell.supports request
the SDK never sends, so a real napplet's capability env stays empty.
- 🔴 host drops id-less messages (shell.ready / inc.emit / keys.unregisterAction).
- 🔴 keys.* rejects at the boundary (only client-side stubs).
- 🔴 upload non-conformant (upload vs upload.upload; flat base64 vs
request:{data:Blob}; and a Blob can't cross our JSON string bridge).
- ◐ relay query/subscribe use only the first of filters[]; identity
getList/getZaps/getBadges + onChanged; resource nostr: + cancel;
relay.closed + live tail.
Conformant and now pinned by NappletSdkConformanceTest: relay
publish/publishEncrypted/query/subscribe + event/eose pushes, identity
reads (method-specific result fields), storage get/set/remove/keys,
resource.bytes, and the error convention. Gap guards assert the current
(non-conformant) behavior so each flips intentionally when fixed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Closes the remaining nsite/napplet runtime gaps, all keeping content
integrity (every blob is sha256-verified) and the sandbox intact:
- resource.bytes blossom: scheme — blossom:<sha256> fetches from the
user's kind:10063 Blossom servers and verifies the hash before
returning. nostr: stays deferred (bytes semantics unspecified).
- Content-type byte-sniffing in the resolver: when a manifest path has
no/unknown extension, sniff magic bytes (png/jpeg/webp/gif/pdf/wasm/...).
Text/markup is never sniffed so HTML detection stays extension-driven.
Unit-tested in quartz.
- kind:10063 server fallback: the launcher augments the manifest's servers
with the author's published Blossom list (best-effort, when cached).
- Blob caching: the host OkHttp client caches blobs on disk with a forced
immutable policy (content-addressed). The resolver re-verifies every
served blob's sha256, so a stale/poisoned cache entry can't be served.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Two nsite/napplet runtime improvements in the sandboxed WebView host,
both keeping the trust boundary intact:
- SPA fallback: a document navigation (Accept: text/html) to a route not
in the manifest now serves the verified index.html instead of 404, so
client-side-routed static sites survive deep links and refreshes.
Missing sub-resources (js/css/images) still 404 — they don't accept
html — so a broken asset never silently returns the page. The fallback
only ever serves the manifest's own hash-verified index.html.
- External links: a link the user taps to an off-origin host is handed to
the system browser via ACTION_VIEW instead of silently failing. Gated on
a real user gesture (so a hostile site can't auto-redirect to spam-open
the browser) and restricted to http/https (no arbitrary intent schemes).
The sandbox WebView itself never navigates away from the internal origin.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
The tab already lists both NIP-5D napplets and NIP-5A nsites (the filter
and subscription include 15128/35128); rename the label so nsites aren't
hidden behind an "Apps"-only name.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Adds a profile tab that lists the NIP-5A nsites (15128/35128) and NIP-5D
napplets (15129/35129) a user publishes, surfacing them per-author. Each
row is the inert feed card; opening one launches the sandboxed :napplet
process — the tab never executes applet code.
- UserProfileAppsFeedFilter + UserProfileAppsFeedViewModel (mirror the
gallery feed; scans LocalCache.addressables for the four manifest kinds
authored by the user, honoring mute/hidden filters).
- TabApps renders the feed via RefresheableFeedView -> NoteCompose (which
now has the napplet/nsite cards).
- ProfileScreen: new ProfileTab.Apps wired through the pager.
- Profile relay subscription fetches the manifest kinds (UserProfileAppKinds)
so the tab is populated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Napplets (15129/35129) had no inline renderer — only the dedicated
browse screen. Add RenderRootNappletEvent/RenderNamedNappletEvent
mirroring the nsite card: an inert Compose card (Text + Button, no
WebView) that shows title/description/source/servers and the declared
capabilities, and launches into the sandboxed :napplet process via
NappletLauncher only on explicit tap. The card never executes applet
code, so feed exposure doesn't widen the trust boundary.
- StaticWebsite.kt: napplet renderers + RenderStaticWebsite gains a
titleRes and a requires (permissions) row.
- NoteCompose: render branches for RootNappletEvent/NamedNappletEvent.
- LocalCache: index the napplet kinds so feeds can scan them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Stock napplets (built on @napplet/core) post structured-clone OBJECTS,
not JSON strings, and expect object replies — our shell only forwarded
strings, so their messages were dropped. Bridge the transport so real
napplets interop:
- shell.html bridges object<->string both directions: applet->native
serializes object envelopes; native->applet parses and posts a
structured-clone object (what the SDK reads via e.data.type).
- resource.bytes returns a real Blob: the shell rebuilds it from the
host's base64 bytes+mime before delivering.
- relay.subscribe is push-based: relay.event (per match) then relay.eose,
keyed by subId, no .result — matching @napplet/shim. New MSG_PUSH IPC
frame carries unsolicited envelopes the host forwards verbatim;
relay.close is a fire-and-forget no-op. Delivers the initial snapshot
then EOSE (a live tail is a follow-up).
Injected shim updated to accept object messages, dispatch subscription
pushes by subId, and use the push-based subscribe. Codec gains
encodeRelayEvent/encodeRelayEose/readSubId with unit tests.
The shell/shim are JS and not covered by the JVM tests — this needs
on-device verification with a playground napplet.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Verified the result field names and wire discriminants against the
canonical @napplet/nap 0.15.0 message types and value-types, and fixed
several mismatches:
- identity reads return method-specific fields, not a generic "result":
getProfile->profile, getRelays->relays, getFollows/getMutes/getBlocked
->pubkeys, getList->entries, getZaps->zaps, getBadges->badges.
- getProfile now builds a ProfileData object ({name, displayName, about,
picture, banner, nip05, lud16, website}) from parsed kind-0 metadata,
mapping display_name -> displayName, instead of dumping raw content.
- storage wire types are storage.get/set/remove/keys (the SDK functions
are getItem/setItem/removeItem/keys); storage.keys returns `keys`.
- relay.publish / publishEncrypted carry the unsigned template in the
`event` field (per @napplet/shim), not `template`.
Shim updated to match; codec round-trip tests lock the shapes.
Still open and documented: the transport is structured-clone objects
(not JSON strings) with a real Blob for resource.bytes, and
relay.subscribe uses relay.event/relay.eose push — the shell relay needs
an object<->string bridge + push channel, with on-device verification.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
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
Audits the implementation against napplet/naps (specs), napplet/web (@napplet/shim
SDK) and kehto/web (reference runtime + playground). Finding: the security core
(process isolation, verified blobs, consent, ledger, permissions UI) is solid and
ahead of what the demo runtimes specify, but the edge layer is not wire-compatible
with the ecosystem — upstream uses a namespaced window.napplet.* and a
{type:"domain.action", id} envelope, we use a flat API and {id, payload:{op}}, and
we lack the mandatory shell.supports() plus the resource/upload/keys domains. So no
real ecosystem napplet runs as-is. Doc includes a coverage scorecard and a
prioritized path to compatibility (envelope + namespaced shim first).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Moves every inline English literal in the napplet UI to string resources:
- Consent dialog: operation summaries (per op), capability label, button labels,
and the title fallback now come from strings.xml. The payment amount is a proper
<plurals> (sat/sats) via pluralStringRes.
- Permissions screen + Napplets list: capability names/descriptions, empty states,
and the untitled-napplet fallback are localized.
- Host toasts (invalid napplet / WebView too old) localized.
- New NappletCapabilityLabels.kt maps each capability to shared label/description
string resources, reused by both the consent dialog (getString) and the
permissions screen (stringResource), so there's one source of truth.
Programmatic strings that cross the API boundary to the applet's JS (broker
Failed reasons) and log messages are intentionally left in English.
:amethyst:compileFdroidDebugKotlin passes; spotless clean.
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
#1 WALLET — PayInvoice now pays via the user's connected NWC wallet
(account.sendZapPaymentRequestFor, wrapped suspend with a 60s timeout). The
gateway returns the preimage on success and throws (→ Failed) on no-wallet,
wallet error, or timeout, so an applet never wrongly believes a payment landed.
The consent dialog decodes the invoice and shows the amount in sats
(LnInvoiceUtil). Gated as before: must declare `value`/`wallet`, then consent.
#3 live relay query — QueryEvents now does a bounded live fetch
(INostrClient.fetchAll, EOSE/8s timeout) across the user's read relays, merged
with LocalCache, deduped, newest-first, limit-respected — instead of cache-only.
#2 inter-applet — deferred per design review. It needs new architecture
(multi-applet hosting + an archetype registry), not just a gateway, and would
risk forking the upstream NAP-INC/INTENT wire format. Surveyed upstream
napplet/naps and wrote the design + prerequisites in
amethyst/plans/2026-06-20-napplet-inter-applet.md.
:amethyst:compileFdroidDebugKotlin passes; spotless clean. Wallet + live query
need on-device verification (real NWC wallet / relays).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
The codec is the one place the trust boundary parses untrusted applet input, so
it deserves tests — but Android stubs `org.json` in JVM unit tests
(returnDefaultValues), making the previous org.json-based codec untestable
off-device. Rewrites NappletProtocolJson on kotlinx.serialization (a real JVM
JSON impl the app already uses for @Serializable routes), which also drops a
runtime Android dependency from the parser.
Adds NappletProtocolJsonTest (20 cases): decode of every request op, unknown op
→ null, malformed/missing-field → throws (caught by the broker as Failed), encode
of every response variant, null→JsonNull for storage/paid, and a SignedEvent
round-trip back through Event.fromJson.
:amethyst:testPlayDebugUnitTest passes (20/0/0); 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
NappletsScreen previously showed only what was already cached. Adds a lightweight
discovery subscription so the list actually populates:
- NappletsFilterAssembler / NappletsFilterSubAssembler (SingleSubEoseManager):
one REQ per read relay for kinds 15129/35129, deduped to a single subscription
per account. No follow-list/feed-state machinery — the screen reads LocalCache
directly, so the query state carries only the account + scope.
- Registered as an app-lifetime singleton in RelaySubscriptionsCoordinator (the
EOSE manager opens its relay sub at construction, so it can't be per-screen).
- NappletsScreen invokes NappletsFilterAssemblerSubscription, which subscribes
on STARTED and tears down after the lifecycle grace window.
:amethyst:compileFdroidDebugKotlin passes; spotless clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
UI entry point:
- NappletsScreen: a "Napplets" drawer item lists napplet manifests in the local
cache (NIP-5D kinds 15129/35129) and opens the selected one in the sandboxed
host. Wired as Route.Napplets with a NavBarItem + drawer entry.
Sandbox process isolation (security fix):
- Application.onCreate runs in every process, so the :napplet process was building
AppModules and initiate() was loading the account + constructing the signer there
— defeating the "no keys in the sandbox" guarantee. Amethyst.onCreate now detects
the :napplet process and skips AppModules entirely, leaving `instance` unset so
any accidental use fails fast.
Tor/proxy-aware blob fetch:
- The host's OkHttpClient now routes Blossom blob fetches through the user's Tor
SOCKS proxy when active. The port is resolved in the main process by the launcher
and passed via the Intent, so the sandbox process never needs the account-bound
HTTP stack.
:amethyst:compileFdroidDebugKotlin passes; spotless clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Implements the Android side of the napplet/nsite trust boundary on top of the
commons core. The applet runs in a separate OS process holding no keys; every
dangerous operation is brokered to the main process and gated by user consent.
:napplet process (no secrets):
- NappletHostActivity: hardened WebView (no file/content access, no DOM storage,
mixed-content blocked, SafeBrowsing on), applet served into an opaque-origin
sandboxed iframe, manifest blobs served already-verified via
shouldInterceptRequest with a default-deny CSP (connect-src 'none' = no direct
network), and a window.napplet.* shim bridged over an origin-restricted
WebMessageListener.
Main process (holds the signer):
- NappletBrokerService: bound Messenger service running the commons NappletBroker
against the live account; exported=false + UID check. Builds the broker per
request so account switches are honored; relay publish via the account's
computed broadcast relays.
- NappletConsentActivity + NappletConsentCoordinator: capability-consent dialog
with a suspend bridge; fails closed on dismissal.
- DataStoreNappletPermissionStore: persistent grant store.
Shared edge: NappletProtocolJson (JSON codec), NappletIpc (Messenger contract),
NappletLauncher (packs a verified manifest into the host Intent), shell.html.
Adds androidx.webkit (Apache-2.0) for the origin-restricted message bridge — a
plain @JavascriptInterface leaks into every frame and would break the boundary.
Manifest declares the :napplet activity, the consent activity, and the broker
service. :amethyst:compileFdroidDebugKotlin passes; on-device verification and a
UI entry point are the remaining steps (see the plan doc).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
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
The settle/reset animation drove a critically-damped spring with the
fling's leftover velocity. A critically-damped spring does not oscillate
from rest, but when handed an initial velocity in the target's direction
its response still crosses the target once before decaying back. On a fast
reveal fling the top bar's offset shot well past 0 (measured ~+190px in a
test) — rendering the bar sliding below its resting position and springing
back, the "goes beyond its final position and then comes back" wobble that
only appeared on fast flings.
Clamp the settle Animatable to the visible travel range [-limit, 0] via
updateBounds, so hitting an edge ends the animation crisply with no
rebound. Add a regression test that steps the settle under a manual frame
clock and asserts the offset never crosses the resting edge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEGCrB5uRBAZES1PSp4Ctz
Extends the CLI to fetch and verify NIP-5D napplet kinds, mirroring `amy nsite`
but adding the napplet-specific runtime checks.
- NappletCommands: `amy napplet fetch AUTHOR [--d ID] | --snapshot EVENT-ID
[--path P] [--server …] [--relay …] [--out FILE] [--timeout SECS]`. Fetches a
root (15129), named (35129, via --d), or snapshot (5129, via --snapshot
<event-id>) manifest; recomputes the NIP-5A aggregate hash and refuses a
manifest whose `x` tag doesn't match its path tags (`aggregate_mismatch`)
before touching any blob; then resolves the path with per-blob sha256
verification. Output adds `requires` (NAP capabilities), `aggregate_sha256`,
and `aggregate_verified`.
- StaticSiteFetch: new shared helper holding the Blossom download + resolve +
emit logic, so `nsite` and `napplet` don't duplicate it. NsiteCommands is
slimmed down to use it (also now reports the manifest `kind`).
Smoke-tested offline: bad-args, help, and dead-relay runs resolving cleanly to
not_found with the correct kind for all three napplet variants (15129/35129/5129)
plus a no-regression check on `nsite fetch`. The aggregate/per-blob verification
logic itself is covered by the quartz unit tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdAJMbnHJfiMY7UcS99T6C
Adds the full NIP-5D napplet manifest layer, plus the NIP-5A aggregate-hash
infrastructure it depends on. Follows the nip88Polls package structure (event
class + tags/ + TagArrayExt + TagArrayBuilderExt).
NIP-5A shared infra (nip5aStaticWebsites):
- XTag — the aggregate-hash tag ["x", "<sha256>", "aggregate"].
- SiteAggregateHash — computes/verifies the NIP-5A aggregate hash: sort the
per-path lines "<hash> <path>\n" lexicographically, concat as UTF-8, SHA-256.
Pinned by a test against an independently computed sha256sum vector.
- siteAggregateHash() parse + builder extensions.
NIP-5D napplets (nip5dNapplets):
- NappletSnapshotEvent (5129, regular), RootNappletEvent (15129, replaceable),
NamedNappletEvent (35129, addressable, d-tag) — all built on the NIP-5A
path/server/title/description/source/x tag set.
- RequiresTag — ["requires", "<bare-nap-name>"] capability declarations.
- NappletManifest interface — uniform accessors (paths/servers/requires/title/
…) plus computeAggregateHash()/verifyAggregate() shared across the three kinds.
build() auto-stamps the x aggregate (required for snapshots, recommended for
root/named).
- Registered all three kinds in EventFactory.
This covers the NIP-5D runtime verification contract end-to-end in quartz:
signature (core Event.verify), per-blob sha256 (StaticSiteResolver.verify), and
the aggregate x-tag (NappletManifest.verifyAggregate). Note the napplet kinds
5129/15129/35129 are distinct from the NIP-5A nsite kinds 5128/15128/35128, so
there is no collision.
Tests: SiteAggregateHashTest (vector + order-independence + tamper) and
NappletEventTest (build/parse round-trip for all three kinds, aggregate
verification, tamper detection, EventFactory routing).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdAJMbnHJfiMY7UcS99T6C
When saving media to the gallery, a non-2xx response triggered a bare
check(response.isSuccessful), which threw IllegalStateException("Check
failed.") with no context. The error was caught and logged, but the
message was useless for diagnosing failures and produced a generic toast.
Include the URL, HTTP status code, and status message in the check so the
log and downstream error handling explain why the download failed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cwv4DpPLTyiJP2Thv3H3jR
An emoji pack can carry duplicate shortcodes: NIP-30 puts no uniqueness
constraint on emoji tags, so foreign packs may repeat them and our own
addEmoji appends without a duplicate guard. The grid keyed items on
"${code}-${priv|pub}", so two same-code entries in the same visibility
bucket produced identical keys (e.g. "kohakucho-pub") and crashed the
LazyVerticalGrid with IllegalArgumentException.
Collapse to one cell per (shortcode, visibility) with distinctBy when
building the list. Beyond fixing the crash this is the correct UX: two
cells with the same shortcode are indistinguishable and share one delete
path (removeEmoji deletes by shortcode, dropping both). Dedup on code +
visibility so a legit public/private pair of the same shortcode survives.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01346aiAXBbdg5hMTAGydTqp
Some OEM ROMs (e.g. LineageOS/peridot on Android 15) crash with
"cannot use a recycled source in createBitmap" inside
MediaMetadata.Builder.scaleBitmap() when the legacy MediaSession path
sets metadata artwork.
media3 size-limits artwork using Resources.getSystem()'s
config_mediaMetadataBitmapMaxSize, which is unresolvable on these ROMs
and falls back to the full screen width. The over-sized bitmap is then
re-scaled by android.media.session.MediaSession.setMetadata(), and those
ROMs recycle the source bitmap during scaling. media3's
CacheBitmapLoader caches the now-recycled bitmap and reuses it on the
next metadata update, hitting createBitmap() on a recycled source.
Cap decoded artwork via DataSourceBitmapLoader.setMaximumOutputDimension
to the same framework limit the platform compares against (resolved from
the app context, 320dp default), so build() never re-scales the bitmap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2raH4U7FCpK99YMQfGVQS
ActivityResultLauncher.launch() throws ActivityNotFoundException on
devices without an app that handles IMAGE_CAPTURE / video capture
intents, crashing the app from a background dispatcher. Wrap the launch
in launchOrToast(), which runs on the main thread, catches the
exception, shows a toast, and dismisses the capture flow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VhTdM5SuUo6WMTCsZz6XWG
An ignored external NIP-55 signer prompt surfaces as
SignerExceptions.TimedOutException. Relay auth (NIP-42) signs replies in a
fire-and-forget scope.launch whose host scope (e.g. viewModelScope) carries no
CoroutineExceptionHandler, so an uncaught timeout there reached the platform
default handler and crashed the app ("Could not sign: User didn't accept or
reject in time.").
Guard the launch in RelayAuthenticator so signing failures are swallowed and
logged (re-throwing only CancellationException). Apply the same guard to
NostrSignerRemote's incoming-bunker-response launch, which decrypts untrusted
relay data on a handler-less scope. Add RelayAuthenticatorTimeoutTest covering
the swallowed-timeout and happy-path-still-sends-AUTH cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RM8zAKJNE8aAQL5nUboso
LiveStreamsFeedFilter.sort and DiscoverLiveFeedFilter.sort computed the primary
sort key, convertStatusToOrder(it.event), lazily inside the comparator. That key
reads OnlineChecker.isCachedAndOffline(url), which depends on a moving
five-minute window and on the checkOnlineCache LruCache. A background online
check can mutate that cache while the sort is running, so the same note could
compare as LIVE (order 2) in one pairwise comparison and offline (order 0) in
another. The resulting unstable ordering makes TimSort throw
"Comparison method violates its general contract!".
Snapshot the status order once per item before sorting (matching how
participantCounts/allParticipants are already precomputed) so the comparator
reads stable values.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HwZzCdNMQoRWbgtZrp4SKH
The ForegroundServiceDidNotStartInTimeException came from MainActivity.onResume
calling NotificationRelayService.start() on every resume. Each
startForegroundService() re-arms Android's "must call startForeground() within
the timeout" requirement, even when the service is already running and already
foregrounded. The old initializeForeground() early-returned via an
`if (foregroundStarted) return` guard once it had been started once, so later
startForegroundService() calls were never matched by a startForeground() — the
re-armed requirement went unsatisfied and the OS crashed the whole app.
ensureForeground() now runs on every onStartCommand (startForeground() is
idempotent — it just refreshes the existing notification) and rebuilds the
notification with the current relay count so repeated calls don't flicker back
to "connecting". It also stopSelf()s on every promotion-failure path (not only
ForegroundServiceStartNotAllowedException), which clears the OS fgRequired flag
and cancels the pending timeout when promotion genuinely can't happen.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012hCJDhJmCNkzqb7SaMWQgB
DiscoverLongFormFeedFilter.sort (and ~44 other feed filters/view models)
sorted notes with the live DefaultFeedOrder comparator, which reads
Note.createdAt() on every comparison. When another thread swaps a Note's
event mid-sort (e.g. a newer replaceable/addressable event arriving from
a relay), createdAt() changes between comparisons and TimSort throws
"Comparison method violates its general contract!"
(IllegalArgumentException).
Migrate every amethyst Set<Note>/Iterable<Note> sort from
sortedWith(DefaultFeedOrder) to the existing sortedByDefaultFeedOrder()
helper, which snapshots createdAt() once per note so the comparator stays
consistent. The pinned-chatroom comparator in ChatroomListKnownFeedFilter
is given the same snapshot treatment.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Sh5XNLssw9GJNxjkxZcRS
Address is a data class over (kind, pubKeyHex, dTag), the same fields
toValue() encodes, so distinct() dedupes on exactly the grid key without
the redundant toValue() projection.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fxWguG7dXgwwe2Et3JBjR