Skip to content

v0.8.0 — client library, frontend reference, dashboard & relay management#103

Merged
0ceanSlim merged 87 commits into
mainfrom
v0.8-rc
Jun 18, 2026
Merged

v0.8.0 — client library, frontend reference, dashboard & relay management#103
0ceanSlim merged 87 commits into
mainfrom
v0.8-rc

Conversation

@0ceanSlim

Copy link
Copy Markdown
Owner

The accumulated v0.8 work, held off main under the RC hold and opened as a PR so CI validates the whole batch (build + integration tests, including the cgo-dependent suites) before it lands. 85 commits on the v0.8 theme: the importable client library plus the frontend that exposes it as the implementation reference.

Highlights

Client library (client/core) — outbox-model engine (#56)

  • Leased additive relay pool, Role vocabulary + routing, UserContext facade, multi-relay streaming-fetch primitive.
  • context.Context threaded through the read and publish paths.
  • Pluggable seams — Signer, Logger (SetLogger), RelayListStore — each with compile-checked examples.

Protocol

Frontend reference

Docs

  • Comprehensive client-library guide covering the full 0.8.0 surface, with compile-checked examples in client/core/example_test.go.

CI

This PR runs the CI / Integration tests workflow (docker-compose integration suite + host-side nostrdb build + go test ./...). Merging to main is the 0.8 RC.

Heads-up for the run: per #78, the NIP-86 fixture bind-mounts have a few known pre-existing failures, and TagFilterE is known-flaky — worth distinguishing those from anything new.

0ceanSlim and others added 30 commits June 10, 2026 19:19
Full design for the v0.8 client-library outbox work: four-layer model
(app defaults, shared growing pool, per-session config, per-target
directory), role taxonomy, routing table, hydration, query semantics,
streaming boundary, AUTH policy, reusable importable API surface, and a
phased plan (0-F). Source of truth for the #56/#77 implementation.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
GetUserRelays waited on sub.Done -- which EOSE never closes -- so every
mailbox fetch burned its full 5s timeout, and the login cache-fill ran the
mailbox then the metadata query serially (~10s worst case). The
/api/v1/events/query handler had the same sub.Done bug and always ran to
its 8s timeout.

- Add collectLatestReplaceable(): one EOSE-aware drain loop shared by
  GetUserProfile and GetUserRelays (removes ~50 lines of duplicated logic).
- GetUserRelays / GetUserProfile now resolve the moment a relay sends EOSE.
- QueryEventsHandler counts per-relay EOSE and finishes when all relays
  have sent their stored events.
- Run the mailbox + profile fetches concurrently in the cache-fill path.
- Make subscription IDs collision-proof with an atomic counter so the two
  now-concurrent REQs cannot cross-wire in the message router.
- Add native tests for the collector, including an EOSE-promptness
  regression guard.

Phase 0 of the outbox relay pool work. Refs #56, #77

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Introduce the connection substrate the outbox pool needs: one shared pool
that can hold many users' relays at once, dialed on demand and reclaimed
when idle, instead of the flat "connect a set, query all" model.

- RelayConnection gains lease counting (leases, idleAt); RelayPool gains
  pool-level pinned/backoff/failCount/dialing maps and a dial semaphore.
- Acquire/Release: connect-on-demand ref-counting, with an underflow guard
  so double-release or unknown urls are safe no-ops.
- Dialing runs off the pool lock with single-flight (concurrent acquires of
  one url share a dial) and bounded concurrency.
- Exponential, capped dial backoff so a dead relay is not re-hammered.
- Soft-cap LRU eviction on Acquire plus a ctx-bound idle sweeper that only
  touches unpinned, lease-free, idle connections.
- Pin() keeps index/seed relays from being evicted.
- core.Config gains dial/idle/backoff knobs (defaults only; MaxConnections
  10 -> 256). Two unexported test seams make Acquire testable without
  sockets.

Nothing calls these yet -- the pool is dormant, so login and queries are
unchanged. Wiring follows in the next slice.

Phase A (slice 1) of the outbox relay pool. Refs #56

Co-Authored-By: Claude Opus 4.8 <[email protected]>
)

Activate the Phase A pool primitives and remove the destructive single-user
teardown. Logging in no longer Close()s the entire relay pool to reconnect to
one user's relays -- the shared pool now grows additively and reclaims idle
connections on its own.

- Subscribe takes a lease on each target relay (connect-on-demand) and
  releases it on Close, so an active subscription's connections are never
  idle-evicted, and idle ones are reclaimed afterward. AddRelay/RemoveRelay
  kept lease-consistent.
- ReplaceRelayConnections / SwitchToUserRelays / SwitchToIndexRelays are now
  additive: they release the previous session's leases and acquire the new
  set, holding one lease per relay for the session. Index relays untouched,
  so auth.go's login path picks this up unchanged.
- The manager pins the index/seed relays so the sweeper never evicts them,
  and init.go starts the ctx-bound idle-eviction sweeper (#93).
- White-box test asserts the switch is additive: index survives a login, the
  previous user's relays are released, the new user's are leased.

Phase A (slice 2) of the outbox relay pool. Refs #56

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Pin down the relay-list event kinds for the outbox role model:
- DMInbox = NIP-17 kind 10050.
- PrivateInbox dropped (no such standard); private relays = NIP-37 kind
  10013 (NIP-44 encrypted), mapped to PrivateHome, owner-only.
- Search/Blocked/Favorite are NIP-51 kinds 10007/10006/10012 -- real
  user-published lists, so self-only event-derived, not local-only.
- Directory persistence: no (cold-resolve from indexers + TTL cache).

Refines the role taxonomy into per-target / self-only / local-only classes.

Refs #56, #77

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Resolve any user's event-derived relay roles (outbox / inbox / DM inbox) from
their published kind 10002 (NIP-65) and 10050 (NIP-17) events, cached with a
TTL. This is the per-target half of the role model -- routing needs to send
each operation to the right relays for the user it concerns.

- RelayDirectory: TTL cache keyed by pubkey, single-flight per pubkey, and
  short negative-caching so a flood of unknown pubkeys can't hammer the
  indexers and a user who later publishes a list is picked up soon.
- Client.ResolveRelays(pubkey) + a resolver that queries the index/seed relays
  for kinds 10002 and 10050 concurrently and maps them to roles.
- parseDMRelays (NIP-17 relay tags); appendUnique for write+both / read+both.
- core.Config gains relay_list_ttl / relay_list_neg_ttl (1h / 1m).
- Tests: tag parsing, dedup, TTL re-resolution, single-flight, negative TTL.

Dormant until routing is wired to it. Phase B (slice 1). Refs #56, #77

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…#56)

Wire the per-target directory into the real operations so the outbox model
finally takes effect:
- Reads (GetUserProfile with no explicit relays) route to the author's outbox
  relays (index fallback) instead of blasting every connected relay.
- Publishes go to the author's outbox PLUS every p-tagged recipient's inbox --
  a reply now reaches both my audience and the relays the person I am replying
  to actually reads. NIP-17 gift wraps (kind 1059) route to the recipient's DM
  inbox (10050). Previously a publish only hit the author's own write relays,
  so replies never reached their target.
- RouteFetch / RoutePublish use the cached directory (B1), keyed on the event's
  own author pubkey, so no session state is needed yet.

Tests cover reply routing, gift-wrap DM routing, and index fallback.

Phase B (slice 2). Refs #56, #77

Co-Authored-By: Claude Opus 4.8 <[email protected]>
The "only write to X / read from Y" session knob is not a routine narrowing of
routing -- it pins the client to fixed relays and turns the outbox model OFF
(replies stop reaching other users inboxes). Reframe it as an explicit,
default-off, discouraged opt-out. The default is always full outbox: when
interacting with another user, write to their relays in addition to your own
(already how B2 RoutePublish behaves).

Refs #56

Co-Authored-By: Claude Opus 4.8 <[email protected]>
The optional fixed-relay override pins every read to a chosen read set and
every write to a chosen write set, bypassing outbox routing. It turns the
outbox model OFF -- replies no longer reach other users inboxes -- so it is
default-off and intended only for users who explicitly want a fixed- or
single-relay client.

- Client.SetFixedRelays / ClearFixedRelays / FixedRelaysEnabled.
- RouteFetch / RoutePublish short-circuit to the pinned read/write set when
  enabled (an empty pinned set falls back to index relays, never to outbox).
- Off by default, so the outbox model (B2) is unchanged unless explicitly
  enabled.

Tests cover the override on/off and the default. Phase B (slice 3a). Refs #56

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…ut (#77)

After B2 routed queries to real per-user relays, Subscribe dialed them one at a
time (A2), so a slow or dead relay in someone outbox stalled the others behind
it. Now:
- Subscribe acquires its target relays concurrently (bounded by the pool dial
  semaphore), so N relays come up in ~one dial time, not the sum.
- Acquire uses a shorter per-dial OpenTimeout (4s, configurable) instead of the
  10s ConnectionTimeout, so a dead relay fails fast; dial backoff then skips it
  on subsequent queries.
- Close sends CLOSE to and releases only the relays actually acquired.

Meets #77 "one dead relay does not add more than its own per-relay timeout".
Test asserts concurrent dialing (4x150ms dials finish in ~150ms) and balanced
leases across Subscribe/Close.

Phase C (slice 1). Refs #77, #56

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Foundation for the dashboard "x / y relays" indicator: surface how many relays
the outbox pool is tracking versus how many are connected.

- RelayPool.Stats() / Client.PoolStats(): {total, connected, pinned, leased}.
- GetCoreClientStatus() now includes the pool counts.
- New GET /api/v1/client/status handler returns them as JSON.

Phase C (slice 2a). Refs #56

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Small header indicator showing how many relays the outbox pool has connected
out of the total it is tracking, polled every 10s from the new
/api/v1/client/status endpoint. Best-effort and guarded -- if the relay is
unreachable it keeps its last value and never breaks the header.

Frontend only; needs a Docker/dashboard smoke-test to confirm rendering.

Phase C (slice 2b). Refs #56

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…#56)

Fixes the brutal dashboard slowdown during a mutelist sync once the outbox pool
had grown to dozens of relays.

Root cause: GetUserRelays queried GetConnectedRelays() -- the ENTIRE pool -- so
every per-author relay-list lookup (the mutelist refresh does many in parallel)
fanned a REQ out to all ~80 connected relays. And B2 made GetUserProfile
synchronously resolve+fetch from each author outbox on every profile view.

Intended flow: indexer relays resolve mailboxes + profile; a user own mailboxes
serve their other data.

- GetUserRelays resolves through the directory (index-relay query, TTL-cached,
  single-flight), reconstructing the read/write/both split. Repeated authors
  become cache hits; fan-out drops from ~80 relays to 5.
- GetUserProfile uses RouteMetadata: index/profile-indexer relays plus the
  author outbox only when ALREADY cached -- never a blocking resolve.
- RelayDirectory.Cached() for non-blocking peeks; Lookup() stamps FetchedAt
  itself so a resolver cannot silently disable caching.

Refs #56, #77

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…known (#56)

The relay indicator denominator is now "known" relays -- everything the client
is aware of -- not just live connections. This matches how the outbox pool is
meant to feel: known climbs fast as the indexers seed mailbox lists, while
connections grow on demand.

- RelayDirectory.KnownRelays() + Client.PoolStats().Known = configured index
  relays + every resolved outbox/inbox/DM relay + pooled connections.
- WarmRelays(): GetUserProfile now background-resolves the viewed user's
  mailboxes into the directory (and thus "known") without blocking and without
  dialing -- resolution only queries the already-connected indexers, so it
  populates "known" but does not re-trigger the dial storm.
- /api/v1/client/status gains pool_known.
- Header indicator: connected/known with a relay icon, moved between the login
  button and the theme swapper; click toggles a pool breakdown panel. Full
  relay manager is a later slice.

Refs #56, #77

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…y URLs (#56)

So the known-relays set starts broad and stays clean.

Seeding: at startup, after the index relays connect, bulk-fetch recent relay-
list events (NIP-65 kind 10002 + NIP-17 kind 10050, limit 500) from the indexers
and fold every advertised relay into the directory. "Known" now starts in the
hundreds instead of climbing only as the user browses; connections still grow on
demand (seeding records relays, it does not dial them).
- FetchEvents: bulk multi-relay collector (dedupe by id, EOSE/limit/timeout).
- RelayDirectory.Store: merge-aware insert (10002 + 10050 for one author merge).
- SeedKnownRelays wired into InitializeCoreClient after index relays connect.

Normalization: every relay URL entering the directory or pool goes through
normalizeRelayURL -- repairs scheme (bare host->wss, http(s)->ws(s), "ws:/"
typo), lowercases host, strips trailing slash; ws/wss kept distinct. Applied in
parseMailboxEvent, parseDMRelays, Acquire, Connect, and the index seed list, so
near-duplicates (wss://X, wss://X/, X) collapse to one.

Refs #56, #77

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…d events (#90)

Backend for editing your own profile from the dashboard:
- core.AssembleProfileEvent: merge field edits over an existing kind-0,
  preserving every other content field AND tag; dual-write each edited field to
  both the content JSON (interoperable source of truth) and a [key,value] tag.
  Errors rather than clobbering if existing content is not valid JSON.
- POST /api/v1/user/profile/build: session-gated merge -> returns the UNSIGNED
  event for the browser to sign.
- POST /api/v1/events/publish: accepts a client-signed event (NIP-07/46/Amber),
  verifies pubkey==session + signature, routes via the outbox (RoutePublish),
  broadcasts, returns per-relay results. (The existing /api/v1/publish only
  signs server-side from a private key.)

Tests cover preservation of unknown fields/tags, tag upsert (replace not
duplicate), new-profile, and invalid-content rejection.

Frontend (edit UI + toast) follows. Refs #90, #56

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…90)

Frontend for in-place profile editing:
- Reveals an Edit button only when the logged-in user views their own profile
  (session pubkey == profile pubkey).
- Edit panel with the common kind-0 fields (name, display_name, about, picture,
  banner, nip05, lud16, website); lud06 added as a lightning-display fallback.
- Save sends ONLY changed fields to /api/v1/user/profile/build, signs the
  returned unsigned event with the user signer (window.grainSigner), and
  publishes via /api/v1/events/publish (outbox-routed). Unknown fields/tags are
  preserved server-side.
- A publish toast lists the relays it went to with per-relay send status.
  Per-relay NIP-20 OK accept/reject is the next slice.

Refs #90, #56, #77

Co-Authored-By: Claude Opus 4.8 <[email protected]>
… kind-0 (#90)

- Publish now collects each relay OK verdict: BroadcastResult gains
  Accepted/Reason, the message router routes OK by event id (resolving the
  long-standing TODO in the OK handler), and BroadcastEvent waits briefly for
  responses from the relays it sent to.
- The profile toast shows per-relay accepted / rejected+reason instead of just
  send status, and the save hydrates the UI in place once at least one relay
  accepts -- no page reload.
- AssembleProfileEvent strips any "client" tag (e.g. a stale client:amethyst
  from another client); kind-0 should not advertise the writing app by default.

Tests: OK routing, OK collection, client-tag strip.

Refs #90, #56

Co-Authored-By: Claude Opus 4.8 <[email protected]>
An Advanced mode (own profile) giving full control before signing:
- Content shown as individual key/value rows (every content key, incl. custom
  ones), editable / add / remove; re-serialized into the event content.
- Tags as rows you can edit, add an element to, remove, and drag to reorder via
  a grip handle (HTML5 drag-and-drop). Order is preserved as published.
- kind (0) and pubkey are read-only; created_at is stamped at sign time.
- Assembles the event directly (full control, no server merge), then reuses the
  shared sign -> outbox-publish -> OK toast -> in-place hydrate path the simple
  editor now also uses.

Refs #90, #56

Co-Authored-By: Claude Opus 4.8 <[email protected]>
- RoutePublish now sends metadata/relay-list kinds (0, 10002, 10050) to the
  index relays in addition to the author outbox -- a profile update has to reach
  the indexers where clients fetch it for arbitrary users, not just the author
  own write relays. (Test added; kind-1 notes unaffected.)
- Publish toast rebuilt: small, bottom-right, spinner while sending, then an
  "accepted by x/N relays" header; click to expand per-relay accept /
  reject+reason / send-failure detail; dismiss with the x. Shared by the simple
  and advanced editors.

Drag-reorder animation in the advanced editor is deferred to the profile
restyle.

Refs #90, #56

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Redesign per the approved mockup:
- Cleaner card layout: banner, centered rounded-square avatar, identity, about,
  a responsive contact grid, social links -- all on grain design tokens so it
  adapts across the 7 themes.
- In-place editing: clicking Edit swaps the display fields for inputs RIGHT
  WHERE THEY SIT (name, about, nip-05, lightning, website, plus picture/banner
  URL + display name) instead of a separate panel. A .pf-view / .pf-edit toggle
  drives it; the avatar gets a change-photo button; Save/Cancel replace Edit.
- Reuses the build -> sign -> outbox-publish -> OK toast -> in-place hydrate
  path, plus the Advanced editor and view-event-json.

The smooth drag-reorder animation in the Advanced editor is the next follow-up.

Refs #74, #90, #56

Co-Authored-By: Claude Opus 4.8 <[email protected]>
The restyle moved the `hidden` class onto #profile-banner-img directly and
dropped the old #profile-banner wrapper, but updateProfileImages still un-hid
the (now-nonexistent) wrapper -- so the banner loaded but stayed hidden behind
the gradient placeholder. Reveal the image element itself.

Refs #74

Co-Authored-By: Claude Opus 4.8 <[email protected]>
The Advanced panel renders below the profile card; opening it now scrolls it
into view so the inputs are visible immediately.

Refs #90

Co-Authored-By: Claude Opus 4.8 <[email protected]>
The toast was created AFTER the signer check, so a save that failed at the
signer step (no signer / not restored) threw before any toast appeared and the
user saw nothing. Create the toast up front and run the signer + publish inside
its try, surfacing any error (including "no signer") in the toast itself. Adds a
toast.status() for the in-progress states.

Refs #90

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Backend for the media-server upload feature: resolve a user's published
media-server lists and expose grain's quick-add suggestions.

- client/core/media.go: MediaServers type + MediaDirectory (TTL cache,
  single-flight), resolves kinds 10063/10096 from the index relays plus
  the user's cached outbox (these lists often live only on the user's own
  write relays, not the metadata indexers). server-tag parse + HTTP(S)
  URL normalize.
- client/core/media_servers.go: static capability table (cost / retention
  / BUD-04 mirror support) + curated suggestions, Blossom-preferred with
  NIP-96 as the legacy fallback. Includes the Happy Tavern offer.
- client/api: GET /api/v1/user/media-servers (resolved + annotated) and
  GET /api/v1/media-servers/suggested.

Native-tested (CGO_ENABLED=0); resolver + cache mirror the relay directory.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Settings -> Media servers: the user's own lists (or an empty state) on top,
grain's recommended quick-adds below in the same row style, add-by-URL, and a
collapsed legacy NIP-96 section that nudges toward Blossom. Save & sign rebuilds
each changed list and publishes it through the outbox.

Backend:
- core.AssembleMediaServerEvent rewrites the server tags in order while
  preserving content + every non-server tag (same "don't drop data" rule as the
  kind-0 editor); core.FetchMediaServerList pulls the latest list (indexers +
  cached outbox) so the rewrite keeps any extra tags.
- POST /api/v1/user/media-servers/build (session-gated, returns unsigned).
- PublishSignedHandler invalidates the media cache on a 10063/10096 publish so
  the new list shows immediately.
- Recommended order: blossom.band, 0x0.happytavern.co, blossom.happytavern.co,
  blossom.primal.net.

Frontend:
- www/static/js/nostr-publish.js: shared sign -> outbox-publish -> per-relay OK
  toast (profile editor, this section, and the coming uploader share one toast).
- www/static/js/media-servers.js: render your-list + recommended rows,
  add/remove/drag-reorder, Save & sign (build -> grainSigner -> publish).
- settings.html: the Media servers section + script includes.

Native-tested (CGO_ENABLED=0). Frontend pending Docker smoke-test.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…rride (#98)

Slice 1 backend for the full relay manager, reusing the media build flow:

- core.AssembleRelayListEvent builds an unsigned relay-list event for kind 10002
  (NIP-65 r-tags with read/write markers; both is unmarked) and kinds
  10050/10006/10007/10012 (relay tags), preserving content + non-relay tags.
- Client.FetchRelayList (indexers + cached outbox) + Client.InvalidateUserRelays.
- POST /api/v1/user/relay-list/build (session-gated, returns unsigned).
- POST /api/v1/client/fixed-relays sets/clears the outbox override (proxy) via
  SetFixedRelays / ClearFixedRelays.
- PublishSignedHandler invalidates the directory cache on a 10002/10050 publish.

Native-tested (CGO_ENABLED=0).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Settings -> Media servers polish from live testing:
- Separate "Save & sign" per list (Blossom 10063 / NIP-96 10096) instead of one
  grouped button, each with its own status line.
- Server names are hyperlinks (open the server in a new tab); the display still
  strips the scheme, and http:// / localhost are preserved as typed.
- Fix the inherited center + airy rows: left-align the section, compact 3-line
  rows (URL -> chips -> info), and an outlined neutral chip so "Permanent" reads
  as a tag alongside the filled Free/Paid/Ephemeral chips.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…no-cache (#83)

The publish toast now streams: each relay's result lands the moment it resolves
and the x/N pill counts up live, expandable to per-relay accept/reject/
no-response, and it persists until dismissed.

- core.broadcastEventStream + Client.PublishEventStream: a channel that emits
  each BroadcastResult as it resolves (send-fail now, accept/reject on the OK,
  no-response at the 5s deadline).
- POST /api/v1/events/publish/stream: NDJSON (start{relays} -> result per relay
  -> done), Flusher-based; same cache invalidation as the non-stream handler.
- nostr-publish.js: rewritten shared toast (incremental start/addResult/done,
  live count-up, expandable, persistent, stacked).
- server/startup.go: Cache-Control no-cache on /static, /style, /views so a
  rebuilt binary's assets load fresh without a manual hard-refresh.

Native-tested (CGO_ENABLED=0); verified live in Docker.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Migrate the profile editor onto grainPublish.signAndPublish so it gets the same
live count-up streaming toast as the media/relay flows, and load nostr-publish.js
on the profile page. Removes ~85 lines of duplicated toast (makePublishToast /
relayLine) from profile-page.js; hydrate-in-place on accept is preserved via the
onAccepted callback.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
0ceanSlim and others added 29 commits June 17, 2026 13:32
Add an optional Encrypter capability (NIP44Encrypt / NIP44Decrypt) a Signer may
also implement, mirroring the nip44 methods a NIP-07/NIP-46 browser signer
exposes — so the same calling code works whether the key lives in the browser or
in a downstream Go EventSigner. EventSigner implements it over the v2 primitives;
decrypt version-dispatches on the payload's version byte (v3 slots into the
switch later) and encrypt defaults to v2. Covered by a round-trip test:
self-encryption (NIP-51 lists), two-party, and wrong-key rejection.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…#100)

Implements the nostr-land NIP-44 v3 draft: per-message HKDF salted with
'nip44-v3\x00' || nonce (so ChaCha20 runs with a fixed zero nonce), a u32 length
prefix for binary plaintext, and the event kind + scope bound into the MAC's
authenticated data for cross-context replay protection. Validated against the
proposal's official test-vectors.json — padded lengths (176), PRK + both message
keys, encrypt/decrypt, long messages, decrypt-only, and the invalid-decryption
suite (incl. non-UTF-8 scope rejection).

Exposed as an optional EncrypterV3 capability on the Signer (EventSigner
implements it); the generic v2 decrypt path explicitly refuses v3 payloads since
it carries no kind/scope context. Encryption elsewhere still defaults to v2 for
interop — v3 is opt-in until it has deployed peers.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…101)

The pool now records each relay's NIP-42 AUTH challenge as it arrives in the
connection read loop (MessageRouter.authStates, session-scoped). A new challenge
resets that relay's authed flag so the manager re-prompts; an identical one
leaves it. Exposes the surface the relay manager + browser signer will use:
AuthRequests (relays that challenged us, with authed status), AuthChallenge,
SendAuth (relays a browser-signed kind-22242 event on the same connection and
marks the relay authed), and RemoveAuth (revoke for the session).

The send path reuses the existing RelayPool.SendMessage. API + UI slices next.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
GET /api/v1/client/auth-requests lists relays that have challenged us this
session (with authed status); POST /api/v1/client/auth relays a browser-signed
kind-22242 event to a relay; POST /api/v1/client/auth/remove revokes a relay's
session AUTH. The browser builds + signs the event (it holds the key); grain only
forwards it on the challenged connection, gating each route on the session. UI
slice next.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
The old stored-inert "Trusted" app-relay role becomes a live "Relays requesting
AUTH · NIP-42" list: relays that challenged grain this session, each with an
Authenticate button (signs a kind-22242 event with the browser signer, POSTs it
for grain to relay on the challenged connection) and a x to forget the session
trust. Answered relays show "trusted · session"; the list polls on the existing
5s timer since challenges arrive during normal pool activity.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…99/#80 slice 1)

grain now always strips any foreign `client` tag from events it builds (profile
kind-0, media lists 10063/10096, relay lists 10002/10050/10006/10007/10012) and,
when enabled, stamps its own ["client", name]. On by default.

- config: ClientConfig gains json tags (#80) + a ClientTag {enabled, name} block;
  ValidateAndApplyDefaults treats an unset block (empty name) as "on, grain".
- core: ApplyClientTag(event, enabled, name) — strip foreign, conditional add —
  unit-tested. AssembleProfileEvent no longer strips `client` itself; the build
  handler owns the policy so the library assembler stays policy-free.
- api: the three build handlers resolve the configured default vs a per-build
  override (client_tag in the build request — the user slider, slice 2) and apply.

Next: user settings slider (slice 2); admin Client section + grain_updateclient (slice 3).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Settings → "Client tag" toggle lets a logged-in user opt out of grain's
client:grain stamp on their own events. Persisted client-side (localStorage via
window.grainClientTag, default on) and sent as `client_tag` with each build
request — profile, relay-list, and media-server builds — overriding the server
default. grain still strips foreign client tags regardless.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…e 3)

Adds the dashboard "Client" section so the built-in Nostr client's settings are
editable: index_relays (ws_url list widget), connection/read/write timeouts, max
connections, retry attempts/delay, keep-alive, user agent — plus the #99
client-tag knobs (default on/off + name). Round-trips through a new
grain_updateclient NIP-86 method (config.UpdateClientConfig, same atomic-write
pipeline; validates index relays as ws/wss, fills a blank tag name with "grain").
Wired into the admin section list + the admin.html dispatch chain.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…80)

`swag init` was aborting on `@Success {object} nostr.Event`: server/types is
declared `package relay`, and swag resolves by real package name (not import
alias), so it couldn't find the type — leaving the committed spec stranded at its
May-20 state and breaking the release build's spec the same way. Use
`relay.Event` in the three build handlers' annotations.

Also register grain_updateclient in the NIP-86 supported_methods list + the
swagger @description (it was wired but unadvertised/undocumented), and regenerate
the spec. It now includes this cycle's endpoints: known-relays, relay-info,
relays/ping, app-relays, the NIP-42 auth routes, and the build endpoints'
client_tag field.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
/api/docs rendered blank in dev: the docs shell loads swagger-ui-dist from
/static/swagger/, but Dockerfile.dev never fetched those assets (only release.yml
+ tests/docker did) — so the UI's JS/CSS 404'd and nothing bootstrapped — and it
overwrote docs/openapi/swagger.json with an empty stub.

The dev image now pulls the pinned swagger-ui-dist (matching the release/test
images) and keeps a real spec when the build context carries one (`make generate`
output — gitignored but copied in), stubbing only when absent. Verified: the
three UI assets serve 200 and /api/docs/openapi.json is the full spec.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
The Swagger Authorize button was a NIP-98 apiKey field — asking you to paste a
base64 kind-27235 event by hand, which only authorizes one request (NIP-98 binds
the signature to the URL + payload, so a pasted token is stale immediately). The
one protected operation (POST / — the NIP-86 RPC) now signs on the fly:
preauthorizeApiKey arms the lock and a requestInterceptor mints the Authorization
header via window.grainSigner per request (same kind-27235 u/method/payload flow
as nip86-submit.js). No token to paste — Try it out uses your signer, like the
rest of the app. Session-cookie endpoints already ride the browser session.
Updated the NostrAuth description to match.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
With "Try it out" auto-signing via the connected signer, the manual NIP-98
Authorize modal is pointless — and swagger's default dialog isn't themed, so it
read badly against grain's dark palette. Hide the Authorize bar + auth dialog
(.scheme-container / .dialog-ux); the per-operation lock icons stay as inert
"needs owner auth" indicators. Also version-stamp the swagger CSS links so the
service worker serves the override fresh after this change.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
www/static/js/blossom-upload.js uploads a File to the user's chosen media server
— Blossom (BUD-01/02 kind-24242 PUT) or NIP-96 (kind-27235 NIP-98 POST) — signing
the auth event with window.grainSigner. Every upload pops a modal: pick the
primary server (default), a per-upload mirror-to-others checkbox, an
ephemeral-server warning, and a "no media server set up" prompt when the user has
none. XHR upload progress. Public API: grainUpload.pick(onUrl, opts) /
grainUpload.open(file, onUrl, opts). Wiring into profile + admin fields next.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Load blossom-upload.js globally and add declarative [data-upload-target] wiring to
the module — a button writes the uploaded URL into its target field (firing
input/change so the profile editor + admin pending-tracker pick it up).

Buttons added:
- Profile editor: Picture URL + Banner URL (image/*).
- Admin Ops: Icon + Banner (image/*), and Privacy/Terms/Posting policy URLs
  (pdf/md/txt). Click → pre-upload modal → URL fills the field → Save/Apply ships
  it via the existing changerelay* / build flows.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
The upload modal showed "no media server set up" when the resolve transiently
returned empty (a negative result that then sticks in cache until TTL), even
though the user has servers. The "no servers" prompt now offers a Re-check that
re-resolves with a cache-bust (GET /api/v1/user/media-servers?refresh=1 →
InvalidateMediaServers first), and distinguishes a failed fetch ("make sure
you're signed in") from a genuine empty list.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
… top + default-open

The old Operations section bundled live stats, the relay-identity fields, and
maintenance. Split the identity fields (name/description/icon/banner/contact/
policies + their upload buttons + the per-field changerelay* save) into a new
"Relay information" section. Operations now holds just live stats + cache
refresh, sits first in the section list with Relay information right under it,
and is expanded by default on page load.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…connect

- Section order: Operations, Relay information, Server, Rate limit, Resource
  limits, Whitelist, Blacklist, then the rest — via orderSections() so the
  definition block stays grouped by concern while display order is curated.
- Relay information icon 📛 → 🪪.
- Signer reconnect after claim: surface the 🔑 Reconnect signer pill proactively
  on load (after a silent restore attempt) instead of only after an action
  fails; reword the error to name the actual button; and re-pull Operations
  stats once the signer reconnects so the operator needn't hit ↻ themselves.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Root of the "no media servers" false negative: the resolve added the user's
outbox relays (where kind-10063/10096 are usually published) only when the relay
list was already cached — a non-blocking peek. On a cold or expired directory it
queried the index relays alone, missed the list, and cached a *negative*, so a
user who has servers saw "none set up" (Settings still showed a stale JS copy
from when it last resolved). Resolve the relay list blocking instead — in both
the resolve path and the build path (FetchMediaServerList) — so the outbox is
always queried.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
#83)

- Card padding p-5 → p-6 so the buttons/content aren't tight against the border.
- Mirror: the checkbox now reveals the *other* servers as individual checkboxes
  (default all on) with a Select all toggle, so you can mirror to a chosen subset
  instead of all-or-nothing. The list re-derives when the primary changes.
- Image uploads show a thumbnail preview above the filename + size.
Dockerfile.dev emitted the local Tailwind stylesheet as /style/tailwind.min.css
with no ?v= — but the service worker caches /style/*, so the dev container served
a STALE CSS across rebuilds. Any class added since the first cached fetch (the
upload modal's padding, the mirror list, the relay-manager card, etc.) was
missing until a manual hard-refresh. tests/docker + release.yml already
version-stamp this link; match them so each rebuild's assetVersion busts the SW
cache.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
… new users

A brand-new identity has no kind-0, no relay list, no media list:
- Profile page no longer errors when there's no profile — it shows an empty
  profile the owner can edit to create one (checkOwnProfile reveals Edit;
  AssembleProfileEvent already builds from nothing), and drops the owner straight
  into the editor when the content is empty. Only a real 404 triggers this; other
  fetch failures still error, so a transient failure can't lead to overwriting an
  existing profile.
- A persistent "set up your relays" header banner for a logged-in user with no
  NIP-65 list, linking to the relay settings. Hidden once a list exists / logged out.
…section (#83)

The no-media-server prompt's "Open settings" was a plain /settings link landing
at the top of the page. It now navigates (HTMX swap, mirroring openRelaySettings)
and scrolls to #media-servers-section via window.__grainScrollTarget, so the
operator lands exactly where they add a server.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…dable NIPs; auth says "Required"

Homepage feed (feed.js):
- Render the author's display name next to the avatar (short hex until the
  profile resolves); name + avatar both link to the profile.
- Sort newest-first deterministically by created_at — live events land at the
  top, lazy-loaded older events at the bottom, regardless of arrival order
  (the old prepend-on-arrival reversed the stored backfill).
- Keep the list short: backfill one small page (15) and lazy-load older events
  on scroll-to-bottom via a `{until,limit}` REQ; trim only while viewing the
  newest so reading older rows never yanks them away.

Relay dashboard (dashboard.js):
- "+N more" on the supported-NIPs list now expands the rest in place instead
  of being a dead label.
- Authentication status reads "Required / Not required" (it gates AUTH), not
  the misleading "Enabled / Disabled".

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…mpile-checked examples (#101)

Extend the client-library guide from the "essentials" surface to the full,
feature-complete 0.8.0 surface, and refresh the stale status callout (NIP-44
and NIP-42 both landed; the remaining publish-path ctx + optional seams are
1.0 polish, not new surface).

New sections, each with a usage example:
- Relay lists & mailboxes — FetchUserRelayLists, ParseNIP65Entries,
  AssembleRelayListEvent; the NIP-51/37 lists + encrypted-content pass-through.
- Media servers (Blossom + NIP-96) — ResolveMediaServers/HasAny, assemble +
  suggestions; note the signed upload stays client-side.
- Encryption (NIP-44) — EventSigner v2 (default) + v3 (draft) methods.
- AUTH (NIP-42) — AuthRequests/SendAuth/RemoveAuth; the session/"trusted" model.
- Known-relays browser — KnownRelays, FetchRelayInfo (NIP-11), Ping(s).
- Client tag (NIP-89) — ApplyClientTag.
- The HTTP API — endpoint groups + the OpenAPI spec as the authoritative ref.

Plus a contents/TOC and an expanded API-reference section.

example_test.go: add compile-checked examples for media, relay-list read/write,
NIP-44 encryption, NIP-42 AUTH, the known-relays browser, and the client tag,
so the new surface can't drift without breaking `go test ./...`.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
#101)

The read/fetch path already took a ctx (E4); the publish path carried only its
own internal deadlines. Add ctx as the leading parameter across the publish
surface, for API consistency and caller-set deadlines/cancellation:

- BroadcastEvent, broadcastEventStream / PublishEventStream,
  broadcastToSingleRelay, BroadcastToUserRelays, BroadcastWithRetry,
  PublishEvent (pkg + Client method), PublishEventWithRetry (pkg + Client method)
- UserContext.Publish / SignAndPublish / Reply

ctx is honoured where it matters: the NIP-20 OK-collection select
(collectOKResponses + the streaming variant), the retry back-off sleep, and the
per-relay connect timeout (bounded by the caller's deadline when sooner, via a
new effectiveTimeout helper).

Callers pass r.Context() (events + profile handlers); broadcast_test, the
compile-checked examples, and the client-library guide are updated to match —
the guide's status callout now states both read and publish paths take ctx.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
client/core logged exclusively through server/utils/log.ClientCore(), forcing
any consumer of the importable library into grain's logging. Introduce a Logger
seam:

- Logger interface = the four *slog.Logger methods (Debug/Info/Warn/Error), so
  the standard library logger and grain's own log.ClientCore() satisfy it as-is.
- Package-global injectable default (atomic.Value, race-free) initialized to
  log.ClientCore(), so behaviour is byte-for-byte unchanged until overridden.
- SetLogger(Logger) is the primary injection point (the library logs from many
  package-level functions with no Client in scope); Config.Logger is honoured at
  NewClient for config-driven setup.
- All 146 log.ClientCore() call sites routed through clog(); the now-unused log
  import dropped from those files (logger.go keeps it for the default).

Adds a compile-checked ExampleSetLogger and documents the seam in the guide.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
The relay directory cached per-user resolutions in a private in-memory map,
forcing that one strategy on every consumer. Introduce a RelayListStore seam:

- RelayListStore interface (Get / Set / Delete / Range over pubkey -> *UserRelays);
  the directory keeps the TTL + single-flight logic, so a store is just a KV map.
- Default in-memory implementation (memRelayListStore) — behaviour unchanged.
- Inject via Config.RelayListStore (e.g. a database for persistence across
  restarts or shared across instances).
- newRelayDirectory keeps its signature (6 test callers); a new
  newRelayDirectoryWithStore variant takes the store and is used by NewClient.

Adds a compile-checked example (doubling as a reference implementation) and
documents the seam in the guide; the status callout now lists all three
pluggable seams (Signer / Logger / RelayListStore) as in place.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…nAPI spec

swag (the OpenAPI generator run in the CI/release docker build) can't resolve a
struct with an anonymous embedded field, so `swag init` failed with "cannot find
type definition: core.KnownRelayStatus" and broke the test-image build. Inline
RelayLiveStatus's three fields into KnownRelayStatus — identical JSON, but a flat
struct swag can parse. The dev image stubs the spec instead of generating it,
which is why this only surfaced once CI ran swag on the branch.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…t chicken-and-egg)

`swag init` runs `go list` over the module, which fails on main.go's
`//go:embed docs/openapi/swagger.json` before swag can generate that very file.
With go list failing, swag can't load client/core and aborts on the first
referenced type — "cannot find type definition: core.KnownRelayStatus" — which
broke the CI test-image build (and would break release the same way). Seed a
minimal placeholder so go list succeeds; swag then overwrites it with the real
spec.

Applied everywhere swag runs: tests/docker/Dockerfile (CI), release.yml
(Unix + Windows), and the Makefile `generate` target. Verified by building the
test image locally — swag generates the spec and the binary builds.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@0ceanSlim 0ceanSlim merged commit 0617c92 into main Jun 18, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant