@@ -40,6 +40,8 @@ plugins: [{ id: 'forge', module: 'forge/plugin.js', prefix: '/forge',
4040 cspConnect: [], // extra connect-src origins (e.g. external Solid IdPs)
4141 chain: ' tbtc4' , // anchoring chain (testnet4); 'btc' is REFUSED …
4242 allowMainnet: false , // … unless this is explicitly true (see Finding 18)
43+ announceRelays: [], // NIP-34 relays (opt-in); empty => emission OFF
44+ announceKey: undefined , // optional 32-byte hex nostr privkey; else generated once
4345 } }]
4446```
4547
@@ -485,6 +487,148 @@ loudly, naming the stakes — unless `config.allowMainnet: true`; unknown
485487chains are refused too (no address is better than a wrong-network
486488address someone might fund).
487489
490+ ## Nostr discovery (NIP-34)
491+
492+ A repo lives on ** many** forges (mirrors); the Blocktrails mark (a
493+ Bitcoin tx) is the ** source of truth** ; [ NIP-34] ( https://github.com/nostr-protocol/nips/blob/master/34.md )
494+ Nostr events are the ** discovery + notification layer** . The Anchors
495+ work above records the source of truth; this layer makes it * findable*
496+ and ** syncable to a precise commit** . The forge publishes two correlated
497+ NIP-34 events, signed by the SAME stable per-instance key, to the
498+ operator-configured relays:
499+
500+ - a ** kind-30617 "repo announcement"** — the repo's name, description,
501+ clone/web ** mirror URLs** , relay set, maintainers, and (when anchored)
502+ its Bitcoin anchor. [ ngit] ( https://gitworkshop.dev/ ) (a kind-30617
503+ viewer) then shows the repo.
504+ - a ** kind-30618 "repo state"** — the repo's refs (`refs/heads/<branch > →
505+ <commit-sha >` + the symbolic ` HEAD`), so a subscriber can sync to an
506+ exact commit. This is what enables ** commit-precise sync** :
507+ [ nostr-git-sync] ( https://github.com/ ) reads the ` kind:30618 ` state and
508+ ` git checkout <commit> ` to the announced tip. Both events share the
509+ same ` d ` = ` <owner>/<name> ` , so a consumer correlates them.
510+
511+ ### The announce identity
512+
513+ A stable per-instance Nostr key. ` config.announceKey ` (32-byte hex
514+ privkey) wins if set; otherwise one is generated ** once** and persisted
515+ at ` pluginDir/announce-key ` (mode ` 0600 ` ), so the forge keeps one
516+ identity across restarts. Its ` npub ` -short is logged at boot. The
517+ announce ** pubkey** is the x-only 32-byte schnorr key (` event.pubkey ` ).
518+
519+ ### Opt-in, never always-on
520+
521+ ` config.announceRelays ` is an array of ` wss:// ` relay URLs. ** Empty or
522+ unset ⇒ emission is DISABLED** (a logged no-op) — relays are never
523+ hardcoded always-on; the operator opts in. A reasonable ngit-compatible
524+ set to opt into:
525+
526+ ``` js
527+ announceRelays: [
528+ ' wss://relay.damus.io' ,
529+ ' wss://nos.lol' ,
530+ ' wss://relay.nostr.band' ,
531+ ]
532+ ```
533+
534+ ### The kind-30617 event shape
535+
536+ ` buildRepoEvent(owner, name) ` returns a signed event (NIP-01: `id =
537+ sha256` of the canonical ` [ 0,pubkey,created_at,kind,tags,content] `
538+ serialization, ` sig = ` schnorr over the ` id ` ). Tags:
539+
540+ | tag | value | when |
541+ | -----| -------| ------|
542+ | ` d ` | ` <owner>/<name> ` | always (the NIP-34 repo id) |
543+ | ` name ` | ` <name> ` | always |
544+ | ` description ` | first line of the repo ` description ` /README | always (also the ` content ` ) |
545+ | ` web ` | ` <origin><prefix>/<owner>/<name> ` | always (browse mirror URL) |
546+ | ` clone ` | ` <origin><prefix>/<owner>/<name>.git ` | always (clone mirror URL) |
547+ | ` relays ` | the configured relay URLs | when ` announceRelays ` set |
548+ | ` maintainers ` | ` <owner> ` | nostr-owned repos (owner ** is** the hex pubkey) |
549+ | ` r ` | ` <origin><prefix>/<owner>/<name>/blocktrails.json ` | when anchored |
550+ | ` anchor ` | ` [<chain>, <genesis-txid>] ` | when anchored |
551+
552+ The last two tie the discovery event back to the Bitcoin source of
553+ truth: ` r ` hands a consumer the CORS-readable, verifier-compatible trail
554+ doc, and the custom ` anchor ` tag carries the ** chain + genesis txid** (a
555+ marked mark 0 — marks are a linear spend chain, so a marked genesis is
556+ the on-chain root). ` created_at ` is ` Math.floor(Date.now()/1000) `
557+ (normal plugin runtime).
558+
559+ ### The kind-30618 event shape (repo state)
560+
561+ ` buildStateEvent(owner, name) ` returns a signed ** kind-30618** event
562+ (same NIP-01 id/sig helper, same announce key). It is a * replaceable*
563+ event (NIP-33) keyed by the ** same ` d ` ** as the 30617, so a consumer
564+ correlates the announcement with its state. It carries the repo's refs,
565+ read straight from the bare repo (` symbolic-ref HEAD ` for the default
566+ branch, ` for-each-ref refs/heads ` for each head's full commit sha):
567+
568+ | tag | value | when |
569+ | -----| -------| ------|
570+ | ` d ` | ` <owner>/<name> ` | always (same repo id as the 30617) |
571+ | ` refs/heads/<branch> ` | ` <full-commit-sha> ` | one per local head (default branch first) |
572+ | ` HEAD ` | ` ref: refs/heads/<defaultBranch> ` | always (the symbolic default) |
573+ | ` r ` | ` <origin><prefix>/<owner>/<name>/blocktrails.json ` | when anchored |
574+ | ` anchor ` | ` [<chain>, <genesis-txid>] ` | when anchored |
575+
576+ ` content ` is ** empty** (per the state-event convention). Each
577+ ` refs/heads/<branch> ` tag hands a subscriber a ** precise commit** to
578+ check out (nostr-git-sync's ` kind:30618 ` → ` git checkout <commit> ` ).
579+ When the repo is anchored, the state event carries the SAME ` anchor `
580+ (chain + genesis txid) and ` r ` tags as the 30617 — so a subscriber can
581+ ** require Bitcoin-anchored state** (verify the txid on-chain) before
582+ pulling the announced commit.
583+
584+ A tip change is announced by either ** (a) anchoring** (a mark flipping to
585+ ` marked ` ) or ** (b) calling the announce endpoint** — there is no
586+ filesystem watcher (git over the forge is shadowed by core ` --git ` on the
587+ live box, so pushes may not flow through the forge; the reliable triggers
588+ are anchor + the manual endpoint).
589+
590+ ### Publishing + triggers
591+
592+ ` publishEvent(event) ` opens a ` ws ` WebSocket to each relay, sends
593+ ` ["EVENT", event] ` , and waits (bounded ~ 5 s per relay) for the relay's
594+ ` ["OK", <id>, true|false, …] ` , returning ` {relay, ok, error?}[] ` . A
595+ single relay failing never throws; the ` ws ` client is loaded lazily
596+ (only when a publish actually happens), so activation never depends on
597+ it and the no-relays path never touches it.
598+
599+ Every announce emits ** both** events (30617 then 30618) to the relays.
600+
601+ - ** Auto on anchor** : when a mark flips to ` marked ` (the ` .../txo `
602+ handler), the forge fire-and-forgets an announcement ** with** the
603+ anchor tags — the 30617 AND the 30618 (so the anchored state carries the
604+ Bitcoin txid) — the txo response never blocks on relays.
605+ - ** Manual** : ` POST <prefix>/api/repos/<o>/<n>/announce ` (owner-only,
606+ same auth as marks/labels) builds + publishes both and returns
607+ `{ published, event: {id, kind, tags} (30617), state: {id, kind, tags}
608+ (30618), relays: [ {relay, ok}] , stateRelays: [ {relay, ok}] }`. With no
609+ relays configured it is a clear ` 200 ` no-op (` published: false ` , both
610+ events still returned for inspection) so callers need no live relay.
611+ - ** Read-back** : ` GET <prefix>/api/repos/<o>/<n>/nostr ` returns the
612+ announce ` pubkey ` /` npub ` , the relay set, and the freshly built signed
613+ ` event ` (30617) ** and** ` state ` (30618) (also as `events: [ 30617,
614+ 30618] `) — inspect the exact shapes, including the commit-precise refs,
615+ without a relay.
616+
617+ ### Who signs — forge instance key, not the maintainer (a Finding)
618+
619+ The forge signs with its ** own instance key** , not the maintainer's
620+ nostr key. For a * mirror announcement* that is the honest claim — "this
621+ instance hosts these clone URLs, anchored to this tx" — and it needs no
622+ custody of the maintainer's key. The ` maintainers ` tag still names the
623+ maintainer's pubkey (for nostr-owned repos the owner ** is** that hex
624+ pubkey), so a canonical ** maintainer-signed** 30617 can supersede the
625+ forge's mirror announcement in any viewer that prefers the maintainer.
626+ This mirrors the anchoring custody posture (Finding 18): the
627+ forge-instance key is a POC-grade signer for a discovery claim, and the
628+ principled upgrade is the same one — the maintainer signs client-side
629+ (NIP-07/xlogin) and the forge only relays. Until then, forge-signed
630+ mirror announcements are a sound, clearly-scoped discovery layer.
631+
488632## Nostr agents (tier 2.5)
489633
490634### Identity model — hex canonical, npub display-only
@@ -1061,3 +1205,37 @@ the `authFetch` PUT is same-origin (`connect-src 'self'` already admits
10611205it). The one seam this still wants is the same ` api.podOf ` ask from
10621206Finding 10 — a did: nostr key with provisioned storage could take this
10631207exact client-write path and the hosted asymmetry would finally vanish.
1208+
1209+ ### 22. The forge is a NODE, not a home — storage + anchor + nostr discovery, three separable layers
1210+
1211+ Three tiers stack into a coherent role: the forge ** hosts** the bytes
1212+ (tier 1 git), ** anchors** their history to Bitcoin (tier 3.5 marks — the
1213+ source of truth), and now ** announces** them over Nostr (NIP-34 30617
1214+ discovery + 30618 repo-state — the latter carries the refs so a
1215+ subscriber syncs to a precise commit, anchored to the same Bitcoin txid).
1216+ The load-bearing idea is that these are * separable*
1217+ and a repo is not owned by any one forge: the same repo lives on many
1218+ mirrors, each a node that hosts a clone URL and points at the one shared
1219+ anchor. The ` clone ` /` web ` tags carry * this* node's mirror URLs; the
1220+ ` anchor ` tag carries the mirror-independent Bitcoin root; a viewer that
1221+ sees two forges announce the same ` d ` /` anchor ` learns they are mirrors of
1222+ one repo. So the forge is deliberately a replaceable node in a
1223+ mirror-set, not the repo's home — losing one node loses a mirror, not the
1224+ project.
1225+
1226+ ** Who signs the announcement** is the one real custody choice, and it is
1227+ the same shape as the anchoring one (Finding 18). The forge signs with a
1228+ ** forge-instance key** (persisted ` 0600 ` , POC custody), not the
1229+ maintainer's nostr key. For a * mirror announcement* that is the honest
1230+ claim and needs no maintainer-key custody: the event asserts "this node
1231+ hosts these URLs, anchored here," which is exactly what the node is
1232+ entitled to say. The ` maintainers ` tag still names the maintainer's
1233+ pubkey (for nostr-owned repos the owner ** is** that hex pubkey), leaving
1234+ room for a canonical ** maintainer-signed** 30617 to supersede the
1235+ mirror's in any viewer that prefers it. The principled upgrade is,
1236+ again, client-side signing (NIP-07/xlogin) with the forge as pure relay —
1237+ at which point the forge signs nothing and this custody note dissolves,
1238+ just as Finding 18's does for the trail key. Until then a forge-signed
1239+ mirror announcement is a sound, clearly-scoped discovery layer, and
1240+ keeping emission ** opt-in** (` config.announceRelays ` , empty ⇒ off) means
1241+ a node broadcasts to the wider network only when its operator says so.
0 commit comments