OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge commits / 92a430b

observation feeds: the gateway's own identity + one feed per user (I1 S1, hyper-social #82)
What the fediverse does to a registered user's records goes into a feed the GATEWAY founds under its own favela identity (minted once into observer/, vault + credential 0600), one per user per generation (namespace obs:<user>:<gen>), key persisted. User nodes hold it read-only and check its founder is the observer root. Observations are content-keyed; replies carry their text; an Undo tombstones. Not wired yet (S2). Unversioned: nothing ships until S2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
92a430b55f5511fde1ea63a1867c6a1b97841622
by plankatron <fleeky@gmail.com> · 2026-10-14T18:51:07.000Z

Changed files (2) +311 -0

lib/observation-feeds.js +186 -0
……@@ -0,0 +1,186 @@
1// observation-feeds.js — what the fediverse did to a registered user's records, written into a feed the GATEWAY
2// owns and the user's nodes HOLD (hyper-social #82, I1, decision D2).
3//
4// ⛔ AN OBSERVATION IS NOT AN EDGE (OBSERVED-INTERACTIONS §2). A like from a Mastodon account is not ours to
5// author, and the user never authored it either — so it is not written into the user's feed. It goes into a
6// feed the gateway founded under ITS OWN identity, one per user, which the user's nodes hold read-only like
7// any other feed. Delivery is replication: an append reaches every node holding the feed, with no polling and
8// no pickup verb — this replaces the drained buffer + `collect` design (OBSERVED §9) by operator decision D2.
9//
10// ⛔ THE GATEWAY HOLDS A FAVELA IDENTITY (operator decision O-1). Minted ONCE into `<storage>/observer/`: the
11// vault (the recovery root) and the device credential, both 0600, like the AP actor keys beside them. It is
12// never re-minted — a new root would make every node's pinned observer root wrong at once.
13//
14// ⚠ ONE CORESTORE NAMESPACE PER FEED, and never one a held feed uses. Two Autobees on one namespace derive the
15// same local writer and DEADLOCK (favela-peer node.js). Held feeds are `feed:<key>`; these are
16// `obs:<username>:<generation>`, so a released and re-claimed name gets a NEW feed and never inherits its
17// predecessor's observations (the generation rule registrations.js already applies to actor ids).
18//
19// ⛔ PUBLIC MATERIAL ONLY. A feed replicates in the clear to anyone holding its key. Whatever is written here is
20// readable by them; DMs and followers-only replies are kept OUT by the caller until an encrypted channel exists
21// (operator decision, #82 D2).
22
23import fs from 'bare-fs'
24import { join } from 'bare-path'
25import crypto from 'bare-crypto'
26import b4a from 'b4a'
27import { Feed } from 'favela-peer/lib/feed-base.js'
28import { RecordStore } from 'favela-peer/lib/record-store.js'
29import { loadCredential, saveCredential, bindWriter } from 'favela-peer/lib/device.js'
30import { mintIdentity } from 'favela-peer/lib/mint.js'
31import { readProof } from 'favela-peer/lib/attestation.js'
32
33export const COLLECTION = 'observation'
34export const KINDS = ['like', 'repost', 'follow', 'reply', 'mention']
35const MAX_BODY = 4000
36
37// The record name for an activity: a content key, so a remote server re-delivering the same activity lands on
38// the same record instead of a second one, and an Undo can find what it undoes from the activity id alone.
39//
40// ⚠ WHICH id the caller passes as `activity` is the identity of the observation. Like / Announce / Follow: the
41// ACTIVITY id (the Undo names it). Reply / mention: the OBJECT id (the Note) — an Update arrives with a new
42// activity id for the same object and must overwrite, and a Delete names only the object.
43export function observationName (activityId) {
44 return crypto.createHash('sha256').update(String(activityId)).digest('hex').slice(0, 32)
45}
46
47export class ObservationFeeds {
48 constructor ({ corestore, storage, overlay = null, log = () => {} } = {}) {
49 if (!corestore) throw new Error('ObservationFeeds: a corestore is required')
50 if (!storage) throw new Error('ObservationFeeds: a storage directory is required')
51 this.corestore = corestore
52 this.storage = storage
53 this.overlay = overlay
54 this._log = log
55 this._dir = join(storage, 'observer')
56 this._mapPath = join(this._dir, 'feeds.json')
57 this._feeds = new Map() // "<username>:<generation>" -> { key, feed }
58 this._opening = new Map()
59 this.cred = null
60 this.root = null
61 }
62
63 async ready () {
64 fs.mkdirSync(this._dir, { recursive: true })
65 let cred = loadCredential(this._dir)
66 if (!cred) {
67 const vaultPath = join(this._dir, 'vault.json')
68 // ⛔ mintIdentity REFUSES to overwrite a vault. A vault without a credential means a half-finished mint
69 // or a lost device.json: re-derive the credential from it rather than minting a different identity.
70 if (fs.existsSync(vaultPath)) {
71 const { mintFromVault } = await import('favela-peer/lib/device.js')
72 cred = await mintFromVault(vaultPath)
73 } else {
74 const minted = await mintIdentity({ vaultPath })
75 cred = {
76 keyPair: { publicKey: b4a.from(minted.credential.publicKey, 'hex'), secretKey: b4a.from(minted.credential.secretKey, 'hex') },
77 proof: minted.credential.proof
78 }
79 this._log({ msg: 'observer: minted the gateway identity (once)' })
80 }
81 fs.chmodSync(vaultPath, 0o600)
82 saveCredential(this._dir, cred)
83 }
84 fs.chmodSync(join(this._dir, 'device.json'), 0o600)
85 const proven = readProof(cred.proof)
86 if (!proven || !proven.root) throw new Error('ObservationFeeds: the observer credential does not verify')
87 this.cred = cred
88 this.root = typeof proven.root === 'string' ? proven.root : b4a.toString(proven.root, 'hex')
89 return this
90 }
91
92 _map () {
93 try { return JSON.parse(fs.readFileSync(this._mapPath, 'utf8')) || {} } catch { return {} }
94 }
95
96 _saveMap (map) {
97 const tmp = this._mapPath + '.tmp'
98 fs.writeFileSync(tmp, JSON.stringify(map, null, 2) + '\n')
99 fs.renameSync(tmp, this._mapPath)
100 }
101
102 /** Open (founding on first use) the observation feed for one registered user. Idempotent. → { key, feed } */
103 async open (username, generation) {
104 if (!this.cred) throw new Error('ObservationFeeds: ready() first')
105 const user = String(username || '').toLowerCase()
106 if (!/^[a-z0-9_]+$/.test(user)) throw new Error('ObservationFeeds: a username is required')
107 const gen = Number(generation) | 0
108 const id = user + ':' + gen
109 if (this._feeds.has(id)) return this._feeds.get(id)
110 if (this._opening.has(id)) return this._opening.get(id)
111
112 const opening = (async () => {
113 const map = this._map()
114 const known = map[id] || null
115 const dir = join(this.storage, 'records', 'obs-' + user + '-' + gen)
116 const store = new RecordStore(dir, { overlay: this.overlay })
117 const feed = new Feed(this.corestore.namespace('obs:' + id), known ? b4a.from(known, 'hex') : null, {
118 store,
119 // The same rule FeedHolder states: every favela node opens feeds with the doorbell ON, so a holder
120 // computes the same view as this writer does.
121 optimistic: true
122 })
123 await feed.ready()
124 // Founding is a device admitting itself; on a reopen it is the same idempotent call favela-peer's node
125 // makes at every boot.
126 await feed.addDevice(bindWriter(this.cred, feed.deviceKey))
127 await feed.update()
128 const key = b4a.toString(feed.key, 'hex')
129 if (known && known !== key) throw new Error('ObservationFeeds: ' + id + ' reopened as a different feed')
130 if (!known) { map[id] = key; this._saveMap(map) }
131 // Bytes written before a restart must be servable again (favela-peer node.js _seedOverlay).
132 store.reregisterLocal().catch(() => {})
133 const entry = { key, feed }
134 this._feeds.set(id, entry)
135 return entry
136 })()
137 this._opening.set(id, opening)
138 try { return await opening } finally { this._opening.delete(id) }
139 }
140
141 /** The feed key for a user, or null if it was never opened. */
142 key (username, generation) {
143 return this._map()[String(username).toLowerCase() + ':' + (Number(generation) | 0)] || null
144 }
145
146 /** Every open feed — what the swarm replicates on each connection. */
147 feeds () { return [...this._feeds.values()].map((e) => e.feed) }
148
149 /**
150 * Record one observation. → its record name.
151 *
152 * ⛔ REFUSED rather than stored when it names no actor, no activity, or a kind outside the set: a row nobody
153 * can be attributed to, or that nothing could ever undo, is noise that reads later as data (observations.js).
154 */
155 async note (username, generation, { kind, actor, subject = null, target = null, at = Date.now(), activity, body = '' } = {}) {
156 if (!KINDS.includes(kind)) throw new Error('ObservationFeeds.note: unknown kind "' + kind + '"')
157 if (typeof actor !== 'string' || !actor) throw new Error('ObservationFeeds.note: an actor is required')
158 if (typeof activity !== 'string' || !activity) throw new Error('ObservationFeeds.note: an activity id is required')
159 const { feed } = await this.open(username, generation)
160 const name = observationName(activity)
161 const frontmatter = { kind, actor, subject: subject || null, target: target || null, at: Number(at) || Date.now(), activity }
162 await feed.put(COLLECTION, name, { frontmatter, body: String(body || '').slice(0, MAX_BODY) })
163 return name
164 }
165
166 /** Remove the observation an activity produced (an Undo or a Delete). → true if there was one. */
167 async remove (username, generation, activity) {
168 const { feed } = await this.open(username, generation)
169 const name = observationName(activity)
170 await feed.update()
171 if (!(await feed.get(COLLECTION, name))) return false
172 await feed.del(COLLECTION, name)
173 return true
174 }
175
176 replicate (socket) {
177 for (const f of this.feeds()) { try { f.base.replicate(socket) } catch {} }
178 }
179
180 async close () {
181 for (const { feed } of this._feeds.values()) { try { await feed.close() } catch {} }
182 this._feeds.clear()
183 }
184}
185
186export default { ObservationFeeds, observationName, COLLECTION, KINDS }
test/observation-feeds.test.js +125 -0
……@@ -0,0 +1,125 @@
1// observation-feeds.test.js — the gateway writes what it OBSERVES for a user into a feed it owns, and the user's
2// node holds it (hyper-social #82, I1 slice S1).
3//
4// ⛔ REAL feeds, real replication over a pipe, the same harness as feed-holder.test.js: a second corestore
5// opening the observation feed BY KEY must read what the gateway wrote, and must be able to check that the
6// gateway founded it — the node trusts the observer root it was given at registration, nothing else.
7
8import test from 'brittle'
9import fs from 'bare-fs'
10import os from 'bare-os'
11import { join } from 'bare-path'
12import Corestore from 'corestore'
13import { Feed } from 'favela-peer/lib/feed-base.js'
14import { RecordStore } from 'favela-peer/lib/record-store.js'
15import { ObservationFeeds, observationName } from '../lib/observation-feeds.js'
16
17const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'obs-'))
18
19function pipe (a, b) {
20 const s1 = a.replicate(true)
21 const s2 = b.replicate(false)
22 s1.pipe(s2).pipe(s1)
23 return () => { s1.destroy(); s2.destroy() }
24}
25
26async function until (fn, { timeout = 8000 } = {}) {
27 const deadline = Date.now() + timeout
28 while (Date.now() < deadline) {
29 const v = await fn()
30 if (v) return v
31 await new Promise((r) => setTimeout(r, 25))
32 }
33 return null
34}
35
36const LIKE = {
37 kind: 'like',
38 actor: 'https://prsm.space/users/alice',
39 subject: 'https://ap.7rnx.net/ap/users/fleeky/objects/01k2post',
40 target: { feed: 'aa'.repeat(32), collection: 'post', name: '01k2post' },
41 at: 1792000000000,
42 activity: 'https://prsm.space/users/alice#likes/1'
43}
44
45test('★ the gateway mints its identity ONCE, and keeps it across restarts', async (t) => {
46 const storage = tmp()
47 const a = new ObservationFeeds({ corestore: new Corestore(join(storage, 'store')), storage })
48 await a.ready()
49 const root = a.root
50 t.ok(/^[0-9a-f]{64}$/.test(root), 'an identity root')
51 for (const f of ['vault.json', 'device.json']) {
52 const mode = fs.statSync(join(storage, 'observer', f)).mode & 0o777
53 t.is(mode, 0o600, f + ' is 0600 (it holds a secret)')
54 }
55 await a.close()
56
57 const b = new ObservationFeeds({ corestore: new Corestore(join(storage, 'store')), storage })
58 await b.ready()
59 t.is(b.root, root, 'a restart reuses the same identity — it is never re-minted')
60 await b.close()
61})
62
63test('★★ one feed per user, founded by the gateway root, readable by a node that holds it by key', async (t) => {
64 const storage = tmp()
65 const obs = new ObservationFeeds({ corestore: new Corestore(join(storage, 'store')), storage })
66 await obs.ready()
67
68 const fleeky = await obs.open('fleeky', 1)
69 const ashatron = await obs.open('ashatron', 1)
70 t.not(fleeky.key, ashatron.key, 'a feed per user')
71 t.is((await obs.open('fleeky', 1)).key, fleeky.key, 'opening again is idempotent')
72 t.not((await obs.open('fleeky', 2)).key, fleeky.key, 'a new generation (a re-claimed name) is a new feed')
73
74 const name = await obs.note('fleeky', 1, { ...LIKE, body: '' })
75 t.is(name, observationName(LIKE.activity), 'named by a hash of the activity id')
76
77 // The user's node: a SECOND corestore holding the observation feed by key, reading bytes from the same
78 // records dir (which stands in for the overlay, as in feed-holder.test.js).
79 const node = new Feed(new Corestore(tmp()), Buffer.from(fleeky.key, 'hex'), {
80 store: new RecordStore(join(storage, 'records', 'obs-fleeky-1')),
81 optimistic: true
82 })
83 await node.ready()
84 t.teardown(() => Promise.all([node.close(), obs.close()]))
85 t.teardown(pipe(obs.corestore, node.base.store))
86
87 const rec = await until(async () => { await node.update(); return node.get('observation', name) })
88 t.ok(rec, 'the node reads the observation')
89 t.alike({ kind: rec.frontmatter.kind, actor: rec.frontmatter.actor, subject: rec.frontmatter.subject, activity: rec.frontmatter.activity },
90 { kind: 'like', actor: LIKE.actor, subject: LIKE.subject, activity: LIKE.activity })
91 t.alike(rec.frontmatter.target, LIKE.target, 'with the subject mapped to the user\'s own record')
92
93 const founder = await node.founder()
94 t.is(founder && founder.identityRoot, obs.root, 'the node can check the gateway founded it')
95})
96
97test('a reply carries its text; an Undo removes the observation', async (t) => {
98 const storage = tmp()
99 const obs = new ObservationFeeds({ corestore: new Corestore(join(storage, 'store')), storage })
100 await obs.ready()
101 t.teardown(() => obs.close())
102 const { feed } = await obs.open('fleeky', 1)
103
104 const reply = { ...LIKE, kind: 'reply', activity: 'https://prsm.space/users/alice/statuses/9/activity', body: 'nice post!' }
105 const name = await obs.note('fleeky', 1, reply)
106 await feed.update()
107 const rec = await feed.get('observation', name)
108 t.is(rec.frontmatter.kind, 'reply')
109 t.is(rec.body.trim(), 'nice post!', 'the reply text is the record body')
110
111 t.is(await obs.remove('fleeky', 1, reply.activity), true, 'removed')
112 await feed.update()
113 t.absent(await feed.get('observation', name), 'gone after the Undo')
114 t.is(await obs.remove('fleeky', 1, reply.activity), false, 'removing again is a no-op')
115})
116
117test('note refuses what it cannot attribute', async (t) => {
118 const storage = tmp()
119 const obs = new ObservationFeeds({ corestore: new Corestore(join(storage, 'store')), storage })
120 await obs.ready()
121 t.teardown(() => obs.close())
122 await t.exception(() => obs.note('fleeky', 1, { ...LIKE, actor: '' }), /actor/)
123 await t.exception(() => obs.note('fleeky', 1, { ...LIKE, activity: '' }), /activity/)
124 await t.exception(() => obs.note('fleeky', 1, { ...LIKE, kind: 'dm' }), /kind/)
125})