Skip to content

First-party Nostr relay (write-restricted) for nbread.lol - #8

Merged
sovITxyz merged 3 commits into
mainfrom
feat/first-party-relay
Jul 17, 2026
Merged

First-party Nostr relay (write-restricted) for nbread.lol#8
sovITxyz merged 3 commits into
mainfrom
feat/first-party-relay

Conversation

@sovITxyz

Copy link
Copy Markdown
Owner

Closes #5.

Stacked on #7 (base is feat/signer-expansion, not main) — the editor's NIP-42 AUTH client ships in #7. Merge #7 first, then rebase this onto main. GitHub will retarget this PR to main automatically when #7 merges.

What

nbread now runs its own Nostr relay at wss://nbread.lol/relay so claimed blogs' kind 30023 events have a durable, nbread-controlled home instead of depending entirely on third-party relays.

  • Reads open to any Nostr client (habla.news, nak, …).
  • Writes restricted to claimed nbread handles — Model 1 from the issue: NIP-42 AUTH + the D1 users allowlist (handle IS NOT NULL AND blocked = 0) + event.pubkey === authed pubkey. Accepted kinds: 30023, 5, 0 (own events only).
  • NIPs: 01 (REQ/EVENT/CLOSE/EOSE), 09 (deletes), 11 (relay info), 42 (AUTH).

How (design decisions)

  • Durable Object, single hibernatable instance (idFromName("relay:v1")), registered on the outer Hono app before securityHeaders — a 101 upgrade is an immutable response (headers.set() would throw), and guard/tenant/csrf never run per-upgrade. Free-plan requirement met via new_sqlite_classes. No timers/alarms (they'd defeat hibernation → duration billing); per-connection auth+challenge state lives in the socket attachment, ephemeral subscriptions in DO SQLite.
  • Storage reuses the D1 events table — writes go through the existing mirrorEvent (replaceable upsert, NIP-09 delete horizons, render-at-ingest, FTS, cache-gen bump), reads translate NIP-01 filters to SQL over events.raw. The relay and the blog can never disagree, and "cron reads its own relay first" becomes a no-op by construction.
  • Write ladder: auth-required → pubkey-match → allowlist (5-min per-connection cache) → created_at upper bound → rate limit (D1, fail-closed: relay:ev:pk 30/5min + relay:global:store 500/day) → mirrorEvent → tombstone-guarded live fan-out.
  • Integrations: editor broadcasts to the relay first; NIP-05 well-known advertises it as a relay hint; cron filters the self-relay out of its outbound dials.
  • Migration 0005: idx_events_author_time (pubkey, created_at DESC) for the dominant authors+kinds query, and reserves the relay handle.

Budget (free plan)

DO requests count only ws upgrades + messages/20 against 100k/day; hibernating objects accrue no duration charge; the relay:global:store cap bounds relay-driven KV gen-bumps to half the 1k/day budget. If DO budget is exhausted the relay 503s but the blog/editor//api/mirror are unaffected — the relay is additive, never load-bearing.

Security

Adversarially reviewed in three lenses (protocol/DO invariants, write-gate attack surface, budget/integration). Fixes applied: live fan-out re-checks the deleted flag so tombstones are never pushed; #d live-match aligned to the slotted-d-tag SQL semantics; created_at upper bound now enforced to match the NIP-11 advertisement; relay 404/429 responses carry nosniff/Referrer-Policy. Confirmed: securityHeaders/guard/tenant still wrap every non-relay route; headers.spec.ts passes unchanged.

Known v1 limitations (documented, low impact)

  • Generic tag filters (#t/#e) are JS-post-filtered after the SQL LIMIT, so such a REQ can under-deliver beyond the limit window (rare for long-form; #d/authors/kinds/ids are fully SQL).
  • A REQ registers its live subscription after EOSE, so an event delivered during the initial query's async window can be missed by that sub (standard relay race; low write volume).
  • /api/mirror and cron stores don't live-fan-out (only relay-received EVENTs do); the editor broadcasts to the relay by default, closing most of the gap.

Testing

  • 840/840 tests (44 files); wrangler deploy --dry-run resolves the RELAY_DO binding; vendor-bundle drift clean.
  • New: relay protocol/filters/query units, DO-core unit, end-to-end ws integration (AUTH → allowlisted write → REQ/EOSE → live fan-out → kind-5 delete → NIP-11).
  • scripts/smoke.sh asserts the NIP-11 document; docs/manual-signer-tests.md gains a relay section (nak readback, claimed-vs-unclaimed write, external-client read). Deploy is user-gated — needs wrangler d1 migrations apply nbread --remote for 0005.

sovITxyz added 3 commits July 16, 2026 18:25
wss://nbread.lol/relay — reads open to any client, writes restricted to
claimed nbread handles via NIP-42 AUTH + the D1 users allowlist. Single
hibernatable DO (no timers) registered ahead of securityHeaders so the 101
upgrade stays immutable; per-connection auth/challenge state in the socket
attachment, ephemeral subscriptions in DO SQLite. Events flow through the
existing mirrorEvent into the shared D1 events table (relay and blog never
disagree); REQ reads translate to SQL over events.raw. Write ladder:
auth-required -> pubkey match -> allowlist (5-min cache) -> created_at
upper bound -> rate limit (fail-closed) -> mirror -> tombstone-guarded
live fan-out. NIP-11 served by the Worker; accepted kinds 30023/5/0.
editorRelays prepends wss://nbread.lol/relay so every publish reaches it
first; NIP-05 well-known adds it to each pubkey's relay hints; cron
filters the self-relay out of its outbound dials (same-zone ws subrequest
won't re-enter the Worker, and the store is shared). Docs page documents
the relay and its write policy.
…ests

smoke.sh asserts the NIP-11 document (supported_nips incl. 42,
restricted_writes) and the info page; ops.md adds the /relay endpoint
rows, the DO budget row (free-plan 100k req/day, hibernation = no duration
billing, relay is additive), and the WAF ws-upgrade note; manual checklist
gains a first-party relay section (nak readback, claimed vs unclaimed
write, external-client read).
@sovITxyz
sovITxyz changed the base branch from feat/signer-expansion to main July 17, 2026 02:51
@sovITxyz
sovITxyz merged commit 54a9923 into main Jul 17, 2026
1 check passed
@sovITxyz
sovITxyz deleted the feat/first-party-relay branch July 17, 2026 02:51
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.

Run a first-party Nostr relay for nbread.lol (write-restricted)

1 participant