Commit Graph

2037 Commits

Author SHA1 Message Date
m 3383b60315 feat(namecoin): distinguish malformed-JSON values from missing-field
When a Namecoin record's value isn't valid JSON (a real failure mode
when an operator hand-builds the value and miscounts braces), the
NIP-05 path used to silently swallow the parser exception and surface
a misleading "no nostr field" message. That sends the publisher
chasing a phantom missing field when the actual problem is the value
itself.

Concrete case that triggered this: a `name_update` published a
474-byte d/testls value with one closing brace short of balanced. The
string parses up to the missing brace, after which kotlinx.serialization
throws "Unfinished JSON term at EOF at line 1, column 474". That error
was previously dropped, leaving the operator to debug "no nostr field"
without ever seeing the underlying JSON parse failure.

Changes:

- New NamecoinResolveOutcome.MalformedRecord(name, error). Distinct
  from NoNostrField. The `error` field is the parser's own diagnostic
  (e.g. "Unfinished JSON term at EOF at line 1, column 474") so the
  publisher can locate the broken byte without spelunking.
- NamecoinNameResolver.performLookupDetailed: parse via a new
  parseValueOrError helper and surface MalformedRecord instead of
  collapsing into NoNostrField. Also rejects non-object top-level
  values (arrays, primitives, null) with a useful diagnostic
  ("top-level value is JsonArray, expected JSON object").
- DesktopSearchScreen handles the new outcome by surfacing the parser
  error verbatim in the Namecoin status banner, so the column number
  reaches the publisher's screen.

Tests (commonTest / NamecoinImportTest):

- "NIP-05 lookup surfaces MalformedRecord with parser detail when
  value is broken JSON": a deliberately one-brace-short value yields
  MalformedRecord with a non-empty diagnostic.
- "NIP-05 lookup surfaces MalformedRecord when top-level value is a
  JSON array": ensures non-object top-level values are rejected with
  a useful "expected JSON object" message rather than silently
  parsing as something unusable.

Tests don't pin the exact parser wording (kotlinx.serialization can
change it across versions); they only pin that the message is
attributed to JSON parsing rather than to a missing field.
2026-05-09 10:02:26 +10:00
Claude 667d3d51c7 docs(quartz): add parked plan for local Bitcoin headers OTS explorer
Comprehensive design doc for a headers-only Bitcoin P2P client that would
let NIP-03 OTS attestations be verified locally against the proof-of-work
chain instead of a trusted block explorer. Parked pending direction on
NIP-BC onchain-zaps verification, which has overlapping requirements.
2026-05-08 19:37:30 +00:00
Vitor Pamplona d950be6d73 Merge pull request #2792 from davotoula/fix/imeta-dangling-space
fix(quartz): drop empty/whitespace-only imeta values
2026-05-08 14:05:53 -04:00
davotoula c7ff30a5d5 fix(quartz): drop empty/whitespace-only imeta values
NIP-92 imeta tag entries are encoded as "key SPACE value" strings.
When IMetaTagBuilder.add() received an empty (or whitespace-only)
value the encoder produced "key " with a trailing space, which
schema-validating relays reject as a malformed tag value.
2026-05-08 19:28:11 +02:00
Vitor Pamplona abd565a460 Merge pull request #1914 from mstrofnone/desktop-namecoin-port
Port Namecoin NIP-05 resolution to Desktop app
2026-05-08 08:55:09 -04:00
m 23a7ac4770 fix(namecoin): accept single-identity {nostr:{pubkey,relays}} on d/ names
ifa-0001 doesn't mandate that domain records use the nostr.names
sub-dictionary. Operators who own a name outright commonly publish:

    {"nostr": {"pubkey": "<hex>", "relays": ["wss://..."]}}

(the same shape id/ records use). Before this fix d/-namespace branch
only accepted {"nostr":"<hex>"} or {"nostr":{"names":{...}}},
so a record like d/mstrofnone with the single-identity object form
silently failed with NoNostrField even though id/mstrofnone resolved
fine.

Resolution rules:
  1. nostr.names wins for any sub-identity.
  2. Root lookups fall back to bare pubkey when names["_"] is absent.
  3. Non-root lookups against names-only or single-identity records
     do NOT silently use the bare pubkey.
2026-05-08 22:33:06 +10:00
m 5efead42fb fix(quartz/electrumx): parse NAME_FIRSTUPDATE outputs alongside NAME_UPDATE
Names whose latest on-chain transaction is still the initial registration
(OP_NAME_FIRSTUPDATE = OP_2 = 0x52) were silently dropped because the
parser only matched OP_NAME_UPDATE (OP_3 = 0x53). The scripthash index
returns the FIRSTUPDATE tx in that case, so resolution looked
'unreachable' even though every server answered.

Accept both opcodes when scanning vouts and when parsing the script.
FIRSTUPDATE pushes <name> <rand> <value>, so skip the extra <rand> push
before reading the value.
2026-05-08 22:33:06 +10:00
Vitor Pamplona ab09c520ce Merge pull request #2758 from mstrofnone/feat/nip9a-community-rules
feat(quartz): NIP-9A community rules parser + validator (kind:34551)
2026-05-08 08:30:50 -04:00
Vitor Pamplona 06d6425369 Merge branch 'main' into claude/add-blossom-cache-support-ts8mK 2026-05-08 08:15:44 -04:00
Vitor Pamplona 8b1b49007c Merge pull request #2785 from greenart7c3/claude/disable-client-tags-option-Nb3b4
Add option to disable NIP-89 client tag in published events
2026-05-08 08:13:49 -04:00
Vitor Pamplona 0127ed4412 Merge pull request #2778 from nrobi144/fix/bunker-timeout-and-decrypt
feat(desktop): custom feeds system with creation, discovery, and author search
2026-05-08 08:11:55 -04:00
Claude f1650ad9ce feat(privacy): add per-account toggle to disable NIP-89 client tag
Adds a "Don't add client tag to my events" switch under Security Filters.
NostrSignerWithClientTag now consults a runtime predicate at sign-time, so
the toggle takes effect immediately on the live session without rewrapping
the signer or rebuilding Account.

https://claude.ai/code/session_01KhNVTm8LLdVphqyxVkZtDS
2026-05-08 08:44:32 +00:00
Claude 28fb3b0e83 test(quartz): make repeat-sub test tolerant of resub timing race
The mid-stream resub pattern (resub1 at events.size==1, resub2 at
events.size==5) has four receive() calls between the two subscribe
calls. On a slow CI worker that's enough time for resub1
(filtersShouldIgnore, kind 10002 limit 500) to actually reach the
relay and start streaming events before resub2 replaces it, so the
loop's second EOSE can come from filtersShouldIgnore — which can
deliver up to 50 events (the preloaded count for that kind), well
past the previous 1..11 bound.

Drop the phase-specific count asserts and verify only structural
invariants: exactly two EOSEs, the loop stopped on the second, every
non-EOSE entry is a valid 64-char id, and the total event count
stays within the worst-case envelope.

https://claude.ai/code/session_01L4qS7BhX3L7ApiS3HsCozn
2026-05-08 00:59:57 +00:00
Claude 44a8aeea28 Merge branch 'main' into claude/review-fanout-index-e1Uqq
Conflicts:
- quartz/.../LiveEventStore.kt: main added IngestQueue (group-commit)
  and a fire-and-forget submit() path; this branch replaced the
  SharedFlow live fanout with FilterIndex-driven dispatch. Resolved
  by keeping main's submit/insert pipeline shape (IngestQueue ingest
  ctor param, submit() callback, insert() wrapping submit via
  CompletableDeferred) but routing the on-Accepted fanout through
  FilterIndex.candidatesFor instead of newEventStream.tryEmit.
  Added private fanout(event) helper. Kept main's
  snapshotIdsForNegentropy method.
- geode/.../LoadBenchmark.kt: both sides added a new @Test method.
  Kept fanoutScaling (this branch) and publishPipelinedSingleClient
  (main).
2026-05-07 23:55:00 +00:00
Claude 7c4f2b720a refactor(negentropy): audit fixes for interop tests
- Delete `strfryDrivesGeodeAsServer` — boots strfry but uses
  kmp-negentropy ↔ Geode, no actual strfry-vs-geode interop.
  Duplicates `Nip77NegentropyTest.negentropyComputesSymmetricDifference`.
- Strip speculative `negentropy { enabled = ... }` and `nofiles`
  blocks from the strfry config; defaults are what we want to test.
- `InteropSyncDriver.reconcile` → `negotiate`. The function
  computes the symmetric difference; it doesn't move events.
  Convert to `suspend fun` to drop a nested `runBlocking` that
  could deadlock under dispatcher pressure.
- Inline `pullSync` helper in GeodeVsGeodeNegentropySyncTest —
  it was a one-line wrapper with a misleading name.
- Batch `relayB.preload(needFromA)` instead of looping.
- Tighten `idsOnRelay(NormalizedRelayUrl)` signature (was
  re-parsing the string on every call).
- Drop redundant `assertTrue(size > cap)` after `assertEquals(11)`.
2026-05-07 23:34:37 +00:00
Claude 4b7e0e880c feat(negentropy): strfry-parity NIP-77 reconciliation path
Implements the A+B+C+D plan in geode/plans/2026-05-07-negentropy-large-corpus.md:

- A: id-and-time-only snapshot. Adds IEventStore.snapshotIdsForNegentropy
  returning IdAndTime(createdAt, id) with no Event materialization. SQLite
  override projects directly off event_headers (~40 B/entry instead of
  ~1 KB/entry; matches strfry's MemoryView footprint).

- B: snapshot-size cap. New [negentropy] config section with
  max_sync_events=1_000_000 (mirrors strfry's relay__negentropy__maxSyncEvents).
  Overflow returns NEG-ERR "blocked: too many query results" (strfry-exact
  wording). No default since-window (strfry honors filters as-is; bounding
  silently would break interop).

- C: 500_000-byte frame cap. NegentropyServerSession.DEFAULT_FRAME_SIZE_LIMIT
  matches strfry's hard-coded Negentropy ne(storage, 500'000). Configurable
  via [negentropy].frame_size_limit.

- D: per-connection session cap = 200 (matches strfry). Overflow sends NOTICE
  "too many concurrent NEG requests" (strfry parity).

Error-string interop:
  - NEG-MSG with unknown subId → "closed: unknown subscription handle"
  - reconcile() parse failure → "PROTOCOL-ERROR"
  - snapshot overflow → "blocked: too many query results"
  - per-conn cap → NOTICE "too many concurrent NEG requests"

NegentropySettings flows: RelayConfig.NegentropySection → Relay → NostrServer
→ RelaySession → NegSessionRegistry. RelayHub takes optional settings for
tests that need to exercise the caps.

Tests:
  - SnapshotIdsForNegentropyTest covers projection correctness across all
    indexing strategies + the maxEntries+1 sentinel contract.
  - Nip77NegentropyTest gains negOpenSnapshotOverflowReturnsStrFryNegErr
    and negOpenPerConnectionCapEmitsNotice.
  - Existing negMsgWithoutOpenReturnsNegErr updated to assert strfry wording.
2026-05-07 23:34:37 +00:00
Claude 7430c2aa17 fix(quartz): audit fixes — VerifyAuthOnlyPolicy + small wins
Self-audit of the event-ingestion-batching changes turned up one
real bug + a handful of cleanups.

Fix: AUTH events skipped signature verification when
parallelVerify=true. Previous commit dropped VerifyPolicy from the
policy chain to avoid double-verifying EVENTs (the IngestQueue does
those off-thread). But VerifyPolicy.accept(AuthCmd) was the only
thing checking AUTH signatures — FullAuthPolicy verifies challenge
/ relay / expiry but trusts the sig. Removing VerifyPolicy let a
forged event mark a pubkey as authenticated.

Split VerifyPolicy into a parameterised base class
(VerifyEventsAndAuthPolicy) with two singletons:
- VerifyPolicy: verifies both EVENT and AUTH (existing default).
- VerifyAuthOnlyPolicy: verifies AUTH only — use when an
  IngestQueue does parallel EVENT verify, since AUTH commands
  bypass the queue entirely.

Geode's composePolicy now selects VerifyAuthOnlyPolicy when
parallelVerify is on, keeping AUTH signature checks intact while
still letting EVENT verify run in parallel on the writer's CPU
fan-out.

Cleanups (no behavior change):
- IngestQueue.processBatch was ~70 lines; split into verifyBatch /
  runInsertStage / dispatchOutcomes.
- Single-event verify shortcut: skip the coroutineScope + async
  dance for batch-of-1 (the common low-load case) and call the
  hook directly.
- Hoisted the "internal error: missing outcome" Rejected sentinel
  to a companion `missingOutcome` constant.
- Imported ClosedSendChannelException / ClosedReceiveChannelException
  instead of using the fully-qualified form inline.
- Dropped NostrServer's verifyEvent companion wrapper — direct
  lambda is just as cheap and the "single instance" comment was
  inaccurate.
- ObservableEventStore.batchInsert now uses requireNoNulls() rather
  than @Suppress("UNCHECKED_CAST"), since every index is provably
  populated.
2026-05-07 23:16:52 +00:00
Claude 70afcd10ca fix(quartz): close HashSet race in LiveEventStore.query dedupe
The previous design wrapped a mutable HashSet in an AtomicReference,
which makes the *reference* thread-safe but not the set's internals.
The historical-replay closure ran `seenIds.load()?.add(event.id)`
from the subscriber's coroutine while `deliver` ran
`seen.contains(event.id)` from the publisher's coroutine —
concurrent add/contains can corrupt buckets or throw CME.

The pre-index implementation didn't have this race because both
operations ran on the same Flow collector coroutine. Index-driven
dispatch put them on different coroutines.

Switch to AtomicReference<Set<String>?> over an immutable Set with a
CAS-loop on add. Reads in `deliver` always see a fully-constructed
snapshot. Single-writer in steady state so the CAS typically
succeeds first try; the loop only matters across the
`seenIds.store(null)` post-EOSE handoff.

Also:
- hoist `NostrSignerSync(keys[target])` out of the per-iteration
  loop in LoadBenchmark.fanoutScaling.
- add FilterIndex tests for `tagsAll` selection, multi-char tag
  fall-through, and re-register key-union semantics.
2026-05-07 23:16:18 +00:00
Claude 289bc4bd5c perf(quartz): Tier 3 — parallel Schnorr verify in IngestQueue
Closes the last item on the event-ingestion-batching plan: signature
verification no longer serialises on each connection's WebSocket
pump. Instead, IngestQueue takes a `verify: ((Event) -> Boolean)?`
hook and fan-outs the per-batch verify across Dispatchers.Default
(`coroutineScope { events.map { async { verify(it) } }.awaitAll() }`)
before opening the SQLite transaction. Failed verifies pre-mark
Rejected and skip the insert.

Wiring:
- NostrServer takes `parallelVerify: Boolean = false` (opt-in to
  preserve existing behaviour for direct library users).
- geode.Relay forwards a matching flag.
- Main.kt enables it whenever signature checking is on (config
  `[options].parallel_verify = true`, default true), and when so,
  composePolicy is told to skip VerifyPolicy from the chain to
  avoid double-verifying every event.
- New CLI escape hatch `--no-parallel-verify` for the legacy path.

Bench: adds publishGroupCommitSingleClient (sequential publish-and-
confirm; 500 EPS regression floor for the synchronous path) — the
companion to the existing pipelined bench that exercises the
group-commit + parallel-verify wins.

Plan doc updated to describe what shipped (batchInsert + SAVEPOINTs
in Tier 1, IngestQueue mechanics in Tier 2, the verify hook in
Tier 3) and to drop the obsolete `synchronous=NORMAL` confirmation
note — the project ships `synchronous=OFF` and intentionally keeps
that.
2026-05-07 23:00:30 +00:00
Claude 7a92f4ef2f perf(quartz): group-commit + per-connection ingest pipeline
Implements the event-ingestion-batching plan: SQLite group commit
with per-row SAVEPOINT isolation, and a per-server IngestQueue that
turns RelaySession.handleEvent into fire-and-forget. The OK frame is
emitted from the writer's callback once the row's outcome is known,
relying on NIP-01 pairing OKs by event id (not by order).

- IEventStore.batchInsert + InsertOutcome contract; SQLite override
  uses SAVEPOINTs so one bad event doesn't roll back the others.
  ObservableEventStore forwards persistable rows to the inner batch
  and emits StoreChange.Insert for accepted ones.
- IngestQueue drains submissions in batches up to 64 per
  transaction. Writer coroutine starts lazily on the first submit
  so subscription-only sessions don't pay for it (and don't perturb
  Default-dispatcher scheduling — the eager launch was visible as
  intermittent NostrClientRepeatSubTest flakes under full-suite
  load).
- RelaySession.handleEvent posts to the queue and returns
  immediately; the WS pump moves to the next frame instead of
  awaiting SQLite. ClosedSendChannelException during shutdown
  surfaces as OK false rather than crashing the pump.
- LiveEventStore.submit fans an event onto the live stream only
  after the writer reports Accepted; the suspending insert is
  retained for tests, routed through the same queue.
- New publishPipelinedSingleClient benchmark in geode.perf:
  10 000 EVENTs back-to-back without awaiting OKs, asserts every
  event id receives exactly one OK (in any order).
2026-05-07 22:41:40 +00:00
Claude 3946117084 perf(quartz): index-driven fanout for LiveEventStore via FilterIndex<S>
Replace the SharedFlow-based broadcast in LiveEventStore with an
inverted index over filter-bearing subscribers. Each REQ registers
its filters into a per-store FilterIndex<LiveSubscription>; insert()
calls index.candidatesFor(event) and only delivers to candidates
whose Filter.match still passes. Cuts the per-event walk from
O(N_subs * N_filters) to a few hash lookups plus match() over a
small candidate set.

FilterIndex itself lives next to Filter.kt (commonMain, KMP-friendly,
AtomicReference + COW) so other call sites with the same shape
(LocalCache.observables, ObservableEventStore.changes) can reuse it.
Each filter contributes entries on its single most-selective dimension
(ids > authors > tags > tagsAll > kinds > unindexed) to keep buckets
narrow and avoid Set-dedupe work in candidatesFor.

The historical-replay race the previous SharedFlow + onSubscription
handoff closed is preserved by registering BEFORE replay starts and
deduping seen ids until EOSE.
2026-05-07 22:37:31 +00:00
Vitor Pamplona 1bee982e34 Merge pull request #2767 from vitorpamplona/claude/connection-scaling-plan-YVjc8
Scale relay to 10k+ concurrent connections with streaming JSON parsing
2026-05-07 17:22:36 -04:00
Claude 64624bdfdb perf(quartz): streaming filter deserializer to remove tree allocation
The relay-inbound path (REQ / COUNT / NEG-OPEN) was the only place
ManualFilterDeserializer still went through `jp.codec.readTree(jp)`,
materializing a full ObjectNode per filter before walking it. The
Command/Message/Event deserializers next door already stream straight
off the JsonParser; this brings filter parsing onto the same shape.

- Add ManualFilterDeserializer.fromJson(JsonParser): mirrors
  EventDeserializer's hand-rolled token loop. Dispatches on field
  name, reads the seven fixed keys + dynamic #x / &x tag arrays via
  nextToken / nextTextValue / longValue / intValue, and drops invalid
  entries silently (same tolerance as the tree-based mapNotNull path).
- Wire the four internal call sites — three in CommandDeserializer
  (REQ, COUNT, NEG-OPEN) and the standalone FilterDeserializer — to
  the streaming overload.
- Keep the existing fromJson(ObjectNode) overload as-is for any
  external/cross-format adapter that already has a tree in hand.

For a single REQ with N filters this drops N ObjectNode trees per
inbound frame, which at 10k connections × ~5 filters × 1 msg/s is
~50k tree allocations/sec we don't have to do. No behavioral change:
all quartz JVM tests pass (including the cross-mapper round-trip
suite that compares Jackson and KotlinSerialization output) and
geode's relay tests pass.
2026-05-07 20:41:39 +00:00
davotoula a2d3c478d2 Add pendingPublishRelaysFor stub to desktopApp StubNostrClient
Add pendingPublishRelaysFor stub to test NostrClient fakes
2026-05-07 21:57:39 +02:00
davotoula 474a957f2b Code review:
- @Volatile ScheduledPostNotifier.channel: two workers firing in the
  same window can race on ensureChannel from different IO threads.
  createNotificationChannel is idempotent, but the field write needs a
  visibility fence so both threads see the cached reference.
- @Volatile PoolEventOutbox.eventOutbox map ref + PoolEventOutboxState
  .relaysRemaining set ref. The new pendingPublishRelaysFor polling
  path reads these from the WorkManager IO thread; mutations still
  happen on NostrClient's IO scope. Pre-existing visibility gap that
  this poll surface exposed; @Volatile is the minimal fix.
- ScheduledPostsScreen.SectionLabel: read the locale from
  LocalConfiguration.current.locales[0] (the Compose-resolved locale)
  rather than Locale.getDefault() (the system locale, which can drift
  from app config and breaks Turkish I/i casing).
2026-05-07 21:51:49 +02:00
davotoula f6991bee15 Wait for relay OK ack before marking SENT
- Quartz: expose pendingPublishRelaysFor(eventId) on INostrClient
- Worker: after publish(), poll pendingPublishRelaysFor every 500ms up
to OK_TIMEOUT_SEC=30s
- notify user when a scheduled post fires or fails
2026-05-07 21:51:49 +02:00
Claude c19bd4e92e refactor(geode): test fixtures + collectUntilEose helper, move to testFixtures sourceSet
The in-process relay was usable but every consumer test paid a 10–15
line tax for scope/client setup, manual cleanup inside the test body
(leaks on assertion failure), hardcoded "ws://127.0.0.1:7770/" magic
strings, and an inline collectUntilEose pattern reinvented per file.

Net: ~250 LOC removed, every test gets correct @After cleanup, and
the synthetic event builders no longer ship in geode's production jar.

- Move geode.fixtures (synthetic event builders) and the new
  geode.testing package from src/main to a new src/testFixtures
  source set via the java-test-fixtures plugin. Production geode jar
  no longer includes them; consumers wire them with
  testImplementation(testFixtures(project(":geode"))).
- Add geode.testing.RelayClientTest open class — owns hub, scope,
  client, defaultRelay, defaultRelayUrl with @After-driven cleanup.
- Add geode.testing.collectUntilEose / collectUntilEoseMulti
  NostrClient extensions with a shared subId counter and timeout knob.
  Replaces hand-rolled subscriber loops in 4+ files.
- Add RelayHub.DEFAULT_URL constant so tests stop typing
  "ws://127.0.0.1:7770/" inline.
- Migrate the 8 quartz NostrClient*Test files + geode's
  Nip01ComplianceTest to extend RelayClientTest and (where REQ/EOSE
  is the pattern) call collectUntilEose. Delete the redundant
  BaseNostrClientTest wrapper.
2026-05-07 13:42:20 +00:00
Claude c2f24a5213 refactor: rename quartz-relay module to geode
The relay implementation now stands as its own module. Fitting brand
for a Nostr relay shipped alongside the Quartz library — a geode is a
rock that holds quartz inside.

- Rename module directory quartz-relay/ -> geode/.
- Rename Gradle module path :quartz-relay -> :geode (settings.gradle).
- Rename Kotlin package com.vitorpamplona.quartz.relay -> com.vitorpamplona.geode.
- Rename application name + main class binding to match.
- Update the relay's NIP-11 advertised name to "geode" and the
  software URL to /tree/main/geode. Test asserting the doc updated.
- Refresh comments / Main.kt usage line / config.example.toml header.
- Update consumers: quartz/build.gradle.kts dependency path, and
  quartz NostrClient tests that import the in-process RelayHub.
2026-05-07 13:12:34 +00:00
Claude f7d4e33409 refactor: promote relay-server toolkit from quartz-relay to quartz
Generalize the operator-agnostic pieces so any embed of the relay
server can use them without depending on quartz-relay (Ktor, TOML,
operator wrapper). quartz-relay shrinks to its actual job: TOML
config, Ktor wiring, persistence sidecar, and the Relay composition.

- Nip98AuthVerifier -> quartz nip98HttpAuth (verify() now suspend,
  uses kotlinx Mutex for KMP). Pairs with HTTPAuthorizationEvent.
- PassThroughPolicy + KindAllowDenyPolicy + PubkeyAllowDenyPolicy +
  RejectFutureEventsPolicy -> quartz nip01Core/relay/server/policies
  alongside the existing EmptyPolicy / VerifyPolicy / FullAuthPolicy.
- Collapse EmptyPolicy into 'object EmptyPolicy : PassThroughPolicy()'
  (PassThroughPolicy is now an open class instead of abstract).
- BanStore -> quartz nip86RelayManagement/server. Reimplemented
  lock-free with kotlin.concurrent.atomics.AtomicReference + immutable
  state snapshots so it works in commonMain.
- DynamicBanPolicy -> renamed to BanListPolicy; lives next to the
  BanStore it consults. The "Dynamic" qualifier was a contrast to
  static policies in quartz-relay; in its new home the name describes
  what it actually does.
- Nip86Server -> quartz nip86RelayManagement/server next to
  Nip86Client. Drops the RelayInfo wrapper indirection: InfoHolder
  now operates on Nip11RelayInformation directly.
- InProcessWebSocket -> quartz nip01Core/relay/server/inprocess.
  Constructor takes NostrServer (was: the operator-side Relay
  wrapper) so any embed can wire the same in-process bridge.
- Move BanStoreTest, Nip86ServerTest, Nip98AuthVerifierTest into
  quartz/jvmAndroidTest. Adjust runBlocking-bodied tests so JUnit 4
  sees Unit returns. PoliciesTest stays in quartz-relay tests because
  it uses module-local SyntheticEvents fixtures.
2026-05-07 13:02:23 +00:00
Claude e78561af67 refactor(relay): split overgrown files + reduce duplication
Audit follow-ups, no behavior change.

- Centralize NIPs/name/version constants in RelayInfo so RelayConfig
  and the default doc share one source of truth.
- Extract NegSessionRegistry (NIP-77 state + open/msg/close) out of
  RelaySession; the connection class now routes commands.
- Move multi-filter snapshot union/dedupe onto LiveEventStore.
- Pull Nip86HttpRoute and WebSocketSessionPump out of LocalRelayServer
  (was 485 lines, three responsibilities).
- Collapse Nip86Server.dispatch repetition with withHex/withHexAndReason/
  withInt/withString helpers; reuse Hex.isHex64 instead of a local regex.
- Make Nip11RelayInformation (and nested types) data classes so
  Nip86Server uses the synthesized copy() directly — drops the
  hand-rolled field-by-field shim.
2026-05-07 12:31:27 +00:00
Claude 9c4e87b937 feat(blossom): route image fetches through local Blossom cache
Adds support for the local-blossom-cache spec
(https://github.com/hzrd149/blossom/blob/master/implementations/local-blossom-cache.md):
when http://127.0.0.1:24242 responds 2xx to HEAD /, image and video
fetches are routed through it with xs= upstream hints so it can proxy
on miss. Toggle defaults ON per account; disable from Media Servers
settings.

Covers both blossom:// URIs and plain http(s) URLs that carry an imeta
sha256, by rewriting the latter to a synthetic blossom:<sha>?xs=<host>
URI before handing it to Coil/ExoPlayer.
2026-05-07 12:10:06 +00:00
mstrofnone 38df69ec83 feat(quartz): NIP-9A community rules parser + validator (kind:34551)
Adds CommunityRulesEvent and CommunityRulesValidator under
quartz/.../nip72ModCommunities/rules/, mirroring the existing definition/
and approval/ subpackages.

NIP-9A complements NIP-72: the freeform rules tag on kind:34550 keeps
the human-readable text, this new addressable kind:34551 event carries
the machine-readable companion. Rules cover per-kind allow-lists with
optional max-bytes and per-author-per-day quotas, per-pubkey allow/deny
overrides, an optional web-of-trust gate, a global event size cap, and
an anti-rollback ratchet for stolen-key recovery.

CommunityRulesValidator is pure logic (no I/O); WoT lookups are deferred
to the caller via a WotResolver functional interface so consumers can
plug in NIP-02 follow lists, NIP-85 trusted assertions, or any local
source.

14 commonTest cases covering each violation path and the explicit-allow
override over WoT gates.

Spec: https://github.com/nostr-protocol/nips/pull/2331

This commit only adds the protocol layer. UI integration (composer
validation, rules editor, feed filter) will land as separate PRs once
the NIP draft has had review feedback and a kind number is finalised.
2026-05-07 19:47:32 +10:00
Claude c440186575 feat(relay): NIP-77 negentropy reconciliation
Server-side negentropy: NEG-OPEN snapshots the matching event set,
NEG-MSG drives the round-trip via the kmp-negentropy library, and
NEG-CLOSE frees per-subId state. Same access controls as REQ apply
to NEG-OPEN. supported_nips bumped to advertise "77".
2026-05-07 04:02:02 +00:00
Claude b7ba3f6d15 fix(relay): security + concurrency audit + on-disk persistence
Addresses every Critical/High/Medium finding from the code-quality
audit, plus the operator request to persist NIP-86 admin state and
NIP-11 doc mutations across restarts.

## Security (audit Critical)

- C1 — NIP-98 verifier no longer trusts the client-supplied `Host`
  header. New `LocalRelayServer.publicUrl` (and `[admin].public_url`)
  must be set in any production deployment; the verifier compares
  against this fixed string. Falls back to Host for loopback unit
  tests with a docstring warning.

- C2 — NIP-98 replay protection. Verifier now keeps a bounded
  LinkedHashMap of recently-accepted event ids (TTL = 2× tolerance,
  max 1024 entries, LRU evicted) and rejects duplicates. Replay check
  runs LAST so a one-shot id isn't burned on a request that fails
  signature/url/method validation.

- C3 — NIP-86 admin POST body is now capped at 1 MiB (configurable
  via `LocalRelayServer.maxAdminBodyBytes`). The `Content-Length`
  header is honored as a fast pre-read reject; any body that exceeds
  the cap during streaming returns 413 PayloadTooLarge before being
  buffered.

## Concurrency / correctness (audit High)

- H1 — Per-session writer coroutine + bounded outbound queue
  (SESSION_OUTGOING_BUFFER = 1024). When the queue fills, the
  connection is closed cleanly instead of silently `trySend`-ing
  into a void; subscribers will never silently miss EVENT/EOSE
  again.

- H3 — `BanStore` kind ops serialize through a new `kindLock` so
  concurrent admin RPCs and policy reads see consistent state.
  `allowKind` and `disallowKind` are now symmetric: each removes the
  kind from the opposite set, so `allowKind(K)` after `disallowKind(K)`
  actually re-allows K.

- H4 — `InProcessWebSocket` reconnect after disconnect is now
  supported. Each `connect()` allocates a fresh CoroutineScope +
  drainer channel, so a prior `disconnect()` (which cancelled the
  previous scope) doesn't leave the new connect with a dead drainer.

- H5 — `Nip86Server.dispatch` re-throws `CancellationException`
  through the runCatching's getOrElse so structured concurrency
  works (cancellation no longer reported as an RPC error).

- M1 — `LiveEventStore.query` dedupes events between the historical
  replay and the live SharedFlow during the in-flight overlap window.
  Events seen during `store.query` are tracked in a transient set and
  filtered out of the live stream until EOSE; the set is dropped after
  EOSE so live-only events don't accumulate memory.

## Operator-facing (audit Medium / Low)

- L4 — Audit-trail log: every admin RPC writes a single structured
  line to stderr (pubkey + method + ok/error).

- L3 — RelayConfig.resolveInfo() default supported_nips list now
  matches RelayInfo.default() (both advertise NIP-86).

- M2 — Hex-id and pubkey params validated as 64-char hex on
  ban/allow/list methods; malformed input returns "invalid params"
  instead of being silently stored.

- M4 — argparse supports `--key=value` form in addition to
  `--key value`.

- M5 — `RelayHub.close()` is idempotent and rejects subsequent
  `getOrCreate` calls so a relay created mid-shutdown can't leak its
  store.

- M7 — `LocalRelayServer.stop()` sets a `shuttingDown` flag *before*
  notifying clients; new WebSocket upgrades during the grace window
  are rejected so they don't miss the NOTICE.

- L5 — Shutdown hook runCatching-wraps both `server.stop()` and
  `relay.close()` so a throw in one doesn't skip the other.

- `--verify` was renamed to `--no-verify` semantically (verify is
  the default). The `--verify` flag is no longer documented.

## On-disk persistence (operator request)

New `RelayStateStore` writes a single JSON sidecar file holding the
live NIP-11 doc + all NIP-86 ban / allow / kind lists. Atomic write
via temp + ATOMIC_MOVE rename so a crash mid-save can never leave the
file half-written.

- `BanStore` now takes an optional `onMutation: () -> Unit` callback;
  every mutation fires it. `Relay` wires it to `snapshot()` which
  captures BanStore + info into a `RelayPersistedState` and calls
  `RelayStateStore.save`.
- `Relay` accepts a new `stateFile: File?` constructor arg. At
  construction it loads the snapshot via `RelayStateStore.load` and
  seeds the in-memory state — using a private `seedFromSnapshot`
  bulk-load that bypasses the mutation callback so we don't write
  back what we just read.
- New config field `[admin].state_file = "/var/lib/quartz-relay/events.db.admin.json"`.
  Convention: place next to the SQLite event-store file.
- Corrupt state files are tolerated: log to stderr and start fresh
  (refuse to silently overwrite operator data).

## Tests

8 new persistence tests: cold-boot writes nothing, ban/allow/info
round-trip across restart, kind allow/deny round-trip, corrupt-file
recovery, atomic write (no leftover .tmp), in-memory mode when no
state file is configured, full RelayStateStore round-trip across all
sections.

Total :quartz-relay tests: 95, 0 failures.
2026-05-07 03:22:56 +00:00
Claude 58f6a0af6b fix(relay): forward ephemeral events to live subscribers (NIP-01)
LiveEventStore.query had a race window between emitting EOSE and
registering as a SharedFlow collector — any event emitted in that
window was lost because newEventStream has replay=0. The race was
usually masked by SQLite write latency for persisted kinds, but it
fired reliably for ephemeral kinds (20000-29999) where store.insert
is a no-op.

Per NIP-01 ephemeral events are not persisted but MUST still reach
matching active subscriptions. Fixed by registering the live
collector before signalling EOSE via Flow.onSubscription.

Adds two tests: one proves an ephemeral event reaches an active
subscriber, the other proves it isn't persisted (a follow-up REQ
returns zero events).
2026-05-07 01:03:06 +00:00
Claude eb80dbcd15 feat(quartz-relay): rename + promote to a real Nostr relay
Renames :quartz-test-relay to :quartz-relay and promotes it from a
test-only fixture to a real, runnable Nostr relay that just happens
to also be used in tests.

Two transports now share the same `Relay` core:

- `RelayHub` + `InProcessWebSocket` — no socket, fastest path; ideal
  for unit tests inside one JVM.
- `LocalRelayServer` — Ktor `embeddedServer` (CIO engine) listening
  on a real `ws://` port. Use for `cli` interop tests, Android
  instrumented tests, or running the relay standalone.

NIPs implemented (all driven through production `NostrClient` in the
new `LocalRelayServerTest`):

- NIP-01 wire protocol (REQ/EVENT/EOSE/CLOSE) over real WebSockets
- NIP-09 deletion (via existing `DeletionRequestModule`)
- NIP-11 relay info doc — `RelayInfo` wraps the existing
  `Nip11RelayInformation` model and is served on HTTP GET when
  `Accept: application/nostr+json` is requested. Loadable from a
  JSON config file via `RelayInfo.fromFile(...)`.
- NIP-40 expiration (existing `ExpirationModule`)
- NIP-42 AUTH (existing `FullAuthPolicy`, opted-in via the
  `--auth` flag in the standalone runner)
- NIP-45 COUNT
- NIP-50 search via the existing FTS index
- NIP-62 right-to-vanish (existing module)

Adds a standalone runner: `./gradlew :quartz-relay:run --args="--port 7447 --verify"`
binds the relay to a real port. Flags: --host, --port, --path,
--info <file>, --db <file>, --auth, --verify.

Test-only event generators moved to a clearly-named
`com.vitorpamplona.quartz.relay.fixtures` package so they're still
shareable with consumer tests without polluting the production API.

Adds `LocalRelayServerTest` covering NIP-01/11/42/45/50 over a real
loopback WebSocket. Existing 11-test `Nip01ComplianceTest` (in-process
transport) continues to pass.
2026-05-07 00:56:57 +00:00
Claude aefecf71d8 test(quartz): add :quartz-test-relay for in-process Nostr relay testing
Replaces production-relay (wss://nos.lol, wss://nostr.bitcoiner.social)
dependencies in 10 quartz JVM relay tests with a deterministic
in-process Nostr relay built on top of the existing NostrServer +
EventStore, plus a NIP-01 compliance suite that exercises the bridge
end-to-end through NostrClient.

The new :quartz-test-relay module exposes:
- TestRelay / TestRelayHub: NostrServer + in-memory EventStore per URL,
  registry implements WebsocketBuilder so tests can drop it into
  NostrClient in place of BasicOkHttpWebSocket.Builder.
- InProcessWebSocket: bridges the WebSocket abstraction to RelaySession
  with a single-coroutine drain to preserve message ordering.
- SyntheticEvents / RelayFixtures: deterministic event generators and a
  loader for the existing nostr_vitor_*.json corpora.

Found and fixed three NIP-01/NIP-45 wire-format bugs in the relay
serializers that prevented round-trip through the in-tree NostrClient:
- OkMessage wrote success as a JSON string instead of a boolean,
  causing publishAndConfirm to hang.
- CountMessage dropped the queryId, breaking COUNT response routing.
- CountResult used the field name "pubkey" instead of "approximate".

Both Jackson and kotlinx-serialization paths were affected; both fixed
to match NIP-01/NIP-45 wire formats.
2026-05-07 00:41:02 +00:00
davotoula fe2cb7da66 Centralise the NIP-05 wildcard check (name != "_") on the data class itself. 2026-05-06 18:32:11 +02:00
davotoula 54c48bc5b7 extract Nip05OrPubkeyLine into shared composable
Extract nip05 value calculation into helper
2026-05-06 18:32:11 +02:00
Claude 084fc7ddc2 test(quartz): make repeat-sub test tolerant of relay limit overshoot
nos.lol (strfry) sometimes returns one extra event past the requested
limit, pushing the second EOSE past the hard-coded slot 111 and failing
the run. Replace the fixed 112-message assertion with EOSE-driven
termination plus structural checks that allow the relay to return up to
limit + 1 events for each sub.
2026-05-06 15:55:55 +00:00
nrobi144 4010a57e68 feat(desktop): add custom feeds system with creation, pinning, and inline switching
- FeedDefinition data model with FeedSource sealed interface (Filter, Following, Global, DVM, PeopleList, InterestSet, SingleRelay)
- FeedDefinitionRepository with StateFlow, groupedFeeds, pin/unpin/reorder (max 3 pinned)
- JSON serialization via Jackson with round-trip tests (18 unit tests)
- SearchQuery.toFeedDefinition() bridge for creating feeds from search
- FeedBuilderDialog with author search (cache lookup + npub decode), hashtag/relay inputs, kind filter checkboxes, exclude authors/keywords, Cmd+S save, Enter to add
- FeedsDrawerTab in app drawer with edit/delete/pin/unpin actions
- Feeds tab in top header (FilterChips) with inline mode switching
- CustomFeedScreen + DesktopCustomFeedFilter + createCustomFeedSubscription for relay-based custom feed content
- FeedDefinitionEvent (kind 31890) in quartz for future publish/import
- Local persistence via java.util.prefs.Preferences (auto-save on change)
- DeckColumnType.CustomFeed for navigation integration
- Renamed Home to Feeds in nav rail
- Fix: AnimatedGifImage race condition (coerceIn on frame index)
- Fix: search results margins (sidePadding applied consistently)
- Fix: app drawer search includes feeds in results

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-06 09:59:30 +03:00
Claude d6be9e4574 test(quartz): drive nip40 expiration test through projection directly
Replace the wall-clock dance with a direct EventStoreProjection
construction wired to a controllable nowProvider. Both events are
inserted with expirations safely in the future so the SQL
reject_expired_events trigger never fires; the test then bumps the
fake clock and replays the StoreChange.DeleteExpired the observable
would emit after a sweep. Tests the projection's reaction to the
sweep signal — which is what this case was always about — without a
6s real-time delay or a flake window. Full SQL sweep behavior
remains covered by ExpirationTest.testDeletingExpiredEvents.
2026-05-06 00:50:30 +00:00
Claude 5f4d8ba747 test(quartz): widen NIP-40 expiration buffer to outlive insert race
The reject_expired_events trigger checks NEW.expiration <= unixepoch()
at insert time, so signing/insert latency on a slow runner could push
unixepoch() to time+1 before the row was committed, raising
SQLiteException at runBlocking entry. Bumping the short event to
time+5 with a matching delay(6000) mirrors
ExpirationTest.testDeletingExpiredEvents and gives the trigger room
to breathe without changing the behavior under test.
2026-05-06 00:42:14 +00:00
Claude 709e254c15 fix: periodic keep-alive to revive relays in long backoff
When a relay enters a long backoff (5 min, e.g. host unreachable or
a server returned an HTTP error during handshake), the per-relay
delayToConnectInSeconds blocks reconnect attempts for up to 5 minutes.
Without a wakeup, nothing inside NostrClient revisits that relay until
the next subscribe/count/publish.

Add a keep-alive coroutine that calls reconnectIfNeedsTo(false) every
60s while the client is active. The per-relay backoff still gates the
actual reconnect, so dead relays are not hammered, but a relay whose
backoff window has elapsed is reconnected within ~60s of becoming
eligible.

The job lives in scope and is cancelled by close().
2026-05-04 20:28:02 +00:00
Claude 575fe952c6 fix: auto-reconnect relays after server-initiated disconnect
Previously, when a relay closed the WebSocket (or the connection
dropped), NostrClient.onDisconnected only updated state and notified
listeners — it never tried to reconnect. The relay then stayed
disconnected until the next subscribe/count/publish call (which
triggers reconnect()) or an explicit reconnect() from the caller.

Now, if the client is still active and the relay is still in the
desired set (i.e. some sub/count/outbox still wants it),
onDisconnected schedules a debounced reconnect via reconnectIfNeedsTo,
which respects per-relay exponential backoff so we don't hammer dead
relays.
2026-05-04 20:17:29 +00:00
Vitor Pamplona 0210d608b5 Merge pull request #2719 from nrobi144/fix/bunker-timeout-and-decrypt
fix(nip46): fix bunker decrypt/encrypt parsing and increase timeout
2026-05-04 08:09:33 -04:00
Vitor Pamplona b1fb13cddc Merge pull request #2720 from davotoula/feat/log-lambda-overloads
chore: convert interpolated Log calls to lambda overload + restore throwables in Marmot catch blocks
2026-05-04 08:08:19 -04:00
Claude 76b69cd440 feat: bulk-remove for blocked users and hidden words
Long-press a row in Settings -> Security Filters to enter selection mode,
pick more rows, tap Unblock. The mute list (kind 10000) and block list
are rebuilt and re-broadcast once for all selected entries instead of
once per item.

- Quartz: add `MuteListEvent.removeAll` and `PeopleListEvent.removeAll`
  bulk overloads using the existing `TagArray.removeAny` primitive.
- Account / state classes: add `showUsers(List)` and `showWords(List)`
  that produce a single resigned event per list.
- AccountViewModel: thin wrappers `showUsers` / `showWords`.
- SecurityFiltersScreen: hoist per-tab selection sets, swap top bar
  in selection mode, add a local selectable user list (the shared
  `UserCompose` row was kept untouched).
2026-05-04 08:55:29 -03:00
davotoula 31dee7fe38 fix(log): pass throwable to Log.w in Marmot catch blocks
Previously the catch-block warnings interpolated ${e.message} but
dropped the throwable, losing the stack trace and showing nothing for
exceptions whose message is null. Switch to the (tag, msg, throwable)
overload so the cause and stack trace are logged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 10:52:39 +02:00
davotoula a22f63e42c refactor(log): convert interpolated Log calls to lambda overload
Avoid eager string interpolation when the log level is filtered out at
runtime. Covers Log.d/i calls and Log.w/e calls that did not pass a
throwable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 10:15:22 +02:00