Commit Graph
17404 Commits
Author SHA1 Message Date
Vitor PamplonaandClaude Opus 4.8 f8d8a2b135 feat(concord): recover a membership stranded by a Refounding
A rotation carries only (newRoot, newEpoch, rotator) — no recipient list
— so a member simply left out of the recipient set receives nothing and
is stranded on the dead epoch forever while everyone else moves on. It
applies to any member, the owner included, and cannot be prevented on the
receive side: there is nothing to check.

Armada does not prevent it either; it recovers, and this follows the same
approach. The invite link a membership was joined through is stored as
the anchor, and when that link later resolves to a HIGHER epoch, the
membership merges forward.

Uses Armada's exact wire names so the kind-13302 list stays compatible in
both directions: `invite_ref` (the link in bare `<naddr>#<fragment>`
form, host-stripped so a link minted by a different front end reduces to
the same anchor) and `excluded_at_epoch`, both at the entry level.

Details that decide whether this works at all:

- `merge()` lets a higher-epoch winner inherit the loser's `invite_ref`
  when it has none. Without it a two-device merge silently discards the
  only anchor recovery has, disarming it permanently.
- `adoptConcordRoot` carries `invite_ref`/`excluded_at_epoch` through a
  rotation; it rebuilt the entry field-by-field and would have dropped
  them at exactly the moment they matter.
- Merging forward preserves `heldRoots`, so prior-epoch history the
  member legitimately holds is not lost by recovering.
- Recovery requires a strictly higher epoch and a matching community id,
  so it is monotonic and cannot be steered by an unrelated bundle.

Hooked onto the existing Concord revision tick immediately after
`drainConcordRekeys`, because the two are halves of one problem: a
rotation you were included in arrives as a rekey to drain, one you were
excluded from produces no message at all and can only be found by polling
the link. Rate-limited to 15 minutes per community; an idle tick costs a
map lookup. Only a Live bundle recovers — an expired or revoked link is
not a missed rotation.

Verified by mutating the production code eight ways (dropping the anchor,
dropping heldRoots, dropping the epoch comparison, renaming the wire
keys, removing the merge inheritance, breaking bare-form parsing) and
confirming each produced exactly the expected failures.

Two things this surfaced, both left for their own change:

- Our parser does NOT round-trip unknown JSON keys — `ignoreUnknownKeys`
  plus closed DTOs — while Armada's format ends in `[k: string]:
  unknown`. So every Amethyst write of the community list silently strips
  fields Armada added that we do not model; it already discards the
  `refounder` field we parse but never re-emit. That is live interop data
  loss, caused by us, independent of this work.
- `mergeForward` keeps the entry's existing private-channel grants rather
  than adopting the bundle's, matching what `joinConcordViaInvite`
  already does. If a recovered member should pick up new-epoch grants,
  both paths need it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:36:52 -04:00
Vitor PamplonaandClaude Opus 4.8 fac1bf5b5d feat(concord): refuse Control-Plane rollbacks with a version floor
`ConcordRefounding.compactControlPlane` re-wraps one edition per entity
when a community rotates epoch, and the ROTATOR chooses which one
survives. The receiving side had no memory: `refold()` folds only the
wraps at the current epoch's Control-Plane address and discards the prior
epoch's buffer, and `EditionFold` accepts whatever it is handed (its
no-genesis fallback anchors at the lowest version present).

So a rotator could publish only version 1 of a chain and omit version 2 —
restoring a revoked role, clearing a banlist, reverting metadata. Every
signature is genuine; this is rollback by omission, not forgery.

Adds a per-entity floor: the version AND hash last successfully folded.

- **No floor (fresh joiner)** — unchanged: genesis anchor, else the
  lowest-version edition as the legitimate compaction bootstrap.
- **With a floor** — the walk is anchored AT the floor: the offered set
  must contain the exact edition already folded (version and hash; a
  same-version sibling is a fork, not our chain), then walks up. A head
  below the floor is structurally unreachable.
- **Gap** (the floor edition is absent) — refuse, and keep the known
  head. Refusing by *retaining* matters here: this fold is recomputed
  from scratch each time, so letting an entity vanish would itself be a
  rollback — a dropped banlist is an unban.

The floor needs no new persistence. It is derived from `heldRoots`, the
rotated-out access roots already persisted in the NIP-44 self-encrypted
kind-13302 list: the session derives each prior epoch's Control-Plane
address from them, folds oldest-first, and takes the resulting heads as
the floor. That survives both a process restart and the session rebuild
`ConcordSessionRegistry.sync` performs at exactly the moment of a
Refounding — which would have destroyed any in-session floor. If the old
planes are not served, there is no floor and behaviour is as before.

Floors are built from AUTHORITY-GATED heads, not raw ones. Without that,
any ex-member still holding a rotated-out root could mint a high-version
edition on the old plane and freeze the entity for every honest client —
a denial of service this change would otherwise have introduced. Covered
by a test.

Verified by disabling both enforcement points: 7 of 12 quartz tests and
the end-to-end commons test fail, and the ones that still pass are
exactly the non-regression cases (fresh joiner, honest compaction,
pass-through without floors).

Known limit: `AuthorityResolver.resolve` folds authorized SUBSETS of the
edition pool and does not carry floors itself; gating happens at the pool
level before the resolver sees anything. Sound, but connectivity checked
on the full set is a weaker precondition than on each subset — passing
floors into the resolver's three folds is worth a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:36:25 -04:00
Vitor PamplonaandClaude Opus 4.8 30f4638954 fix(concord): re-key observed members on a Refounding
The Refounding recipient set was `Guestbook joins ∪ roster ∪ owner ∪ self`
— it never consulted `observedAuthors`, the members seen publishing to a
channel. `ConcordCommunitySession.allMembers()` already unions them in
(and already subtracts the banned); the rotation path just didn't use it.

Amethyst announces a Guestbook Join, but nothing in the protocol requires
one, so in practice most members of a cross-client community have never
sent one. Every member who had only ever *posted* — no role, no Join —
therefore received no rekey blob and was silently expelled by the next
rotation. Removing a single spammer would quietly strand most of the
community, and it fell hardest on Armada members, who are the bulk of the
roster in the communities we interop with.

Now uses `allMembers()`. Still a floor rather than a census, as its KDoc
says: a member who joined silently, holds no role, and has never posted
leaves no trace to re-key, and nothing here can find them. Stranded
recovery is what brings those back.

Also corrects an overclaim on `ConcordInviteBundle.validate`. Its KDoc
said self-certification stops a bundle smuggling "a false owner or a fake
key for a real community". Only the first half is true: `community_id`
commits to (owner, salt) — both public in any invite — and NOT to
`community_root`, so an attacker can mint a bundle carrying a real
community's id, owner and salt beside a root of their own. A joiner
adopts that root, believes they are in the real community, and posts into
planes the attacker can read.

That is a CORD-05 limitation, not an implementation gap: Armada's
`validateBundle` checks exactly the same thing and also leaves the root
unbound, so a stricter unilateral rule would break interop while
protecting nobody. Verifying the adopted root's Control Plane does not
close it either — sealed editions carry the owner's own signature, so an
attacker can re-wrap genuine owner editions into their plane, which is
what a legitimate compaction does. Closing it needs a spec change
(commit the root into the self-certifying id, or require the bundle to be
signed by a roster-authorized member); the KDoc now says so instead of
claiming a guarantee the code does not provide.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:35:50 -04:00
Vitor PamplonaandClaude Opus 4.8 0f53eb09a2 fix(concord): gate revokes on target rank; drop the owner rotation refusal
Reviewed the previous commit's authority changes against Armada
(gitlab.com/soapbox-pub/armada), the reference Concord client we interop
with. Read for its rules only — Armada is AGPLv3 and Amethyst is MIT, so
no code was taken from it.

It confirmed the role rank gate (Armada checks both directions too: the
author must outrank the position being minted AND the standing position
being replaced) and the banlist check on rotation. It also showed one of
our rules was wrong and one gate was missing.

**Removes the owner's refusal of foreign rotations.** The previous commit
made the owner ignore any rotation it did not author, reasoning that a
BAN-holder could otherwise carry the owner onto a root of their choosing.
Armada does the opposite on purpose — "authority is the roster, never key
possession" — and it is right: an admin legitimately rotating to remove a
spammer would leave the owner alone on the dead epoch, self-inflicting
the strand the rule was meant to prevent, and diverging from the
reference implementation forks communities across clients. The threat is
better answered by the rank gate: with role editions gated, nobody can
escalate themselves to BAN, so BAN-holders are people the owner
deliberately trusted.

**Adds the missing rank gate on grants.** A grant was authorized if the
granter outranked every role it handed out — but a REVOKE carries no role
ids, and `all {}` over an empty list is vacuously true. So any
MANAGE_ROLES holder could strip anyone's roles, the owner's admins
included: promotion was gated, demotion was free. Armada treats a grant
as an action ON the member and requires outranking the target's standing
rank; this now does the same.

Both new tests were verified to fail with the corresponding check
disabled, and each has a companion asserting the legitimate case still
works (an admin can still revoke a moderator beneath it).

Also records what Armada does about exclusion, since we cannot prevent it
receiver-side: it does not try to. A rotation carries no recipient list
there either, so a stranded member instead re-resolves the invite link
they joined through and merges forward to the higher epoch ("stranded
recovery"). Amethyst has no equivalent, so a stranded member stays
stranded — noted at the call site as follow-up work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:10:36 -04:00
Vitor PamplonaandClaude Opus 4.8 c9c91a8c98 fix(concord): rank-gate role editions and harden rotation authority
Two authority holes, plus the limit that remains.

**Role editions had no rank gate.** Grant editions are correctly gated —
a granter must hold MANAGE_ROLES *and* strictly outrank every role it
hands out. Role editions checked only that the author held MANAGE_ROLES.
Nothing stopped an authorized signer editing a role at or above its own
rank, including the role it holds itself.

So a moderator at position 5 with MANAGE_ROLES could publish one edition
on their own role's chain claiming position 1 and every permission bit,
then a second demoting the real admins beneath them — reaching full
authority over everyone but the owner in two editions. The
rogue-higher-version defence added earlier does not cover this: it drops
editions from *unauthorized* signers, and this signer is authorized.

Role editions are now gated in both directions: an author may not claim a
position at or above its own rank, may not touch a role that already sits
at or above it, and may not hand a role permission bits it does not hold
itself. Deleting keeps only the second rule, so retiring a role beneath
you still works. The owner is unaffected.

**A banned moderator could still rotate the community.** The rekey
receive path authorized the rotator with `effectivePermissions(...)`,
which ignores the banlist, rather than `hasPermission(...)`, which
excludes banned members. Now uses the latter.

**The owner no longer adopts a root someone else minted.** A rotation
replaces the community root, so a rotator who *includes* the owner as a
recipient hands themselves the keys to the owner's own community — the
owner would follow them onto an attacker-chosen epoch. The owner changes
epoch only by rotating themselves.

**Known limit, documented at the call site.** A rotation carries only
(newRoot, newEpoch, rotator) — no recipient list — so a receiver cannot
tell who was omitted. A BAN-holder can therefore still evict the owner by
leaving them out: everyone else adopts, the owner is stranded on the old
epoch. That is not fixable in the receive path; it needs a protocol
change (a recipient commitment the receiver can check, or owner
co-signing). Tracked for CORD-06.

The escalation test was verified to fail with the gate disabled, and a
companion test asserts an admin can still edit a role beneath it, so the
gate is not merely blocking everything.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:00:50 -04:00
Vitor PamplonaandClaude Opus 4.8 996b800da5 docs(changelog): correct claims the code does not support
An audit of the v1.13.0 notes against the implementation found several
claims that are wrong or overstated. These are user-facing release notes,
so a false claim changes what people believe about their own security.

Corrected:

- **Privacy Lock.** The notes said it gates Messages "behind a password or
  biometric" on "Android and Desktop", with inactivity auto-lock and
  optional preview redaction. It does not exist on Android at all — no
  code, no settings entry — and there is no biometric implementation on
  any platform (`CredentialPrompter` has no implementations). Preview
  redaction persists a setting nothing reads, and the auto-lock is a fixed
  timer, since the idle-reset modifier is never applied. It is also a
  screen gate rather than encryption at rest: the account stays live and
  messages keep syncing while locked. Now described as what ships — a
  password gate on the Desktop Messages and Wallet columns.
- **"Every signature, payment, or data read needs your explicit
  approval."** Only payments require per-use consent; everything else can
  be granted once and reused, and the default trust level auto-signs notes,
  reactions and encryption after a single tap.
- **Web of Trust (GrapeRank)** was listed under app features, but crawling
  and scoring exist only in the `amy` CLI — no app module references it.
  The app consumes NIP-85 cards published by an operator, which is what the
  entry now says.
- **"One-tap trust for your follows' relays"** described bulk-granting
  relays used by people you follow. What exists is category rules evaluated
  per challenge.
- **Pinned web apps "show the app's own icon"** — they render a generic
  placeholder.
- **PoW** contradicted itself: "all cores" in one entry, "half the device's
  cores" in another. The latter matches `PoWPolicy.minerWorkers`.
- **Onion-Location "through every HTTP client"** — the Android app's
  clients only; the desktop, CLI, geode and sandbox blob clients don't
  install it.
- **Relay hardening** claimed REQ refusals stop immediately and failures
  evict "on the first strike"; both take repeated failures, and the
  first-strike eviction applies only to crawls.
- **`bunker://` links cannot be pasted in** — Amethyst only emits them.
- **Git code browser** needs a repository with an http(s) clone URL.
- **Geohash anonymous identity** — the per-area identity is unlinkable, but
  the optional nickname is one global handle, so setting it links your
  posts across areas.
- **Concord ban** is read-time enforcement for everyone else; the banned
  member keeps the keys until a Refounding.

Adds an Upgrading section for the user-visible effects of the per-account
isolation work: sites signed out once, permissions re-asked per account,
and relay logins now prompting under the default remote-signer policy.

Contributor and translator credits are left as they are — several entries
are unresolved npubs and Crowdin-generated usernames, but correcting
attribution is not a call to make from the code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 19:52:52 -04:00
Vitor PamplonaandClaude Opus 4.8 2ccd837f30 fix(napplet): sign as the account a surface was launched as
Requests resolved their signer through `sessionManager.loggedInAccount()`
— whichever account is active *right now* — with no binding to the
surface that asked. A full-screen host is a separate activity that an
account switch does not tear down, so:

- Open a site full-screen as A and log in via NIP-07: the page shows A.
- Switch to B in the main app.
- Return to that still-open surface and request a signature: the broker
  handed it **B's** key.

Confirmed on device before the fix. Worse than a mismatched prompt: B's
session was then written into **A's** WebView storage jar, so afterwards
even the embedded tab — which rebuilds correctly and had been verified
correct — displayed the wrong account. Per-account isolation held only
until a full-screen surface wrote a foreign session into a jar. And it
happened silently, because the ledger is per-account+origin and B had
already granted "always allow" for that origin from an earlier session.

`NappletLaunchRegistry.Session` now carries the account that minted the
token, and the broker resolves *that* account out of the cache. This
needs no new machinery to satisfy both halves of the rule: embedded
surfaces are torn down and re-minted on a switch, so they follow the
active account, while a full-screen surface keeps the account it was
opened with. It also extends an argument the code already made — the
sandbox can only act as the napplet it was launched as, because it holds
only its own token; now the same is true of the account.

Fails closed: if the launch account is no longer loaded, the request is
refused rather than falling back to whoever is signed in now.

The same live-account resolution existed on two adjacent paths, fixed
here too:

- Relay subscriptions took the account from a global supplier, so a
  full-screen surface's REQs would target the newly-active account's
  relays while its signatures came from the old one. The account is now
  passed per-open from the launch token.
- `identity.changed` streamed the app's active account, so a page bound
  to A could be told it had become B while signatures still returned A —
  the same desync inverted. It is now bound to the surface's own account
  and reports only that account going away.

Verified on device: with B active, a fresh identity read from a
full-screen surface launched as A returns **A**; the embedded tab still
follows B; both surfaces ran simultaneously under different accounts with
no cross-writes between jars.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 19:38:52 -04:00
Vitor PamplonaandClaude Opus 4.8 c8be65a02e fix(concord): require consent for invite links, enforce expiry, fold the head
Three fixes to the Concord invite and moderation paths.

**Invite deep links redeemed with zero consent.** `ConcordInviteScreen`
called `joinConcordViaInvite` from a `LaunchedEffect` on open, and the
manifest registers `https://amethyst.social/invite/` as BROWSABLE. So a
link on any web page — or a QR code, or a push — silently caused a
connection to up to three ATTACKER-CHOSEN relay URLs decoded from the URL
fragment (disclosing the user's IP to a third party), a Guestbook JOIN
signed by the user's identity published to those relays, and a write to
their private community list. No tap, no preview.

The screen now opens in an awaiting-consent state and only joins from an
explicit Join button. The preview is built entirely from the link itself
— base64url and NIP-19 decoding, both pure in-memory — and touches the
network for nothing: no relay connection, no signing, no publishing. It
shows the relays it would contact so the user can see whom they'd be
talking to. The community name lives inside a bundle only those relays
can serve, so it is honestly reported as unknown until joining rather
than fetched.

**Invite expiry was decorative.** `ConcordInviteBundle.isExpired` had no
production callers at all — the only ones were in a test — so an expired
invite redeemed forever. Expiry is now enforced at `classify`, the choke
point every redeem path funnels through, with its own result and message
so the user knows to ask for a fresh link.

**Moderation read the wrong edition.** `ConcordModeration` used
`firstOrNull` over `controlEditions()`, which is in wrap-ARRIVAL order,
not the folded head. Once an entity had two or more editions the next one
chained off a stale predecessor, forking the chain at an already-used
version, and `EditionFold` then resolved the fork by `minByOrNull` on the
rumor id — a coin flip. Bans were masked by a down-only healing union;
UNBANS and role revocations were not, so they could silently fail to
apply. Both call sites now fold to the true head.

Regression tests assert the fold-head behaviour under two arrival orders
— a single order accidentally puts the head first and passes against the
buggy code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:59:50 -04:00
Vitor PamplonaandClaude Opus 4.8 153191e722 fix(nip46): stop auto-signing relay AUTH under the default policy
`REASONABLE` — the default policy on connect — auto-approved kind 22242
(NIP-42 relay auth). The in-code justification was that the event is
ephemeral and bound to one relay and challenge, so it cannot be replayed
elsewhere. That is true and beside the point: the requesting app supplies
the `relay` and `challenge` tags verbatim, so it never needs to replay —
it just asks for a FRESH signature naming any relay it likes.

A paired app could therefore, with no prompt, open its own socket to any
NIP-42 relay, take the challenge, get 22242 signed, and authenticate to
that relay AS THE USER. That yields read access to whatever the relay
gates behind AUTH — notably the kind-1059 giftwrap inbox and its full DM
metadata (who, when, how many) — and burns quota on paid relays, which
bill whoever authenticates.

Amethyst auto-signing AUTH for relays the USER configured is not the same
as letting a third party name the relay; the comment conflated them.

22242 now falls through to ASK. The existing test asserted the vulnerable
behaviour with the same flawed reasoning, so it is inverted here rather
than merely extended.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:59:33 -04:00
Vitor PamplonaandClaude Opus 4.8 5fd8be64fd fix(podcast): clamp V4V fee splits so a feed cannot multiply payments
`computeShares` paid each fee recipient `totalMilliSats * split / 100`
with no upper bound on `split`, and `split` comes verbatim from a
kind-30054 episode event that anyone can publish (`ValueTag.parse` is a
bare `fromJson` with no validation). A single `fee:true, split:1000`
recipient was therefore paid TEN TIMES the amount the user chose. The
`remainder` clamp looked like a safety net but only zeroed the honest
recipients; it never touched the fee recipients themselves.

Proven by test before fixing: `split:1000` pays 10x, and two fee
recipients at 60% each pay 1,200,000 millisats for a 1,000,000 zap.

This was reachable in the worst possible place. Streaming V4V pays every
minute, automatically, so a modest multiplier stays under a typical NWC
budget and simply runs — and the on-screen running total tracks the
INTENDED amount, so it reads "100 sats" while 1,000 left the wallet,
while the streaming error handler suppresses the toast. The ordinary zap
button reroutes through the same path for any note carrying a value
block, so it was not limited to the streaming toggle.

Fees are a percentage off the top, so each is now clamped to 100% and the
cumulative total to the remaining budget: the payout can never exceed
what the user chose.

The existing test asserted the right invariant (`sum <= total`) but only
ever ran it on well-formed input, which is why this survived.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:59:18 -04:00
Vitor PamplonaandClaude Opus 4.8 7f135a12ab fix(embed): rebuild pinned tabs when the account changes
A WebView's storage profile is fixed at construction, so per-account jars
mean each pinned tab has to be rebuilt on an account switch. Nothing did
that, and the result was a tab that went permanently black: the
logged-in subtree is wrapped in `key(account.pubKey)`, so a switch
disposes and rebuilds it, which closes each sandbox session — but
`EmbeddedTabHost` is process-scoped, so the warm controllers survived.
The rebuilt layer then re-attached fresh views to controllers whose
adapter had already been consumed, and a `SandboxedSdkView` with no
adapter paints only its background, forever.

Three fixes:

- Tabs are torn down and re-armed against the new account's profile when
  it changes, including tabs that were never opened, so none survives
  bound to the previous account's jar.
- `attachView` now recovers instead of silently doing nothing when a
  second view attaches after the adapter was spent, minting a FRESH
  session id — reusing the id let the disposed view's late `close()`
  reap the replacement.
- The re-warm kickoff moved off `LaunchedEffect`. It dispatches through
  the composition's scope, and an account switch floods the main thread:
  measured on a slow device, the sweep started 943-1157 ms after the
  teardown, and the account watcher itself fired 3-4 s late. Running it
  synchronously in the same apply phase drops that to 10-23 ms. The
  suspending work stays in a coroutine; only the kickoff is immediate.

Also gates the load overlay on the tab id rather than a live controller,
so a tab that is re-arming shows the existing spinner instead of a bare
surface — previously the one moment it most needed a cover was the one
moment it had none.

Verified on device across both switch directions, and with profile
detection force-disabled to confirm the recovery path alone prevents the
black.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:59:04 -04:00
Vitor PamplonaandClaude Opus 4.8 095b156953 fix(napplet): give each account its own WebView storage jar
Embedded web content — browser tabs, napplets, nSites — runs in WebViews
in the `:napplet` process, and their cookies, localStorage, IndexedDB and
service workers were SHARED across every Nostr account on the device.
Nothing in the repo ever cleared them: no `CookieManager`, no
`WebStorage` call anywhere.

So a web app stayed logged in as the previous account after a switch, and
a Nostr web client's localStorage — which routinely holds decrypted DMs,
drafts and follow caches — was readable by whichever account came next.
For a user keeping a pseudonymous npub apart from a real one, the app
could correlate the two itself.

Uses the androidx.webkit multi-profile API (already a dependency) to give
each account its own profile: cookies, storage, geolocation grants and
service workers are all partitioned per `Profile`. Switching accounts
moves to that account's jar and switching back restores the session
intact — isolation rather than deletion, so nothing is lost.

The sandbox never learns which account it is serving. The main process
derives an opaque, domain-separated SHA-256 of the account pubkey,
truncated to 32 hex chars, and passes only that; `:napplet` validates the
shape before use, so a compromised sandbox cannot mint a name for another
account's jar. Both re-arm paths read the current profile at send time,
so a re-created session can never resurrect the previous account's jar.

Where MULTI_PROFILE is unsupported (older WebView), isolation degrades to
lossy-but-safe: cookies and web storage are wiped when the account behind
the WebViews changes, rather than silently shared.

Known gap, documented at the logout hook: a removed account's profile is
not deleted. It cannot be done from the main process — WebView profiles
live in the `:napplet` data directory, and booting WebView here would
collide on it — so it needs a broker message that has the sandbox call
`ProfileStore.deleteProfile`, and that must refuse a profile still bound
to a live WebView.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:58:47 -04:00
Vitor PamplonaandClaude Opus 4.8 5bce87d76e fix(napplet): make the consent dialog say what a signature really does
The dialog rendered `kind` plus a 160-char content preview and nothing
else. For the kinds whose payload lives entirely in the TAGS, that is
technically true and practically useless — the user saw "publish an event
of kind 3" while approving a replacement of their whole social graph.
kind 10002 redirects every future read and write to attacker relays;
kind 5 deletes notes.

Now, for a replaceable list, it diffs the proposed tags against the copy
already cached on the account and reports what actually changes, rather
than a raw total that hides the dangerous case (a list that silently
drops 130 follows). A single-account edit — by far the common one — names
and pictures that account, so the user can recognize who it is at a
glance. Republishing an identical list says so plainly instead of raising
a false alarm, and a missing baseline falls back to the total and admits
it could not compare. Mute lists diff people only, so the string points
at "Show Event" for muted words and hashtags.

Adds a "Show Event" raw-event toggle mirroring the NIP-46 dialog, which
already had one; the napplet dialog had no way to inspect the full event.

Also fixes an amountless-invoice display bug in the same family:
`LnInvoiceUtil.getAmountInSats` returns ZERO (not null, not a throw) for
a BOLT11 with no amount, so both this dialog and the Blossom pay dialog
affirmatively rendered "0 sats" — telling the user a payment was free
when the amount is in fact unspecified and chosen by the payee.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:57:57 -04:00
Vitor PamplonaandClaude Opus 4.8 389b460f7a fix(napplet): disclose what connecting to an app pre-grants
Accepting the "Connect to Nostr" dialog bulk-grants every declared
non-payment capability as ALLOW_ALWAYS — RELAY, IDENTITY, STORAGE,
RESOURCE, UPLOAD, NOTIFY, KEYS — unless the user picks PARANOID. The
dialog never showed that: `buildConnectInfo` did not receive `declared`
at all, so the user approved a set on the strength of a title and an
icon.

`SignerConnectInfo.requestedPermissions` already existed for exactly this
("shown so the user gives INFORMED consent before those ops are
pre-granted") and is populated by the NIP-46 nostrconnect path. The
napplet path simply never filled it in. This threads `declared` through
`NostrConnectPrompt` to `buildConnectInfo`, which lists the capabilities
that actually get pre-granted — SHELL/THEME never prompt and VALUE is
per-use, so listing those would overstate what accepting hands over.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:57:12 -04:00
Vitor PamplonaandClaude Opus 4.8 dbf15a2146 fix(napplet): scope applet grants per app and per account
Every napplet/web-app grant was keyed by applet coordinate alone
(`<appAuthor>:<identifier>`), which carries no account. The stores and
ledgers are process-wide singletons shared by all accounts, so grants
leaked in two directions:

- **Across apps.** `NappletBroker.sessionAllows` held a bare `op.key`
  ("sign:1"), and the check ran *before* the per-app ledger lookup. One
  app's "Allow for this session" therefore authorized that op for every
  other applet and browser origin, silently, for the broker's lifetime.

- **Across accounts.** A grant made under one npub authorized the same
  applet under every other npub on the device. For a user keeping a
  pseudonymous account separate from a real one, an app authorized by
  one could sign as the other with no prompt — defeating the point of
  separate accounts.

NIP-46 already solved this shape correctly: `Nip46PermissionAuthorizer`
namespaces by account (`nip46:<signer>:<client>`) and keys session grants
by `(coordinate, op)`. Its comment even claims it "mirrors the napplet
broker's sessionAllows" — the mirror was backwards. This adopts the same
pattern on the napplet side:

- `sessionAllows` and `NappletPermissionLedger.session` are keyed by
  account + coordinate + op.
- Napplet storage and the capability store namespace keys by account,
  read at call time so a switch moves reads/writes with no rebuild.
- The signer ledger is deliberately NOT account-scoped at the store: it
  is shared with NIP-46, whose sessions run for a specific account rather
  than the active one, so scoping it there would break a background
  bunker. The napplet path namespaces its own coordinate instead.

Also here, found while scoping:

- `identity.watch` consulted only the manifest declaration and never the
  ledger, bypassing a standing DENY — it short-circuits before
  `NappletBroker.handle`, where the "a standing denial always wins" rule
  lives. It now applies that rule itself.
- `DataStoreNappletStorage.keys()` filtered on a space separator while
  keys are written with NUL, so it silently matched nothing and always
  returned an empty list.

Existing grants live under the old un-namespaced keys and are not
migrated: users are re-prompted once. Migrating would attribute grants
made under the broken model to whichever account is active, preserving
the bug.

Regression tests cover both leak directions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:56:58 -04:00
Vitor PamplonaandGitHub ee3b8786d4 Merge pull request #3648 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-19 15:15:02 -04:00
Vitor PamplonaandGitHub 49b93db532 Merge pull request #3647 from vitorpamplona/claude/changelog-new-version-y1y331
docs(changelog): add v1.13.0 release notes
2026-07-19 15:13:29 -04:00
vitorpamplonaandgithub-actions[bot] 710c68d6aa chore: sync Crowdin translations and seed translator npub placeholders 2026-07-19 19:09:10 +00:00
Vitor PamplonaandGitHub 824f9898de Merge pull request #3645 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-19 15:06:36 -04:00
Claude fb730d7ea8 docs(changelog): add v1.13.0 release notes
Add docs/changelog/v1.13.00.md — the v1.13.0 "Web Apps, Communities & Git"
release notes — and link it at the top of the changelog index.

Covers the full v1.12.6..HEAD range: the in-app Browser and NIP-5D/5A web
clients, Concord communities, NIP-34 git collaboration, Location Channels, the
NIP-46 Remote Signer and Privacy Lock, NIP-42 relay login permissions, NIP-29
Groups, the redesigned Messages inbox, Blossom file sync, Podcasting 2.0,
proof-of-work (NIP-13), negentropy (NIP-77), GrapeRank, accent theming, and the
Desktop, amy CLI, Quartz, and Geode work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrPNt4FchqArpMtAHHfqGi
2026-07-19 19:06:03 +00:00
vitorpamplonaandgithub-actions[bot] fe761ab7f2 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-19 14:44:14 +00:00
Vitor PamplonaandGitHub 768e19ff9b Merge pull request #3646 from vitorpamplona/claude/update-all-dependencies-h7vnw4
chore(deps): update dependencies across all modules
2026-07-19 10:41:30 -04:00
Claude 18c0c4291f chore(deps): update dependencies across all modules
Bump to latest stable (or latest within an existing pre-release track),
skipping stable→alpha/beta jumps for production safety.

- kotlin 2.4.0 → 2.4.10 (+ kotlinTest, compose compiler); ksp 2.3.9 → 2.3.10
- composeBom 2026.06.00 → 2026.06.01; composeRuntimeAnnotation 1.11.3 → 1.11.4
- firebaseBom 34.15.0 → 34.16.0; jacksonModuleKotlin 2.22.0 → 2.22.1
- kotlinxCollectionsImmutable 0.5.0 → 0.5.1; sqlite 2.6.2 → 2.7.0
- composemediaplayer 0.10.0 → 0.11.3; zoomable 2.12.0 → 2.13.0
- jlatexmath 1.4 → 1.5; jna 5.14.0 → 5.19.1 (nestsClient)
- benchmark 1.5.0-alpha06 → alpha07; genaiPrompt 1.0.0-beta2 → beta3
- bump DisableCacheInKotlinVersion guard 2_4_0 → 2_4_10 (iOS test workaround)

appfunctions held at alpha09 (appfunctions-service has no alpha10 published).
AGP 9.3.0, gms 4.5.0, kotlin stable, ktor, coil, media3, navigation, and other
androidx libs already at their newest stable; alpha-only newer releases skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TreeiMiHrhWce6PbnA5qWz
2026-07-19 14:06:37 +00:00
Vitor PamplonaandGitHub 618664c45a Merge pull request #3644 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-19 09:46:56 -04:00
vitorpamplonaandgithub-actions[bot] 7b5e5f572b chore: sync Crowdin translations and seed translator npub placeholders 2026-07-19 02:42:47 +00:00
Vitor PamplonaandGitHub f875d2c397 Merge pull request #3643 from vitorpamplona/claude/nip29-location-geohash-ux-890wjz
Map-first geohash location UX: picker, teleport, follow, retarget
2026-07-18 22:39:58 -04:00
Claude b94303a77a fix(location): audit fixes — stuck GPS spinner, main-thread geocode
- "Use my location" could spin forever after a permission denial: the reset was
  in an isGranted-keyed effect that never re-ran on false→false. Use
  rememberPermissionState's result callback, which fires on grant AND denial, so
  the spinner always clears.
- Forward-geocode search ran the blocking Geocoder on the UI thread on Android
  < 13 (ANR risk). Run the search on Dispatchers.IO.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
2026-07-19 02:11:59 +00:00
Claude a87d4688a8 fix(location): single location control on the geo-post screen
The geo-post composer showed two locations: the static external-id marker at the
top and the new "Posting to · Change" row below the message. Drop the duplicate
and, for the geohash case, render the interactive channel control (with retarget)
in the marker's place. Non-geohash external ids still use DisplayExternalId.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
2026-07-19 01:50:02 +00:00
Claude fb9ccbaad0 feat(location): follow a teleported place; retarget geo-posts
1) Follow this location from the feed: when a top-nav Geohash filter is active
   (e.g. after teleporting), the filter header shows a bookmark toggle to
   follow/unfollow that place (kind 10081), so a teleported spot can be saved to
   your locations — the "add to my interests" step. Respects read-only accounts.

2) Retarget a geo-post: the geohash comment composer now shows "Posting to <place>"
   with a Change action that opens the map picker and re-scopes the post's NIP-73
   geohash channel (CommentPostViewModel.geohashScope/setGeohashScope), instead of
   being locked to the feed's channel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
2026-07-19 01:25:24 +00:00
Vitor PamplonaandGitHub b801a6b138 Merge pull request #3639 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-18 21:13:04 -04:00
Claude 7c37a8691c feat(topnav): teleport to a place from the feed filter dialog
Add a "Teleport to a place…" entry to every top-nav feed filter that offers
"Around Me" (home, video, discover, notifications, products, music, git,
podcasts, relay-group discovery, …). Selecting it opens the shared map picker;
on confirm the chosen place becomes this screen's TopFilter.Geohash feed.

Implementation is centralized in FeedFilterSpinner (no per-top-bar changes): a
UI-only TopFilter.TeleportPicker sentinel is intercepted there to open the
picker, then forwarded through the normal onSelect path as a TopFilter.Geohash —
which each screen's existing changeDefault*FollowList already handles. The
spinner header falls back to the raw selection so a teleported (unfollowed)
geohash still shows its place name.

Follow-up (not in this commit): a "follow this location" action so a teleported
place can be saved to the kind-10081 geohash list from the feed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
2026-07-19 00:55:43 +00:00
Claude 1445aea849 feat(location): open the picker at the user's location when permitted
If the picker opens with no seed and location permission is already granted, fly
to the user's current position instead of the neutral world view. Runs once and
never prompts — it only consumes already-granted permission; on denial or no fix
it stays at the world view as before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
2026-07-19 00:48:24 +00:00
vitorpamplonaandgithub-actions[bot] 6ba4ab0a4b chore: sync Crowdin translations and seed translator npub placeholders 2026-07-19 00:27:16 +00:00
Vitor PamplonaandGitHub 035c50421c Merge pull request #3642 from vitorpamplona/claude/cli-module-deep-review-le4dsj
amy CLI overhaul: exit-code/JSON contract v1, per-command help, contract tests, docs, and thin-layer extractions
2026-07-18 20:24:28 -04:00
Claude c6b2bad0a9 feat(location): calmer map, cell highlight, zoom on area size
- Mute the busy MAPNIK tiles in light mode (desaturate + gentle lighten), the
  light-mode counterpart to the existing dark tile filter. Applied to both the
  interactive picker and the preview thumbnail.
- Outline the selected geohash cell on the map so the user sees exactly which
  region a post/filter covers; the rectangle follows the pin and resizes with the
  area-size level.
- When the area-size level changes, animate the zoom so the new cell is framed.

LocationPickerMap gains zoomTo/highlight/highlightColor params (all guarded to
rebuild only on change, preserving the per-frame-cheap update path).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
2026-07-19 00:21:02 +00:00
Claude 4efb2cca98 fix(quartz,cli): audit fixes — bounded drains, no event loss, honest verdicts, false-reject traps
Adversarial audit of the PR's own changes (8 finder angles, verified
before fixing). Quartz core:

- fetchAll-family drains get a wall-clock ceiling (maxTotalMs, default
  10x the idle window, delay()-watchdog: cancellable and virtual-time
  testable). The pure idle window was unbounded when a relay trickled
  events forever — sandboxed napplet queries, set -e fetches, and
  marmot await stuck inside one drain. Streaming relays still finish.
- The suspending onEvent hook no longer runs inside a cancellable
  timeout scope (an expiring window could cancel verifyAndStore
  mid-write and silently drop a received event); the timeout is armed
  only when the channels are dry (no per-message timeout-job churn).
- fetchAll is a projection over fetchAllWithHooks: fixes its
  unsynchronized events/seenIds mutation from concurrent socket
  threads and deletes the duplicate loop + per-event activity channel.
- publishAndConfirmDetailed regains its only-responders contract
  (synthetic no-response entries no longer render as 'relay rejected
  your message' in app callers); results built by pure associateWith;
  shared failure-reason constants + PublishResult.isTransportFailure.
- NIP-65 mutations: split read+write r-tags for the same URL now merge
  to BOTH instead of last-wins dropping a facet (+ test).
- TcpProber's 128-thread pool drains after 60s idle.

CLI:

- publishGuard: all-transport failure exits 124 as timeout; rejected/1
  is reserved for an actual OK-false answer.
- --help anywhere in argv is hoisted centrally; 'amy notes post "x"
  --help' prints usage instead of publishing.
- rejectUnknown false-reject traps fixed: geochat --no-fetch behind an
  early return, and 13 elvis-alias short-circuit sites read eagerly.
- Aliases load once per Context and only match name-shaped inputs (no
  shadowing a real npub/NIP-05/hex); stderr color requires a
  positively-known terminal (TERM sniff polluted captured logs).
- Relay-CSV strictness unified on RawEventSupport.relayFlag (post,
  graperank publish/followers/register no longer silently drop
  malformed URLs); Args.timeoutMs(+OrNull) replaces 27 hand-rolled
  conversions, all strict; offer/debit --timeout > 3600 rejected with
  a 'looks like milliseconds' hint; NPub.create idiom; stale jq .id in
  the marmot reactions harness; printUsage drift (offer pay --with,
  profile --clink-offer, search --kind).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-19 00:17:43 +00:00
Claude 787fd99d1e fix(location): picker polish — open zoom, search pill, chip rows
- Open the seeded picker at a zoom that fits the geohash cell's precision
  (region opens wide, building opens tight) instead of a fixed zoom.
- Hide the search field's rectangular outline so its square corners no longer
  poke through the rounded pill (border colors → transparent).
- Drop the 48dp minimum touch-target on the area-size chips so wrapped rows sit
  8dp apart (matching the horizontal gap) instead of ~24dp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
2026-07-18 23:57:06 +00:00
Claude b20efb3147 fix(location): map drag interception + wrap precision chips
- The interactive picker map didn't tell ancestor views to stop intercepting
  touches, so a horizontal drag on the Teleport map got stolen (opening the nav
  drawer) instead of panning. Add the same requestDisallowInterceptTouchEvent
  touch listener LocationPreviewMap already uses.
- The precision chips were a single horizontally-scrolling Row; make them a
  FlowRow so "Region · ~1250 km … Building · ~38 m" wraps onto multiple lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
2026-07-18 23:45:44 +00:00
Claude 78e641b3b2 fix(location): audit fixes for the geohash picker
Bugs:
- "Use my location" could spin forever when a fix never arrives (location off,
  indoors, permission granted but unavailable). Wait with a 20s timeout and stop
  on any terminal outcome so the button always resets.
- With no seed, an initial osmdroid layout-scroll at the opening center could be
  mistaken for a pick, auto-selecting the mid-Atlantic and enabling Confirm before
  the user moved. Gate selection behind a real pan/search/GPS/tap (hasSelection).

Performance:
- The map's AndroidView update ran setColorFilter + overlays.removeAll +
  invalidate() on every scroll-driven recomposition. Move the dark-mode tile
  filter to a theme-keyed effect, and rebuild the marker + invalidate only when
  the marker point actually changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
2026-07-18 23:43:02 +00:00
Claude 962d1706dc fix(quartz): fetchAll-family timeouts are idle windows, not absolute deadlines
The fetchAll/fetchAllWithHooks accessories wrapped their whole
collection loop in one withTimeoutOrNull, so a relay actively
streaming a large backlog was cropped mid-delivery the moment the
absolute deadline hit — even though the loop already has proper
terminal conditions (per-relay EOSE / CLOSED / cannot-connect) and the
timeout's only real job is stall detection.

timeoutMs now measures the delta since the LAST message: every event
or terminal signal resets the window (fetchAll gains a conflated
activity ping so event progress is visible to its wait loop), and only
a full window of silence ends the fetch early. fetchFirst/count keep
absolute waits (single-response — idle and absolute coincide), and
subscribe's duration timeout stays absolute by design (a live stream
has no terminal state).

Since the pages/pool helpers delegate to fetchAll, pagination inherits
the semantics. This also changes app-side callers of these accessories
— in their favor: the timeout only ever fired on slow relays, exactly
when cropping loses data.

New commonTest suite pins the behavior: a relay emitting every 200ms
under a 300ms window streams to completion (10/10 events); a stall
ends one window after the last message, not after the start; EOSE
still returns immediately. CLI docs reworded (--timeout = idle window).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 23:39:25 +00:00
Claude 620fc465df feat(cli): publish results carry each relay's rejection reason; converge output shapes
With no external consumers yet, converge the --json surface to its
ideal shape in one pass:

- quartz gains publishAndCollectResults: the NIP-01 OK message, connect
  errors, and silent timeouts now survive as PublishResult(accepted,
  message) per relay instead of dying in a debug log. The existing
  boolean APIs delegate unchanged. Silent relays are reported as
  'no response within timeout' rather than omitted.
- Context.publish returns the rich map; the new
  RawEventSupport.ackFields(ack) is the one canonical projection every
  publisher emits: published_to (urls) + rejected_by as
  [{relay, reason}] — 'why didn't it post' now answers itself, in
  partial failures and in the rejected error alike.
- author/pubkey rule enforced module-wide: 'author' is the key that
  signed an event (feed/search/dm/message list items), 'pubkey' an
  identity being described; profile show and outbox add the bech32
  npub beside the hex when the user is the primary subject.
- Event-list items converge on event_id/author/created_at/content
  (dm, feed, search, marmot message, geochat, concord).
- Byte counts standardize on *_bytes keys (blossom/nsite size ->
  size_bytes); the text renderer drops the fragile bare-'size'
  heuristic and colors stderr progress independently of a piped
  stdout.
- Error details are sentences everywhere (not bare gids); dead Result
  class removed from the quartz publish accessory.

Docs updated (DEVELOPMENT output conventions, README rejected example).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 23:30:06 +00:00
Claude fea9bcb8ea feat(composer): extend map location picker to all composers
Lift pickedGeoHash into the shared ILocationGrabber interface and add a shared
GeoHashPostSection composable (GPS default + "pick on map"), then wire it into
every remaining composer: long-form, classifieds, private DMs, public messages,
NIP-22 comments, channel messages, and nest messages. Each ViewModel now honors
the map-picked geohash over GPS at build time and round-trips it through drafts;
each screen renders the shared section in place of the GPS-only LocationAsHash.

The short-note composer is refactored onto the same shared section (its local
copy removed). The geohash-chat "New location channel" screen already reaches the
picker via its Teleport card, which now uses the shared, polished picker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
2026-07-18 23:29:46 +00:00
Vitor PamplonaandGitHub 43704772a7 Merge pull request #3641 from vitorpamplona/claude/nip29-group-load-perf-wz4yca
NIP-29 group chat: split state (always-on) from content (paginated)
2026-07-18 19:15:22 -04:00
Claude 8d3a47b8f8 refactor(cli): split GrapeRankCommand into graperank/ sub-files; TCP prober to quartz
The 1500-line GrapeRankCommand (15 sub-verbs in one object, 7.5x the
module's 200-line smell threshold) becomes a 165-line dispatch that
delegates to graperank/{Crawl,Score,Publish,Operator,Support}. The TCP
reachability pre-probe with its dedicated 128-thread dispatcher is
transport infrastructure, not command code — it moves to quartz
nip66RelayMonitor/reachability (TcpProber, jvmAndroid). Pure move, no
behavior change; cli tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 23:00:15 +00:00
Claude 82f041b1f5 docs(cli): reconcile README/ROADMAP/DEVELOPMENT/tests with reality
The docs had drifted badly behind the code: geochat was documented
nowhere, concord/zap/search/podcast20/nsite-publish were README-
invisible, the ROADMAP matrix contradicted its own nak table on six
shipped features, and DEVELOPMENT described the legacy FS event store
as the default when SQLite is.

- README: sections for search, zap (incl. --with auto-pay), podcast20,
  nsite/napplet (all four sub-verbs), concord (13 verbs), geochat, and
  a 'Which chat system?' comparison table; output section rewritten for
  the new contract (exit-code derivation, rejected, unknown-flag
  errors, -- terminator, per-command --help); layout diagram fixed for
  the SQLite default + operator/ + concord.json; the bunker nak-interop
  claim reworded honestly; RECIPES.md linked.
- ROADMAP: stale new-item rows flipped (follow, outbox, Blossom,
  bunker, search; zap partial), rows added for relaygroup/geochat/
  concord/nsite/napplet/podcast20/CLINK/fof, orphaned thread note
  fixed, test-suite section updated.
- DEVELOPMENT: canonical error-code list pinned, exit-code rule
  documented, no-prompts carve-outs, refreshed architecture tree +
  command template (USAGE/route(help=)/rejectUnknown/publishGuard),
  SQLite store section, testing table covers the new JVM suites.
- tests/README: all ten suite dirs listed, JVM contract suite noted,
  mis-spliced marmot row repaired.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 22:59:37 +00:00
Claude 7b32ca1da3 feat(composer): add "pick on map" location to the short-note composer
The composer could only attach the current GPS location (fixed ~5km), which
fails when GPS is denied/unavailable and can't tag a post with a different
place. Add a "pick a place on the map" action that opens the shared
GeohashLocationPickerDialog and stores the result in
ShortNotePostViewModel.pickedGeoHash, which overrides the GPS fix at build time
and round-trips through drafts. Picking a place also skips the GPS permission
prompt. The existing "use my location" GPS flow is unchanged; this is additive.

Scoped to the short-note composer for now; the other ILocationGrabber composers
(long-form, classifieds, DMs, comments, channel/nest messages) can adopt the
same pickedGeoHash override + section in a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
2026-07-18 22:57:55 +00:00
Claude 6e664b2db6 refactor(cli): extract the drain loop to quartz accessories; split Context per domain
Context.drain/drainAllPages/requestResponse re-implemented the
subscription state machine quartz already ships in
relay/client/accessories — the CLI-specific needs (per-event
verify-and-store hook, dead-relay collection, pending-on-auth) now live
in an option-rich fetchAll variant there, and Context keeps thin
adapters. The per-domain sections bolted onto Context (Cashu seed
warming/snapshot/restore counters; the Concord stream-key AUTH
registry) move to CashuContext/ConcordAuth, with the NUT-09 restore
counter rule shared via commons CashuWalletOps so the CLI and Android
can't drift. Context.kt: 1246 -> 950 lines; behavior unchanged
(cli tests green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 22:55:56 +00:00
Claude 8658dcd512 refactor(geohash): reuse the shared picker in the Teleport screen
Extract the picker body into GeohashLocationPickerContent (chrome-less: map +
search + use-my-location + precision + readout + confirm), leaving
GeohashLocationPickerDialog as a thin dialog wrapper around it.

Rewrite GeohashTeleportScreen to host that shared content inside its own scaffold,
passing a teleport-specific confirm label and action (follow the cell + open the
chat with the teleport flag). This dedupes the map/precision/city-name code that
Teleport previously copied, and gives Teleport place search, "use my location",
dark-mode tiles, area-size chips, and the animated pin for free.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
2026-07-18 22:36:47 +00:00
Vitor PamplonaandClaude Opus 4.8 ad2c232868 build(tor): rebuild libarti_android.so for the SOCKS reply-code fix
Regenerates the shipped native library from the preceding lib.rs change. Kept
as its own commit so the binary diff is isolated and can be audited against the
source change independently.

Built with the canonical reproducible-build path (/tmp/amethyst-arti-build) and
the pinned 1.94.1 toolchain; ARTI_VERSION and Cargo.lock are unchanged, so
lib.rs is the only input that moved. Note that the build path is embedded in
the output, so overriding ARTI_REPRO_DIR produces different bytes — the
artifact committed here comes from the canonical path.

verify-reproducible.sh passes: two clean builds produced identical bytes.
  arm64-v8a  3293f9fd1663f9481972f4b705fe80feb9650271b5a3b4258420501d6375bb3e
  x86_64     a5a5864741a09708cd0b6e44ef3ba28298b4862872084d98e172487d6b12720b

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 18:35:25 -04:00
Vitor PamplonaandClaude Opus 4.8 dd6e7e2852 fix(tor): map Arti errors to accurate SOCKS reply codes
The JNI wrapper answered every failed client.connect() with SOCKS reply 0x05,
so a domain that no longer exists, an exit that timed out, and a genuinely
refused port were indistinguishable. Java renders 0x05 as
SocketException("SOCKS: Connection refused"), so the whole failure taxonomy
collapsed into one opaque string and callers could only apply their most
generic retry policy. On a cold start with the default settings — torType
INTERNAL and newRelaysViaTor true, so the entire outbox fan-out is routed
through Tor — 639 of roughly 768 relay failures arrived this way.

This is why the DNS classification added earlier was effectively dead code for
default users: name resolution happens at the exit, so UnknownHostException is
never raised locally.

socks_reply_for() maps ErrorKind onto the codes Java surfaces with distinct
messages, so a caller can tell "this relay is gone" from "this circuit had a
bad minute". No new dependency — arti-client re-exports ErrorKind and HasKind.

RemoteHostResolutionFailed is mapped to 0x04 even though Arti documents it as
retryable, because an exit's resolver failing is not proof the name is dead.
The caller's response to 0x04 is a bounded backoff rather than permanent
condemnation, which is a retry, just a slower one; probing the relays that
produced this error found 17 of 21 to be NXDOMAIN from an ordinary resolver, so
the conservative reading costs far more than it saves. Changing that arm to
0x01 restores Arti's reading if that tradeoff is ever judged wrong.

Verified on device: Java now reports "SOCKS: TTL expired", "SOCKS: Host
unreachable", "SOCKS: Connection not allowed by ruleset" and friends where
everything was previously "SOCKS: Connection refused".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 18:35:25 -04:00