Skip to content

Commit 6db3f32

Browse files
forge: NIP-34 Nostr emission — repo announcements (30617) + repo state (30618)
The forge becomes a discoverable node in a decentralized git network: it publishes NIP-34 kind-30617 (repo announcement: name, clone/web URLs, maintainers) and kind-30618 (repo state: refs/heads/<branch> -> commit) events to configured relays (config.announceRelays; empty = off). Signed by a per-instance announce key (persisted 0600, or config.announceKey). Anchored repos carry ['r', <blocktrails.json>] + ['anchor', <chain>, <genesis-txid>] on both events, so a subscriber can require Bitcoin- anchored state before syncing. Emitted on anchor (auto) and via the owner-only POST <prefix>/api/repos/<o>/<n>/announce endpoint; both events also readable at GET .../nostr for inspection without a relay. This is the discovery/notification layer that lets nostr-git-sync (or any NIP-34 consumer, e.g. ngit) react to forge updates — the trigger bus for the decentralized push -> CI -> deploy loop documented in AGENT.md. Forge signs with its instance key (honest mirror-announcement); the maintainers tag names the real key so a maintainer-signed 30617 can supersede it. 115 tests.
1 parent b0f31d3 commit 6db3f32

3 files changed

Lines changed: 731 additions & 2 deletions

File tree

forge/README.md

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
485487
chains are refused too (no address is better than a wrong-network
486488
address 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
10611205
it). The one seam this still wants is the same `api.podOf` ask from
10621206
Finding 10 — a did:nostr key with provisioned storage could take this
10631207
exact 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

Comments
 (0)