You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Add NIP-57 Lightning Zaps to nbread so readers can tip a blog's author in sats and so each long-form post can display an aggregate "⚡ N sats · M zaps" count — all while keeping blog pages JS-free (the test-enforced BLOG_CSP policy).
Because a zap is an interactive, JS-heavy dance (build a kind 9734 request → hit the author's LNURL-pay callback → get a bolt11 invoice → pay it in a wallet), and blog pages ship zero JavaScript by policy, this issue splits zaps into two independently shippable pieces:
v1 (this issue's core): a static, server-rendered ⚡ Zap link on every post (sourced from the author's lud16), plus server-side aggregation of kind 9735 zap receipts into D1 so counts render with no client JS.
v2 (follow-up): a first-party interactive zap page on the apex (JS allowed there) that does the full 9734 → LNURL → invoice flow with the existing NbreadSigner, so the blog's static link can hand off to us instead of a third-party client.
This is a feature request / design discussion, not an implementation.
Why
Long-form writers expect zaps. Lightning tipping is the native monetization primitive of the Nostr long-form ecosystem; a NIP-23 blog host that can't surface a zap affordance is conspicuously incomplete.
Counts are social proof. "⚡ 21,000 sats · 14 zaps" under a post is a discovery and engagement signal we can render for free — the cron already pulls from relays, so receipts are just another kind to ingest.
No new trust. v1 counting is read-only aggregation of publicly-published receipts; no custody, no invoicing, no funds ever touch nbread.
How NIP-57 works (the flow we must respect)
Author config lives in their kind 0 profile as lud16 (a user@domain lightning address) or lud06 (a bech32 LNURL). A client resolves lud16 to the LNURL-pay endpoint https://<domain>/.well-known/lnurlp/<user>.
That endpoint's JSON must advertise "allowsNostr": true and a "nostrPubkey" (the BIP-340 hex key its wallet signs receipts with), plus a "callback" URL and min/maxSendable.
A client builds an unsigned-then-signed kind 9734 zap request carrying tags: "relays" (where the receipt should be published), "amount" (millisats), "lnurl", "p" (recipient pubkey), and — for our addressable NIP-23 posts — an "a" tag30023:<author_pubkey>:<d-tag> (and/or "e" for a specific event id), optionally "k" = "30023".
The client GETs the callback with ?amount=<msat>&nostr=<uri-encoded 9734>&lnurl=<bech32>; the server returns {"pr":"lnbc…"} — a bolt11 invoice whose description hash commits to the 9734.
A wallet pays the invoice. The LNURL server then publishes a kind 9735 zap receipt to the 9734's "relays", signed by its nostrPubkey, with tags: "p" (recipient), optional "P" (sender), the original "e"/"a" references copied from the 9734, "bolt11", "preimage", and a "description" tag containing the full JSON of the 9734. content is empty; created_at matches the invoice paid time.
Receipt validation (NIP-57 appendix): the receipt's pubkey must equal the author's LNURL nostrPubkey; the "description" must parse to a 9734 whose p is the author and whose a/e references the post; the bolt11 amount must match the 9734 "amount". Anonymous zaps are allowed — the 9734 may be signed by an ephemeral key with an "anon" tag, so the sender P is not always meaningful.
Current state (real files)
Blogs are JS-free by policy — the central constraint.BLOG_CSP in src/middleware/headers.ts is default-src 'none'; img-src * data:; style-src 'self' 'unsafe-inline'; media-src *; base-uri 'none'; form-action 'none' — no script-src, so a page in this class cannot run a zap button. This class covers tenant subdomains and the apex /npub1… views (isNpubPath). test/integration/headers.spec.ts asserts blog markup contains no <script>. The post page is src/views/tenant/post.tsx (server-rendered, bodyHtml is ingest-rendered HTML).
The apex allows first-party JS.APEX_CSP grants script-src 'self' https://challenges.cloudflare.com plus connect-src 'self' wss: …. A new apex route (not an /npub1… path) could host the interactive zap UI. NbreadSigner (public/js/signer.js → getPublicKey/signEvent for any kind) and the editor's client-side relay/AUTH broadcast (public/js/editor.js) are the building blocks for that.
Profiles today carry no lud16.src/services/profiles.tsupsertProfile parses only name/display_name/picture/about/nip05 into the profiles table (pubkey, name, picture, about, nip05, raw, updated_at). There is no lud16 column and no dashboard field for it — src/routes/dashboard.ts edits only css/about/relays. The full kind-0 JSON does survive in profiles.raw (pickEventFields), so lud16 is recoverable without a migration, but nothing surfaces it. BlogProfile (src/views/tenant/layout.tsx) exposes only {name, picture, about} to views.
Cron ingests only [0,5,30023].src/cron/refresh.ts (collectBacklog, filter kinds:[0,5,30023]) mirrors per-user with a sync.since watermark, capped at REFRESH_VERIFY_CAP = 5 new verifications/user/run, reading from the user's configured relays merged ahead of RELAYS (self-relay filtered out via isSelfRelayHost). 9735 is not fetched.
The first-party relay won't hold receipts.ALLOWED_EVENT_KINDS = new Set([0,5,30023]) in src/relay/do.ts, and writes are NIP-42-gated to claimed handles. An external LNURL wallet is neither an allowed kind nor a claimed pubkey, so 9735 receipts must be read from external relays, never from our own relay.
Storage/dedup precedent.events table + mirrorEvent (src/services/mirror.ts) verify → replaceable-upsert → dedup by id; feeds project slim rows (src/services/events.ts). A zaps store should follow the same shape (verify once, dedup by receipt id, one bounded index).
Discover/feed rows are built by feedItems/FeedList (src/views/main/feed.tsx); FeedItem currently has no zap field.
Proposed integration
v1 — static zap link (blogs) + server-side counts
A. The zap affordance on JS-free blogs. Options considered:
(a-i) lightning: / LNURL deep-link <a> — <a href="lightning:<lud16-as-LNURL-or-bech32>">⚡ Tip</a>. Zero JS, walletless-friendly (mobile wallets register the lightning: scheme). Downside: it's a plain tip, not a Nostr-attributed zap — no 9734 is built, so no 9735 receipt references the post and it never increments our counts.
(a-ii) Hand-off to a Nostr client's zap UI — a plain <a> to https://njump.me/<nevent> (or a configured web client) where the reader's own client/extension performs the full 9734 dance. This produces a real, attributed zap whose 9735 references the post's a-coordinate, so it feeds our counts. Requires the reader to have a Nostr client. Recommended primary link for v1.
(b) Relax BLOG_CSP for a tiny first-party zap widget — rejected. It breaks the test-enforced JS-free contract and adds a script execution surface to the exact pages that render hostile relay-sourced content. Not worth it.
(c) Put interactive zapping only on an apex page, blogs show a static link — this is the v2 path below; the blog's static link points at our own apex zap page instead of njump.
Recommendation: v1 renders a server-side ⚡ Zap link that hands off to njump (a-ii) for an attributed zap, and MAY additionally expose the raw lightning: address (a-i) as a walletless "plain tip" fallback. Gate the whole affordance on the author actually having a lud16 (see dependency below). In v2 the same link is repointed to https://<MAIN_HOST>/zap?….
B. Server-side receipt counting (JS-free). Add a cron pass that fetches kind 9735 receipts referencing each claimed author's posts and aggregates them into D1:
Read source: the author's configured relays + a small set of well-known public relays (the "relays" tag on a 9734 is where receipts land, and popular wallets also fan out to big relays). Not the first-party relay (it can't hold 9735). Fetch with a filter like { kinds:[9735], "#p":[author_pubkey], since:<watermark> }, capped like the existing backlog logic.
Validation before counting (per NIP-57 appendix): parse the "description" 9734; confirm it targets this author's post via its "a" = 30023:<author>:<d> (or "e"); resolve the author's LNURL nostrPubkey once (cache it from lud16) and require receipt.pubkey === nostrPubkey; take the amount from the bolt11 invoice; dedup by receipt id.
Storage: a new zaps table (receipt_id PK, target_address, target_event_id, author_pubkey, sender_pubkey, amount_msat, created_at) plus a cheap materialized rollup per post (a zap_msat_total / zap_count column on events, or a zap_totals(address) table) so the render path is a single indexed read — mirroring the posts_fts/rollup discipline already in mirror.ts.
Render: the post view (post.tsx) and FeedItem/FeedList gain a ⚡ N sats · M zaps line, populated from the rollup — pure server-side, no CSP change.
A new apex-class route (e.g. GET /zap?a=30023:<pubkey>:<d> — deliberately not an /npub1… path, so it gets APEX_CSP) ships first-party JS that:
resolves the author's lud16 → LNURL-pay endpoint, checks allowsNostr/nostrPubkey;
builds a kind 9734 with the post's "a" tag, "amount", "relays", "p", "lnurl", signs it with NbreadSigner (or offers an anonymous/ephemeral-key zap);
GETs the callback, shows the returned bolt11 (QR + copy, and/or webln if present);
relies on the LNURL server to publish the 9735, which our cron then counts.
Then the blog's static ⚡ Zap link is repointed from njump to https://<MAIN_HOST>/zap?…, keeping the whole experience first-party while the blog page itself stays JS-free.
Scope
v1 (ship first)
Surface the author's lud16 (parse from profiles.raw, or add a lud16 column + parse in upsertProfile) and expose it on BlogProfile.
Server-render a static ⚡ Zap link on post.tsx (hand-off to njump; optional lightning: fallback), gated on lud16 being present.
New cron pass: fetch kind 9735 (#p = author) from user relays + a public-relay set, watermarked and verification-capped like the existing backlog.
Validate receipts (nostrPubkey match, description-9734 targets this post's a/e, bolt11 amount), dedup by receipt id.
Repoint the blog's static link from njump to the first-party /zap page.
Open questions
Which public relays to add to the 9735 read set, and how to bound the extra cron cost (a second relay round-trip + up to N verifications per user per run against the free-tier rows_read/CPU/subrequest budget)? Reuse the REFRESH_VERIFY_CAP discipline?
Rollup shape: columns on events vs a separate zap_totals table — which keeps the discover JOIN cheap without widening the hot events row?
Amount source of truth: parse msat from the bolt11 invoice (authoritative) vs trust the 9734 "amount" tag — do we need a bolt11 decoder in the Worker, and is that within the CPU budget?
Anonymous zaps: accept them toward counts (recommended — they're valid receipts) even though sender P is absent/ephemeral?
Abuse: a malicious LNURL server could publish inflated 9735s for its own nostrPubkey; since we bind counting to the author's resolvednostrPubkey, is that sufficient, or do we also cap per-receipt/aggregate amounts?
lud16 trust: it's untrusted relay content (like nip05) — length-cap and shape-validate before building any LNURL URL; never fetch an attacker-controlled host without an allowlist/SSRF guard on the cron side.
Do we count zaps on the free lightning: tip path at all? (No receipt is produced, so no — worth stating in the UI.)
Related / dependencies
Depends on the sibling "configurable kind 0 profile" issue. Today nbread can't edit the Nostr profile at all (dashboard.ts handles only css/about/relays), so an author has no in-nbread way to set lud16, and upsertProfile doesn't even parse it. Zaps need lud16 both stored and surfaced; that profile issue should add the field, and this issue consumes it.
Cron sync (src/cron/refresh.ts) — the ingestion point to extend with a 9735 pass; reuses the watermark + verification-cap machinery.
NIP-05 (src/routes/wellknown.ts) — precedent for an untrusted profile-derived identifier handled carefully; lud16 deserves the same shape/length discipline.
Summary
Add NIP-57 Lightning Zaps to nbread so readers can tip a blog's author in sats and so each long-form post can display an aggregate "⚡ N sats · M zaps" count — all while keeping blog pages JS-free (the test-enforced
BLOG_CSPpolicy).Because a zap is an interactive, JS-heavy dance (build a
kind 9734request → hit the author's LNURL-pay callback → get abolt11invoice → pay it in a wallet), and blog pages ship zero JavaScript by policy, this issue splits zaps into two independently shippable pieces:⚡ Zaplink on every post (sourced from the author'slud16), plus server-side aggregation ofkind 9735zap receipts into D1 so counts render with no client JS.NbreadSigner, so the blog's static link can hand off to us instead of a third-party client.This is a feature request / design discussion, not an implementation.
Why
NbreadSigner(PR Multi-signer support: NIP-46 (Amber/bunker), NIP-55 Android intents, nsec paste #7) already signs arbitrary kinds client-side, so the 9734 signing needed for v2 is nearly free; the first-party relay (PR First-party Nostr relay (write-restricted) for nbread.lol #8) and cron give us a receipt-read path.How NIP-57 works (the flow we must respect)
kind 0profile aslud16(auser@domainlightning address) orlud06(a bech32 LNURL). A client resolveslud16to the LNURL-pay endpointhttps://<domain>/.well-known/lnurlp/<user>."allowsNostr": trueand a"nostrPubkey"(the BIP-340 hex key its wallet signs receipts with), plus a"callback"URL andmin/maxSendable.kind 9734zap request carrying tags:"relays"(where the receipt should be published),"amount"(millisats),"lnurl","p"(recipient pubkey), and — for our addressable NIP-23 posts — an"a"tag30023:<author_pubkey>:<d-tag>(and/or"e"for a specific event id), optionally"k"="30023".?amount=<msat>&nostr=<uri-encoded 9734>&lnurl=<bech32>; the server returns{"pr":"lnbc…"}— abolt11invoice whose description hash commits to the 9734.kind 9735zap receipt to the 9734's"relays", signed by itsnostrPubkey, with tags:"p"(recipient), optional"P"(sender), the original"e"/"a"references copied from the 9734,"bolt11","preimage", and a"description"tag containing the full JSON of the 9734.contentis empty;created_atmatches the invoice paid time.pubkeymust equal the author's LNURLnostrPubkey; the"description"must parse to a 9734 whosepis the author and whosea/ereferences the post; thebolt11amount must match the 9734"amount". Anonymous zaps are allowed — the 9734 may be signed by an ephemeral key with an"anon"tag, so the senderPis not always meaningful.Current state (real files)
BLOG_CSPinsrc/middleware/headers.tsisdefault-src 'none'; img-src * data:; style-src 'self' 'unsafe-inline'; media-src *; base-uri 'none'; form-action 'none'— noscript-src, so a page in this class cannot run a zap button. This class covers tenant subdomains and the apex/npub1…views (isNpubPath).test/integration/headers.spec.tsasserts blog markup contains no<script>. The post page issrc/views/tenant/post.tsx(server-rendered,bodyHtmlis ingest-rendered HTML).APEX_CSPgrantsscript-src 'self' https://challenges.cloudflare.complusconnect-src 'self' wss: …. A new apex route (not an/npub1…path) could host the interactive zap UI.NbreadSigner(public/js/signer.js→getPublicKey/signEventfor any kind) and the editor's client-side relay/AUTH broadcast (public/js/editor.js) are the building blocks for that.lud16.src/services/profiles.tsupsertProfileparses onlyname/display_name/picture/about/nip05into theprofilestable (pubkey, name, picture, about, nip05, raw, updated_at). There is nolud16column and no dashboard field for it —src/routes/dashboard.tsedits onlycss/about/relays. The full kind-0 JSON does survive inprofiles.raw(pickEventFields), solud16is recoverable without a migration, but nothing surfaces it.BlogProfile(src/views/tenant/layout.tsx) exposes only{name, picture, about}to views.[0,5,30023].src/cron/refresh.ts(collectBacklog, filterkinds:[0,5,30023]) mirrors per-user with async.sincewatermark, capped atREFRESH_VERIFY_CAP = 5new verifications/user/run, reading from the user's configured relays merged ahead ofRELAYS(self-relay filtered out viaisSelfRelayHost). 9735 is not fetched.ALLOWED_EVENT_KINDS = new Set([0,5,30023])insrc/relay/do.ts, and writes are NIP-42-gated to claimed handles. An external LNURL wallet is neither an allowed kind nor a claimed pubkey, so 9735 receipts must be read from external relays, never from our own relay.eventstable +mirrorEvent(src/services/mirror.ts) verify → replaceable-upsert → dedup by id; feeds project slim rows (src/services/events.ts). A zaps store should follow the same shape (verify once, dedup by receipt id, one bounded index).feedItems/FeedList(src/views/main/feed.tsx);FeedItemcurrently has no zap field.Proposed integration
v1 — static zap link (blogs) + server-side counts
A. The zap affordance on JS-free blogs. Options considered:
lightning:/ LNURL deep-link<a>—<a href="lightning:<lud16-as-LNURL-or-bech32>">⚡ Tip</a>. Zero JS, walletless-friendly (mobile wallets register thelightning:scheme). Downside: it's a plain tip, not a Nostr-attributed zap — no 9734 is built, so no 9735 receipt references the post and it never increments our counts.<a>tohttps://njump.me/<nevent>(or a configured web client) where the reader's own client/extension performs the full 9734 dance. This produces a real, attributed zap whose 9735 references the post'sa-coordinate, so it feeds our counts. Requires the reader to have a Nostr client. Recommended primary link for v1.BLOG_CSPfor a tiny first-party zap widget — rejected. It breaks the test-enforced JS-free contract and adds a script execution surface to the exact pages that render hostile relay-sourced content. Not worth it.Recommendation: v1 renders a server-side
⚡ Zaplink that hands off to njump (a-ii) for an attributed zap, and MAY additionally expose the rawlightning:address (a-i) as a walletless "plain tip" fallback. Gate the whole affordance on the author actually having alud16(see dependency below). In v2 the same link is repointed tohttps://<MAIN_HOST>/zap?….B. Server-side receipt counting (JS-free). Add a cron pass that fetches
kind 9735receipts referencing each claimed author's posts and aggregates them into D1:"relays"tag on a 9734 is where receipts land, and popular wallets also fan out to big relays). Not the first-party relay (it can't hold 9735). Fetch with a filter like{ kinds:[9735], "#p":[author_pubkey], since:<watermark> }, capped like the existing backlog logic."description"9734; confirm it targets this author's post via its"a"=30023:<author>:<d>(or"e"); resolve the author's LNURLnostrPubkeyonce (cache it fromlud16) and requirereceipt.pubkey === nostrPubkey; take the amount from thebolt11invoice; dedup by receipt id.zapstable (receipt_id PK, target_address, target_event_id, author_pubkey, sender_pubkey, amount_msat, created_at) plus a cheap materialized rollup per post (azap_msat_total/zap_countcolumn onevents, or azap_totals(address)table) so the render path is a single indexed read — mirroring theposts_fts/rollup discipline already inmirror.ts.post.tsx) andFeedItem/FeedListgain a⚡ N sats · M zapsline, populated from the rollup — pure server-side, no CSP change.v2 — first-party interactive zap page (apex, JS allowed)
A new apex-class route (e.g.
GET /zap?a=30023:<pubkey>:<d>— deliberately not an/npub1…path, so it getsAPEX_CSP) ships first-party JS that:lud16→ LNURL-pay endpoint, checksallowsNostr/nostrPubkey;kind 9734with the post's"a"tag,"amount","relays","p","lnurl", signs it withNbreadSigner(or offers an anonymous/ephemeral-key zap);bolt11(QR + copy, and/orweblnif present);Then the blog's static
⚡ Zaplink is repointed from njump tohttps://<MAIN_HOST>/zap?…, keeping the whole experience first-party while the blog page itself stays JS-free.Scope
v1 (ship first)
lud16(parse fromprofiles.raw, or add alud16column + parse inupsertProfile) and expose it onBlogProfile.⚡ Zaplink onpost.tsx(hand-off to njump; optionallightning:fallback), gated onlud16being present.kind 9735(#p= author) from user relays + a public-relay set, watermarked and verification-capped like the existing backlog.a/e, bolt11 amount), dedup by receipt id.zapstable + per-post rollup (zap_msat_total,zap_count); D1 migration + index.⚡ N sats · M zapson the post page and on discover/search (FeedItem/FeedList).v2 (follow-up)
/zaproute (not an/npub1…path) with first-party JS.NbreadSignersigning + LNURL callback +bolt11/QR/webln display; optional anonymous zap./zappage.Open questions
rows_read/CPU/subrequest budget)? Reuse theREFRESH_VERIFY_CAPdiscipline?eventsvs a separatezap_totalstable — which keeps the discover JOIN cheap without widening the hoteventsrow?bolt11invoice (authoritative) vs trust the 9734"amount"tag — do we need a bolt11 decoder in the Worker, and is that within the CPU budget?Pis absent/ephemeral?nostrPubkey; since we bind counting to the author's resolvednostrPubkey, is that sufficient, or do we also cap per-receipt/aggregate amounts?lud16trust: it's untrusted relay content (likenip05) — length-cap and shape-validate before building any LNURL URL; never fetch an attacker-controlled host without an allowlist/SSRF guard on the cron side.lightning:tip path at all? (No receipt is produced, so no — worth stating in the UI.)Related / dependencies
kind 0profile" issue. Today nbread can't edit the Nostr profile at all (dashboard.tshandles onlycss/about/relays), so an author has no in-nbread way to setlud16, andupsertProfiledoesn't even parse it. Zaps needlud16both stored and surfaced; that profile issue should add the field, and this issue consumes it.NbreadSigner(PR Multi-signer support: NIP-46 (Amber/bunker), NIP-55 Android intents, nsec paste #7,public/js/signer.js) — makes v2's 9734 signing trivial (any kind, four backends).src/relay/do.ts) — explicitly cannot host 9735 (kind allowlist + NIP-42 claimed-handle writes); receipts are read from external relays only.src/cron/refresh.ts) — the ingestion point to extend with a 9735 pass; reuses the watermark + verification-cap machinery.src/routes/wellknown.ts) — precedent for an untrusted profile-derived identifier handled carefully;lud16deserves the same shape/length discipline.