OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge commits / 59a51b9

0.10.15: ap-bridge is a module (startApBridge), so it can share a process
The favela gateway (hyper-social #80) runs ap-bridge beside the atproto membrane in one process. index.js's main() bound the public and admin ports, admin.sock, a FeedHolder (Corestore + swarm), the DID path's swarm/store and an admin-identity Corestore, then shut down by closing ONLY the public server and calling process.exit — process exit released the rest. - lib/bridge.js: startApBridge(config, { log, bootstrap, version }) does what main() did and returns { port, adminPort, bridgeKey, handlers, status, onInvite, onNotifications, onReproject, steps }. `steps` closes everything it opened: outbound drain, server, admin server, admin.sock, registration door, holder, DID peer/swarm/store, admin identity (Browsers.close closes only its namespace, so the store is closed too). - lib/control.js: the verbs are a plain map (bridgeHandlers), same parameter mapping; startControl registers it unchanged. - index.js: the standalone wrapper — admin subcommand first, control socket, release-ready -> 75, signals; its shutdown now runs every step under the same 5 s cap. - FeedHolder takes an optional `bootstrap` (tests only). - `adminPort: 0` now means "any free port" (tests); unset stays off. - Three source-text tests repointed from index.js to lib/bridge.js. Verified: new test/start-bridge.test.js (start -> stop -> start again on the same ports and data dir in one process; same bridgeKey) was red without the module; suite 366/366. The real entrypoint on a throwaway dir: actor 200, release-ready -> exit 75 after 1.25 s, SIGTERM -> exit 0 after 0.87 s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
59a51b9d38598e6f0cd6ff908f5ba8fa89bb3677
by plankatron <fleeky@gmail.com> · 2026-10-13T20:31:58.000Z

Changed files (10) +854 -644

index.js +27 -597
……@@ -1,44 +1,16 @@
11// ap-bridge — the ActivityPub membrane. See DESIGN.md.
22//
3// M1 SCOPE (deliberate deviation, DESIGN.md §11): this process holds the actor's private key.
4// The target architecture keeps keys on an attested virtual-ap-device and signs over RPC, so the
5// bridge stays keyless — that is M2. Proving federation and proving keylessness are two separate
6// experiments; running them together means a failure has two possible causes.
3// The standalone app around lib/bridge.js. The bridge itself is a module since 0.10.15 so the favela
4// gateway (hyper-social #80) can run it beside the atproto membrane in one process. This file owns
5// what only a standalone process may own: the admin CLI subcommand, the control socket and its
6// sidecar name, the OTA exit, and the signal handlers.
77
88import './lib/bare-globals.js' // FIRST — deps touch TextEncoder at module load
99import fs from 'bare-fs'
1010import process from 'bare-process'
1111import { join } from 'bare-path'
1212import { loadConfig, assertUsable } from './lib/config.js'
13import crypto from 'bare-crypto'
14import http from 'bare-http1'
15import { createServer } from './lib/server.js'
16import { actorDocFor } from './lib/router.js'
17import { FollowerStore } from './lib/followers.js'
18import { InboundStore } from './lib/inbound-store.js'
19import { ObservationStore } from './lib/observations.js'
20import { RemoteKeyResolver } from './lib/remote-key.js'
21import { createDeliverer } from './lib/deliver.js'
22import { LocalSigner } from './lib/signer.js'
23import { DeliveryQueue, createQueuedDeliverer } from './lib/queue.js'
24import { toNotifications } from './lib/inbound-notifications.js'
25import { Users } from './lib/users.js'
26import { PerUser } from './lib/per-user.js'
27import { Registrations } from './lib/registrations.js'
28import { FeedHolder } from './lib/feed-holder.js'
29import { UserKeys } from './lib/user-keys.js'
30import { keyId, actorId, buildActor } from './lib/actor.js'
31import { RecordSource } from './lib/records.js'
32import { createBlobFetcher } from './lib/blobs.js'
33import { httpClient } from './lib/https.js'
34import { Publisher } from './lib/publisher.js'
35import { JsonState } from './lib/state.js'
36import { DidPeer } from 'atproto-did-peer'
37import { FavelaSource } from './lib/favela-source.js'
38import { resolveDidVerifyKey } from './lib/plc.js'
39import Hyperswarm from 'hyperswarm'
40import { openCorestore } from 'atproto-store/lib/corestore.js'
41import { discoveryTopic } from 'atproto-identity/lib/discovery.js'
13import { startApBridge } from './lib/bridge.js'
4214
4315const log = (o) => console.log(JSON.stringify({ t: new Date().toISOString(), ...o }))
4416
……@@ -58,8 +30,8 @@ async function main () {
5830 if (process.argv[2] === 'admin') {
5931 const { runAdminCli } = await import('./lib/admin-cli.js')
6032 // ⛔ THE SAME DERIVATION THE DAEMON USES, via loadConfig (which expands ~ and ${HOME}) and the
61 // identical fallback below. If the CLI and the daemon disagreed about dataDir by one character the
62 // CLI would report "no control socket" on a bridge that is running and serving it.
33 // identical fallback in lib/bridge.js. If the CLI and the daemon disagreed about dataDir by one
34 // character the CLI would report "no control socket" on a bridge that is running and serving it.
6335 //
6436 // ⚠ loadConfig, NOT assertUsable — the CLI must work on a bridge whose keys or host are
6537 // misconfigured. Refusing to hand out a recovery invite because the thing needing recovery is
……@@ -70,549 +42,25 @@ async function main () {
7042 }
7143
7244 const config = assertUsable(loadConfig())
45 const version = pkgVersion()
46 const bridge = await startApBridge(config, { log, version })
7347
74 const privateKey = fs.readFileSync(config.privateKeyPath, 'utf8')
75 const publicKeyPem = fs.readFileSync(config.publicKeyPath, 'utf8')
76
77 const dataDir = config.dataDir || join(process.env.HOME || '.', '.config', 'ap-bridge')
78 const followers = new FollowerStore(join(dataDir, 'followers.json'))
79 // What the fediverse sends us — replies, mentions, likes, boosts. Until now these were verified,
80 // acked with 202 and discarded, so ap-bridge could be replied to and never knew.
81 // ⛔ THE CAP IS THE OPERATOR'S. It was fixed at 500 here, and for the fediverse an eviction is not a
82 // cache miss — there is no backlink index to re-ask, so it can be the only copy. See config.js.
83 const inbound = new InboundStore({ file: join(dataDir, 'inbound.json'), cap: config.inboundCap })
84 // ⛔ THE DURABLE HALF OF THE SAME ARRIVAL. `inbound` is a capped timeline for THIS bridge's pages;
85 // an observation is what a registered PEER collects and keeps. Both are written from one inbox POST
86 // and they have different lifetimes, which is why they are two stores rather than one with a flag —
87 // see hyper-social/docs/OBSERVED-INTERACTIONS.md §9.
88 const observations = new ObservationStore({ path: join(dataDir, 'observations.jsonl') })
89 const resolver = new RemoteKeyResolver()
90
91 // ⛔ CUSTODY IS NAMED HERE, not implied by passing a key around. This is the primary actor and it
92 // is co-located with its key on this box, so LocalSigner is honest — signer.js: "a RemoteSigner to
93 // a peer ON THE SAME BOX buys nothing". Moving custody to the virtual-ap-device (DESIGN.md §4) is
94 // now a change to this one expression; nothing in deliver.js or http-signature.js moves.
95 //
96 // It also parses the PEM once instead of on every delivery, which is what signRequest did when
97 // handed a PEM string (rsa.js:184 re-parses whenever the key is a string).
98 const sendNow = createDeliverer({
99 signer: new LocalSigner({ keyId: keyId(config), privateKeyPem: privateKey }),
100 log
101 })
102
103 // ⛔ EVERY OUTBOUND ACTIVITY GOES THROUGH THE QUEUE, and is durable before the network is touched.
104 // Both callers benefit: Publisher's Creates, and router.js:287's `Accept` for an inbound Follow —
105 // which logged "will retry" while having no retry that survived a restart. A follow that arrives
106 // while this box is down is the main thing genuinely owed to somebody, and it is now owed durably.
107 // ⛔ THE QUEUE CARRIES A KEY ID, AND THIS TURNS IT BACK INTO A SIGNER. A signer is a live object
108 // and cannot survive a restart in a JSON file, so the durable entry records WHO signs and the
109 // signer is resolved per attempt. Without this every queued activity fell back to the process
110 // signer: a registered user's posts went out signed with the primary's key against the primary's
111 // key id, and Mastodon dropped every one of them after answering 202.
112 //
113 // ⚠ Assigned below, once PerUser exists. A null resolver means a queued entry with a signerId is
114 // HELD rather than sent under the wrong identity — the safe direction.
115 let signerByKeyId = null
116 const outbound = createQueuedDeliverer({
117 queue: new DeliveryQueue(join(dataDir, 'queue.json')),
118 deliver: sendNow,
119 resolveSigner: (keyId) => (signerByKeyId ? signerByKeyId(keyId) : null),
120 log
121 })
122 const deliver = outbound.deliver
123
124 // ⛔ Registration is OFF unless the operator turned it on — `config.registration` is 'open' or
125 // 'invite'. Absent means no registry at all and /ap/register 404s, so a bridge never starts
126 // handing out names because nobody said not to.
127 const registrations = config.registration
128 ? new Registrations({ path: join(dataDir, 'registrations.json'), policy: config.registration })
129 : null
130 if (registrations) log({ msg: 'registration', policy: registrations.policy })
131
132 // ⛔ Holds registered peers' feeds over the SWARM. A registered peer is on somebody else's
133 // machine, so the bridge holds their feed read-only by key — the same thing any follower does —
134 // rather than reaching for a unix socket that only exists when the peer is co-located.
135 const holder = registrations ? await new FeedHolder({ storage: join(dataDir, 'held'), log }).ready() : null
136 // ⛔ ITS OWN KEYPAIR PER USER. Mastodon indexes actors by publicKey.id, and sharing the primary's
137 // key made a registered user resolve AS the primary — a duplicate, not a new account. Distinct
138 // ids fix resolution; distinct key material is what makes the separation real, since a signature
139 // is what proves who acted.
140 const userKeys = registrations ? new UserKeys({ dir: join(dataDir, 'user-keys') }) : null
141 // ⛔ The control half of admission. registrations.js holds the registry and says in its own header
142 // that proving control "belongs at the route where the challenge lives" — this is that challenge.
143 // Without it an identity root, which is public, was accepted as proof of holding it.
144 const { Admission } = await import('./lib/admission.js')
145 const admission = registrations ? new Admission() : null
146
147 let seq = 0
148 const ctx = {
149 registrations,
150 admission,
151 config,
152 publicKeyPem,
153 followers,
154 inbound,
155 observations,
156 resolvePublicKey: (kid) => resolver.resolve(kid),
157 deliver,
158 // Activity ids must be unique and stable enough that a receiver can dedup. Timestamp+counter
159 // is adequate for one actor; a real id scheme belongs with the durable queue.
160 newId: () => `${Date.now().toString(36)}-${(seq++).toString(36)}`,
161 log
162 }
163
164 // ★ THE KEYLESS READ SIDE. Front the DID exactly as atproto-bridge and favela do: replicate the
165 // peer's cores, read records, project to AS2 on demand. No key involved — atproto-did-peer
166 // refuses to front a repo whose signed commit doesn't verify against the DID's published key,
167 // which is what makes reading keylessly safe rather than merely convenient.
168 //
169 // Failure here is NON-FATAL: without a record source the outbox is empty but discovery, the
170 // inbox and Follow/Accept all still work. A peer that is briefly unreachable must not stop the
171 // actor existing.
172 // Hoisted to main() scope: the control surface's `reproject` (below) must reach it, and it was
173 // previously declared inside the try-block that builds it — in scope for onHead, out of scope for
174 // everything after. node --check does not catch that; only calling reproject would have.
175 let publisher = null
176
177 /**
178 * ⛔ THE FAVELA SOURCE, when one is configured — records read from the peer's own markdown rather
179 * than from the atproto projection of them.
180 *
181 * `favela-markdown/spec/design.md`: both networks derive from the canonical record, neither from
182 * the other. Deriving AS2 from `record.text` gives the fediverse bsky's plain text, so a heading
183 * and a list arrive as paragraphs of literal bullet characters. This path renders the markdown.
184 *
185 * ⛔ AND IT NEEDS NO DID. No atproto identifier, no PDS, no MST, no swarm topic — just the peer's
186 * socket. That is what lets this bridge front an identity holding no atproto credential at all
187 * (identity.md §4.1), which the branch below cannot do.
188 *
189 * ⚠ EXCLUSIVE, and checked rather than assumed. Two sources would mean two sets of object ids for
190 * one author, and whichever ran second would look like a stranger reposting the first.
191 */
192 if (config.favelaSocket) {
193 if (config.did) {
194 throw new Error('ap-bridge: configure favelaSocket OR did, not both — two sources for one ' +
195 'actor produce two object ids per post, and the second reads as somebody else reposting')
196 }
197 ctx.records = new FavelaSource({
198 sock: config.favelaSocket,
199 host: config.host,
200 actorId: actorId(config),
201 followersId: `https://${config.host}/ap/followers`,
202 roomType: config.roomType || 'public'
203 })
204 log({ msg: 'favela source', sock: config.favelaSocket })
205 } else if (config.did) {
206 // GOTCHA (favela hit this): use atproto-store's openCorestore, NOT `new Corestore` from a
207 // separately-installed copy — RepoStore's `instanceof Corestore` fails across duplicated
208 // module copies and surfaces as "path... undefined".
209 const corestore = openCorestore(join(dataDir, 'store'))
210 const swarm = new Hyperswarm()
211 try {
212 const verifyKey = await resolveDidVerifyKey(config.did)
213
214 // onHead fires on every newly VERIFIED head — the event-driven trigger for delivery. A
215 // polling timer here would be the anti-pattern this fleet keeps having to remove.
216 const peer = new DidPeer(corestore, {
217 did: config.did,
218 verifyKey,
219 // ⛔ A HEAD ADVANCE CARRIES PROFILE CHANGES, NOT JUST POSTS. onHead drained notes and
220 // nothing else, and refreshProfile was called exactly once at boot — so a changed display
221 // name, bio or avatar reached Mastodon only if the process happened to restart afterwards.
222 // Measured 2026-09-09: our actor served a bio prsm had never seen, last Update 21h earlier.
223 // refreshProfile is fingerprint-guarded, so this is a no-op when nothing changed.
224 onHead: () => {
225 if (!publisher) return
226 publisher.onHead()
227 publisher.refreshProfile().catch((err) => log({ msg: 'profile refresh failed', error: err.message }))
228 },
229 onError: (err) => log({ msg: 'peer error', error: err.message })
230 })
231
232 // One replicate per connection; the peer attaches to learn its cores (the keet model).
233 swarm.on('connection', (conn) => peer.attach(conn))
234
235 // ★ JOIN THE DID'S TOPIC. Attaching connections is useless without this — nothing ever
236 // connects, and the only symptom is an outbox that is permanently empty with no error.
237 // {client,server} BOTH: symmetric join is load-bearing for HOLE-PUNCHING, not just
238 // discovery — a client:false member never punches outbound so its NAT mapping never opens.
239 swarm.join(discoveryTopic(config.did), { client: true, server: true })
240 await swarm.flush()
241 await peer.ready({ timeout: 20000 })
242 ctx.records = new RecordSource({
243 peer,
244 did: config.did,
245 host: config.host,
246 actorId: actorId(config),
247 followersId: `https://${config.host}/ap/followers`
248 })
249 publisher = new Publisher({
250 records: ctx.records,
251 followers,
252 deliver,
253 actorId: actorId(config),
254 newId: ctx.newId,
255 log,
256 state: new JsonState(join(dataDir, 'publisher.json')),
257 backfill: config.backfill,
258 // Used ONLY by reproject(): can we actually serve this attachment? A /ap/blobs/<cid> URL is
259 // proxied from the PDS, so the honest check is whether the PDS has the bytes. One post's
260 // blobs were lost before the overlay migration; re-sending it would teach the receiver the
261 // same 404 again and burn the one edit that would fix it if the bytes ever came back.
262 blobResolves: async (url) => {
263 const m = /\/ap\/blobs\/([^/?#]+)$/.exec(String(url || ''))
264 if (!m) return true // not one of ours — not ours to judge
265 try { return !!(await ctx.fetchBlob(m[1])) } catch { return false }
266 },
267 buildActorDoc: async () => buildActor({
268 host: config.host,
269 username: config.username,
270 displayName: config.displayName,
271 summary: config.summary,
272 publicKeyPem,
273 alsoKnownAs: config.alsoKnownAs || [],
274 profile: await ctx.records.getProfile()
275 })
276 })
277 // Prime WITHOUT delivering: existing posts stay undelivered, only new ones go out.
278 await publisher.primeWatermark()
279 await publisher.refreshProfile()
280
281 // ★ Reconcile once at boot. The drain is otherwise triggered ONLY by a head advance, so a
282 // deploy that IMPROVES the projection (images, and later facets/quotes) would sit inert
283 // until the next unrelated post — the same trap the backfill hit. Cheap and self-limiting:
284 // one listNotes plus a fingerprint compare, and nothing is sent if nothing changed.
285 publisher.onHead()
286 // The primary holds posts for the same reason and needs the same kick when somebody follows.
287 ctx.onFollowed = () => { if (publisher) publisher.onHead() }
288
289 ctx.fetchBlob = createBlobFetcher({ did: config.did, pdsUrl: config.pdsUrl, doFetch: httpClient })
290
291 log({ msg: 'fronting DID (keyless read)', did: config.did })
292 } catch (err) {
293 log({ msg: 'could not front DID — outbox will be empty (discovery/inbox unaffected)', did: config.did, error: err.message })
294 }
295 }
296
297 // ⛔ MULTI-USER, AND STRICTLY ADDITIVE. `PerUser` and `Users` have existed and tested since 0.9.51
298 // with nothing constructing them (hyper-social #64 §8, #65 §7). This is that construction.
299 //
300 // The point is a second actor whose records come from FAVELA MARKDOWN rather than from the
301 // atproto projection — `PerUser.records()` builds a FavelaSource from `user.favelaSocket`, which
302 // needs no DID at all.
303 //
304 // ⛔ THE INCUMBENT DOES NOT MOVE. `/ap/actor` and every already-federated
305 // `/ap/objects/<rkey>` must stay byte-identical: prsm.space follows this actor and holds 20 of
306 // its objects, and an actor that changes its id is a stranger to every server that already knows
307 // it. The primary keeps the FLAT paths and deliberately inherits the process-wide instances; new
308 // users live under `/ap/users/<name>/`. A test pins that a single-actor registry produces
309 // identical ids to having no registry at all.
310 //
311 // ⚠ AND SWITCHING THE INCUMBENT'S SOURCE IS NOT AN OPTION, which is why this is additive rather
312 // than a config flip. Both sources mint `objectIdFor({host, rkey})`, but the DID path's rkey is
313 // an atproto TID and FavelaSource's is `row.name` — the favela record's own name. Same content,
314 // different ids. Repointing the live actor would orphan all 20 federated objects and read as a
315 // deletion followed by a repost.
316 //
317 // No `users` in config = no registry = today's behaviour exactly, by the router's own fallback.
318 // ⛔ A REGISTRY IS NEEDED WHENEVER REGISTRATION IS ON, not only when users are listed in config.
319 // This bridge is env-configured with a single AP_USERNAME and no users array — so gating the
320 // registry on `config.users` would have left registration enabled and nothing ever admitted: a
321 // name claimed, a 200 returned, and no actor.
322 //
323 // With no list, the registry starts as the configured primary alone. `Users` with one entry
324 // produces ids identical to having no registry at all, which is pinned by a test — so this stays
325 // invisible to the incumbent.
326 const userEntries = Array.isArray(config.users) && config.users.length
327 ? config.users
328 : (registrations ? [{ username: config.username, primary: true, favelaSocket: config.favelaSocket || null }] : null)
329
330 if (userEntries) {
331 const users = new Users(userEntries, config.host)
332 const perUser = new PerUser({
333 users,
334 dataDir,
335 // ⛔ The overlay's Files, so a held user's avatar resolves from THEIR bytes rather than being
336 // looked for on the primary's PDS, where it is not and never was.
337 files: holder ? holder.files : null,
338 readFile: async (p) => fs.readFileSync(p),
339 log,
340 // ⛔ AFTER ctx.records is built. The primary INHERITS these; constructing the
341 // registry earlier handed it a null source, which is the cross-wiring PerUser exists
342 // to prevent — pointed the wrong way.
343 // ⛔ THESE KEY NAMES ARE THE CONTRACT. PerUser reads shared.records / shared.followerStore /
344 // shared.fetchBlob / shared.signer. Passing `followers` instead of `followerStore` — which is
345 // what shipped — made `_inherits` never match, so the incumbent and every registered user
346 // read the SAME follower list and each per-user publisher found no recipients. Nothing threw.
347 shared: { records: ctx.records, followerStore: followers, fetchBlob: ctx.fetchBlob, signer: null }
348 })
349 ctx.users = users
350 Object.assign(ctx, perUser.resolvers())
351
352 // Resolve a queued entry's key id back to that user's signer. Scanning the registry is right
353 // here: the key id IS the actor id plus '#main-key', so this cannot match somebody else by
354 // accident, and there are a handful of users rather than thousands.
355 signerByKeyId = (keyId) => {
356 for (const u of users.all()) {
357 const s = perUser.signer(u)
358 if (s && s.keyId === keyId) return s
359 }
360 return null
361 }
362
363 /**
364 * A registration becomes a served actor: hold the feed, add the user.
365 *
366 * ⛔ ADDITIVE ONLY. `Users.add` never makes a registrant primary, so the flat paths — and every
367 * one of the incumbent's already-federated object ids — stay exactly where they are.
368 */
369 const admit = async ({ username, root, feedKey }) => {
370 const peer = feedKey && holder ? await holder.hold(feedKey) : null
371 // Minted on first admission, read from disk on every one after — a key that changed would
372 // break every remote server that had cached it.
373 const keys = userKeys ? await userKeys.forUser(username) : null
374 const user = users.add({ username, root, feedKey, peer, keys })
375 log({ msg: 'admitted', username, held: !!peer, keyed: !!keys })
376 // ⛔ AND START PUBLISHING. Without this a registered user's posts reach the OUTBOX and nobody
377 // else: the outbox is a pull surface and no fediverse server backfills one. Every post
378 // ashatron made was visible at /outbox and delivered to zero followers, because the per-user
379 // Publisher was built lazily inside refreshUser and the only method ever called on it was
380 // refreshProfile. Nothing primed a watermark and nothing ever called onHead.
381 if (peer) await startPublishing(user).catch((err) => log({ msg: 'publisher start failed', username, error: err.message }))
382 }
383 ctx.keysFor = (u) => (u && u.keys) || null
384
385 /**
386 * A publisher per registered user, so a profile change reaches their followers.
387 *
388 * ⛔ Signed with THEIR key. deliver() takes a per-delivery signer (0.9.52), so this hands each
389 * user's own signer down — the primary's key against their key id would be rejected by every
390 * receiver, and the actor would resolve while nothing it sent was believed.
391 */
392 const publishers = new Map()
393 const publisherFor = (u) => {
394 if (publishers.has(u.username)) return publishers.get(u.username)
395 const ids = users.ids(u)
396 const signer = perUser.signer(u)
397 const p = new Publisher({
398 records: perUser.records(u),
399 // ⛔ The SAME store instance the router writes an inbound Follow into — PerUser memoises per
400 // username, so a follow accepted at /ap/users/<n>/inbox is a recipient here immediately.
401 followers: perUser.followers(u),
402 deliver: (d) => outbound.deliver({ ...d, signer }),
403 actorId: ids.actor,
404 newId: () => `${Date.now().toString(36)}-${(seq++).toString(36)}`,
405 log,
406 state: new JsonState(join(dataDir, 'publisher-' + u.username + '.json')),
407 // ⚠ Inherited from the bridge's own setting deliberately. A registered user's history is
408 // one-shot and self-disarming exactly as the primary's is (see primeWatermark): those rkeys
409 // enter the delivered-set as they go out, so a restart repeats nothing.
410 backfill: config.backfill,
411 buildActorDoc: async () => actorDocFor(ctx, u)
412 })
413 publishers.set(u.username, p)
414 return p
415 }
416
417 /**
418 * Give a held user a publisher, and an EVENT to drive it.
419 *
420 * ⛔ The primary's trigger is DidPeer's `onHead`. A held favela feed had no equivalent, so this
421 * is it: the feed's writer cores, re-hooked, plus a peer returning. Never a poll — the standing
422 * rule is that events set the latency floor and a timer would only be expressing absence, which
423 * a `connection` event already does.
424 */
425 const startPublishing = async (u) => {
426 const p = publisherFor(u)
427 // ⛔ PRIME ON EVERY ADVANCE, not once at start. A held feed's log replicates asynchronously,
428 // so a prime at boot routinely sees an empty listing — and 0.9.76 correctly declined to record
429 // that, but nothing ever called it again. The publisher stayed unprimed, the backfill never
430 // ran, and the delivered-set stayed empty with the outbox already serving 16 posts. Measured:
431 // primed=true/delivered=0 across two deploys.
432 //
433 // primeWatermark is cheap and returns early the moment it has genuinely primed, so running it
434 // per advance costs one listNotes on a feed that has already settled and is the only thing
435 // that makes "try again when the log arrives" true.
436 // ⛔ refreshProfile BELONGS IN THE ADVANCE, not before it. It used to run once here and never
437 // again, so a registered user could edit their name, bio or avatar and no follower would ever
438 // be told — posts kept flowing, because those ride onHead, which made the gap look like a
439 // profile-specific bug rather than a missing trigger. Same defect the primary had above.
440 const advance = async () => {
441 await p.primeWatermark()
442 p.onHead()
443 await p.refreshProfile()
444 }
445 // watch() fires once immediately, so this still covers the already-settled case at boot.
446 if (holder && u.feedKey) holder.watch(u.feedKey, advance)
447 else await advance()
448 }
449
450 ctx.refreshUser = async (username, force = false) => {
451 const u = users.get(username)
452 if (!u || !u.peer) return { ok: false, error: 'not a held user' }
453 await publisherFor(u).refreshProfile({ force: !!force })
454 return { ok: true, forced: !!force }
455 }
456 ctx.publishUser = async (username) => {
457 const u = users.get(username)
458 if (!u || !u.peer) return { ok: false, error: 'not a held user' }
459 publisherFor(u).onHead()
460 return { ok: true }
461 }
462 // A follow accepted at /ap/users/<n>/inbox drains THAT user's held posts immediately.
463 ctx.drainFor = (u) => { const p = u && publishers.get(u.username); if (p) p.onHead() }
464 ctx.onRegistered = admit
465
466 // ⛔ Releasing must STOP HOLDING. A bridge that kept replicating a feed whose owner had left
467 // would go on serving their posts under a name they no longer hold — which is the reuse hazard
468 // the generation counter exists to prevent, arriving by a different door.
469 ctx.onReleased = async (username) => {
470 const u = users.get(username)
471 if (u && u.feedKey && holder) await holder.release(u.feedKey)
472 if (userKeys) await userKeys.forget(username) // a burned name must not keep signing
473 users.remove(username)
474 log({ msg: 'released', username })
475 }
476
477 // ⛔ THE SWARM DOOR onto the same registration flow. A peer has no outbound HTTP — favela-peer
478 // has none at all — and both sides are already swarm members, so this is how a handset asks for a
479 // name without anybody shelling into anything. The rules are `admit.js`; this is transport.
480 //
481 // ⚠ On the FeedHolder's swarm, not a second one: it already runs one to hold registered peers'
482 // feeds, and two swarms in a process is two NAT mappings and two sets of connections for no gain.
483 if (registrations && holder && holder.swarm) {
484 try {
485 const { serveRegistration } = await import('./lib/register-rpc.js')
486 const reg = serveRegistration({ swarm: holder.swarm, registrations, admission, observations, config, onRegistered: ctx.onRegistered, onReleased: ctx.onReleased, log })
487 await reg.ready()
488 // ⛔ PRINTED, because it is what a peer needs in order to find this bridge at all. A key that
489 // exists and is written nowhere is the favela-android device-key mistake one layer over.
490 log({ msg: 'registration open over the swarm', bridgeKey: reg.key })
491 } catch (err) {
492 // Non-fatal: the HTTP door still works, and a bridge that federates is worth more than one
493 // that refuses to boot because a second door would not open.
494 log({ msg: 'swarm registration unavailable', error: (err && err.message) || String(err) })
495 }
496 }
497
498 // ⛔ AND ON BOOT, or a restart silently unserves everyone who registered. The registry is
499 // durable; what was held is not, so it has to be re-held from what the registry remembers.
500 if (registrations) {
501 for (const reg of await registrations.list()) {
502 try { await admit(reg) } catch (err) { log({ msg: 'could not re-admit', username: reg.username, error: err.message }) }
503 }
504 }
505 log({ msg: 'multi-user', count: userEntries.length, primary: users.primary && users.primary.username })
506 }
507
508 const server = createServer(ctx)
509
510 await new Promise((resolve, reject) => {
511 server.on('error', reject)
512 server.listen(config.port, config.bind, resolve)
513 })
514
515 // ⛔ THE OPERATOR SURFACE IS A SECOND LISTENER, ALWAYS LOOPBACK, AND OFF UNLESS ASKED FOR. It must
516 // not share the public port: that one is proxied to the internet, and this one has NO authentication
517 // in front of it — reach it over SSH. The same decision favela-surface states for itself: "exposing
518 // it must be a decision, never a default." It mints nothing; every action on it is a flow that
519 // already exists (invite, list, release).
520 if (config.adminPort) {
521 const { serveAdmin } = await import('./lib/admin-surface.js')
522 const { Sessions } = await import('./lib/admin-auth.js')
523 const { parseForm } = await import('./lib/admin-serve.js')
524 const { Browsers } = await import('favela-peer/lib/browsers.js')
525
526 // ⛔ A TOKEN PER BOOT, HELD ONLY IN MEMORY AND ONLY UNTIL CLAIMED. Writing it to disk would make a
527 // standing secret out of something needed exactly once; regenerating it every start costs nothing
528 // because it is meaningless the moment the bridge is claimed.
529 const bootstrap = crypto.randomBytes(16).toString('hex')
530 // GOTCHA, same as the record store above: openCorestore, never `new Corestore` from a separately
531 // installed copy — `instanceof Corestore` fails across duplicated module copies.
532 const browsers = new Browsers(openCorestore(join(dataDir, 'admin-identity')), { bootstrap })
533 await browsers.ready()
534
535 // ⛔ PRINTED ONLY WHILE UNCLAIMED, because after that it grants nothing and logging a dead secret
536 // every boot teaches an operator to ignore the line that matters.
537 if (await browsers.unclaimed()) {
538 log({ msg: 'admin surface UNCLAIMED — claim it with this bootstrap token, then it closes for good', bootstrap })
539 }
540
541 const adminCtx = { browsers, sessions: new Sessions(), registrations, inbound, config, log, root: new URL('.', import.meta.url).pathname }
542 const adminServer = http.createServer((req, res) => {
543 parseForm(req).then((form) => serveAdmin(
544 {
545 method: req.method,
546 path: (req.url || '/').split('?')[0],
547 // ⚠ Parsed here rather than in the routes, so a handler never sees a raw URL. The timeline
548 // pages on `offset`, and a page control that reads its own query string is the one place a
549 // surface starts re-implementing a URL parser.
550 query: Object.fromEntries(new URLSearchParams((req.url || '').split('?')[1] || '')),
551 form,
552 headers: req.headers || {},
553 // ⛔ FROM THE SOCKET. The recovery route gates on being on this machine, and a header would
554 // let a caller assert that rather than demonstrate it.
555 remoteAddress: (req.socket && req.socket.remoteAddress) || null
556 }, adminCtx))
557 .then((out) => { res.writeHead(out.status, out.headers); res.end(out.body) })
558 .catch((err) => { res.writeHead(500, { 'content-type': 'text/plain' }); res.end(String((err && err.message) || err)) })
559 })
560 await new Promise((resolve, reject) => {
561 adminServer.on('error', reject)
562 // ⛔ ITS OWN BIND, never config.bind — this deployment sets that to 0.0.0.0 for the fediverse
563 // listener, and inheriting it would publish the operator surface as a side effect. Defaults to
564 // loopback; widening it is deliberate, and safe because the surface is authenticated.
565 adminServer.listen(config.adminPort, config.adminBind || '127.0.0.1', resolve)
566 })
567 // ⛔ STILL LOOPBACK EVEN THOUGH IT NOW HAS AUTH, and for a reason that is not caution:
568 // static/anproto-client.js hashes with `crypto.subtle`, which browsers expose only in a SECURE
569 // CONTEXT ("works on localhost + HTTPS" — its own comment). Bound to a public port over plain
570 // HTTP this would serve a login page whose crypto is undefined in the browser that loaded it.
571 // Publish it through the proxy that already terminates TLS for this host.
572 log({ msg: 'admin surface (authenticated)', bind: (config.adminBind || '127.0.0.1') + ':' + config.adminPort })
573
574 // ⛔ THE LOCAL CONTROL SOCKET — the door that cannot be locked out. Losing every operator browser
575 // otherwise shuts the bridge for good: the bootstrap token gates the FIRST claim only and the
576 // instance never reopens. Owner-gated by the filesystem, which is the fleet's pattern for exactly
577 // this (pgv3 gates on ctx.transport; hpp's UDS surface is "TRUSTED WITHOUT the writer-allowlist"
578 // because only a local process that can open the path connects).
579 try {
580 const { serveAdminControl } = await import('./lib/admin-control.js')
581 const control = await serveAdminControl({ browsers, uds: join(dataDir, 'admin.sock'), log })
582 log({ msg: 'admin control socket (operator recovery)', uds: control.uds })
583 } catch (err) {
584 // ⚠ NOT FATAL, and said out loud. A bridge that refuses to serve the fediverse because its
585 // recovery socket would not bind has turned a recovery aid into an outage.
586 log({ msg: 'admin control socket unavailable — recovery from the CLI will not work', error: (err && err.message) || String(err) })
587 }
588 }
589
590 // ★ The first pass picks up whatever the last run left owed — which is the entire point of the
591 // queue being durable. Started after listen so a backlog drain never delays the port opening.
592 outbound.start()
593
594 log({
595 msg: 'ap-bridge listening',
596 bind: `${config.bind}:${config.port}`,
597 actor: `https://${config.host}/ap/actor`,
598 handle: `@${config.username}@${config.host}`,
599 followers: await followers.count(),
600 queued: await outbound.pending()
601 })
602
603 // Declared BEFORE the control surface that closes over it: `const` is not hoisted, and having the
604 // OTA handler reference a binding that is still in its temporal dead zone is a trap waiting for
605 // the day someone makes startControl call onRelease synchronously.
48 // Every stop step, in order, then exit with the caller's code. A step that never settles must not
49 // strand the process — the supervisor would otherwise wait out its full grace period on every OTA —
50 // so the same 5 s cap the old shutdown had still exits regardless.
51 let stopping = null
60652 const shutdown = (code = 0) => {
53 if (stopping) return stopping
60754 log({ msg: 'shutting down', code })
608 // Stop the drain timer, never the queue itself — anything still owed is on disk and is picked
609 // up by the next boot. That is the difference this module was built to make.
610 outbound.stop()
611 server.close(() => process.exit(code))
612 // A connection that never closes must not strand the process — the supervisor would otherwise
613 // wait out its full grace period on every OTA.
61455 const t = setTimeout(() => process.exit(code), 5000)
615 if (t.unref) t.unref()
56 stopping = (async () => {
57 for (const [name, fn] of bridge.steps) {
58 try { await fn() } catch (err) { log({ msg: 'shutdown step failed', step: name, error: err && err.message }) }
59 }
60 clearTimeout(t)
61 process.exit(code)
62 })()
63 return stopping
61664 }
61765 process.on('SIGINT', () => shutdown(0))
61866 process.on('SIGTERM', () => shutdown(0))
……@@ -620,34 +68,16 @@ async function main () {
62068 // OTA + control surface. Best-effort: if it cannot come up we keep federating (see lib/control.js).
62169 const { startControl } = await import('./lib/control.js')
62270 await startControl({
623 // ⛔ The operator's half of registration. `/ap/register` handles the HOLDER's side; minting the
624 // token that gates it is not something an HTTP caller should be able to assert, which is the same
625 // split the release path already makes.
626 onInvite: registrations ? (() => registrations.invite()) : null,
71 onInvite: bridge.onInvite,
62772 udsPath: process.env.AP_SOCK || join(config.dataDir, 'svc.sock'),
62873 name: process.env.PGH_APP_NAME || 'ap-bridge',
629 version: pkgVersion(),
630 status: () => ({ ok: true, actor: `https://${config.host}/ap/actor`, did: config.did, port: config.port }),
74 version,
75 status: bridge.status,
63176 // Exit 75 AFTER the RPC has answered — the supervisor's respawn signal. Close the server first
63277 // so the port is free for the replacement rather than racing it.
63378 onRelease: () => shutdown(75),
634 // The publisher is created lazily once the peer is fronted, so resolve it at CALL time rather
635 // than capturing a null at boot.
636 // What has the fediverse actually sent us? Without a way to ASK, an inbound store is
637 // indistinguishable from the 202-and-drop it replaced.
638 // `items` is the raw stored shape, kept for existing callers. `notifications` is the same thing
639 // projected into favela-peer's notification row (peer.js:646) so a view can MERGE it with the
640 // peer's own rows instead of building a second, parallel notification screen. Each carries
641 // source:'fediverse' — its `author` is an actor URI, not a feed key, and a view must not try to
642 // resolve one as the other.
643 onNotifications: async ({ limit = 50 } = {}) => {
644 const items = await inbound.list({ limit })
645 return { ok: true, items, notifications: toNotifications(items, { limit }) }
646 },
647 onReproject: async (opts) => {
648 if (!publisher) return { ok: false, error: 'publisher not ready (peer not fronted yet)' }
649 return { ok: true, ...(await publisher.reproject(opts)) }
650 },
79 onNotifications: bridge.onNotifications,
80 onReproject: bridge.onReproject,
65181 log
65282 })
65383}
lib/bridge.js +664 -0
……@@ -0,0 +1,664 @@
1// ap-bridge — the ActivityPub membrane, as a module. See DESIGN.md.
2//
3// M1 SCOPE (deliberate deviation, DESIGN.md §11): this process holds the actor's private key.
4// The target architecture keeps keys on an attested virtual-ap-device and signs over RPC, so the
5// bridge stays keyless — that is M2. Proving federation and proving keylessness are two separate
6// experiments; running them together means a failure has two possible causes.
7//
8// ⛔ WHY A MODULE. Until 0.10.15 this was index.js's main(): it bound the public and admin ports, the
9// admin recovery socket, a FeedHolder (Corestore + swarm) and an admin-identity Corestore, and shut
10// down by closing ONLY the public server and calling process.exit — process exit is what released
11// everything else. The favela gateway (hyper-social #80) runs this beside the atproto membrane in one
12// process, so stopping it has to free every port, socket and store lock it took. startApBridge() does
13// exactly what main() did and returns:
14// - `steps`: the ordered stop sequence as [name, fn] pairs, for the caller's bounded shutdown;
15// - `handlers`: the control verbs (lib/control.js bridgeHandlers), for whichever socket serves them;
16// - the callbacks startControl takes (status, onInvite, onNotifications, onReproject);
17// - `port`, `adminPort`, `bridgeKey`.
18// Nothing here exits the process or installs a signal handler. index.js is the standalone wrapper.
19
20import './bare-globals.js' // FIRST — deps touch TextEncoder at module load
21import fs from 'bare-fs'
22import process from 'bare-process'
23import { join } from 'bare-path'
24import crypto from 'bare-crypto'
25import http from 'bare-http1'
26import { createServer } from './server.js'
27import { actorDocFor } from './router.js'
28import { FollowerStore } from './followers.js'
29import { InboundStore } from './inbound-store.js'
30import { ObservationStore } from './observations.js'
31import { RemoteKeyResolver } from './remote-key.js'
32import { createDeliverer } from './deliver.js'
33import { LocalSigner } from './signer.js'
34import { DeliveryQueue, createQueuedDeliverer } from './queue.js'
35import { toNotifications } from './inbound-notifications.js'
36import { Users } from './users.js'
37import { PerUser } from './per-user.js'
38import { Registrations } from './registrations.js'
39import { FeedHolder } from './feed-holder.js'
40import { UserKeys } from './user-keys.js'
41import { keyId, actorId, buildActor } from './actor.js'
42import { RecordSource } from './records.js'
43import { createBlobFetcher } from './blobs.js'
44import { httpClient } from './https.js'
45import { Publisher } from './publisher.js'
46import { JsonState } from './state.js'
47import { bridgeHandlers } from './control.js'
48import { DidPeer } from 'atproto-did-peer'
49import { FavelaSource } from './favela-source.js'
50import { resolveDidVerifyKey } from './plc.js'
51import Hyperswarm from 'hyperswarm'
52import { openCorestore } from 'atproto-store/lib/corestore.js'
53import { discoveryTopic } from 'atproto-identity/lib/discovery.js'
54
55const defaultLog = (o) => console.log(JSON.stringify({ t: new Date().toISOString(), ...o }))
56
57/**
58 * @param {object} config assertUsable(loadConfig())'s shape
59 * @param {object} [opts]
60 * @param {Function} [opts.log]
61 * @param {Array} [opts.bootstrap] DHT bootstrap; tests pass [] to stay off the public DHT
62 * @param {string} [opts.version] for the `status` verb
63 */
64export async function startApBridge (config, { log = defaultLog, bootstrap = null, version = null } = {}) {
65 const privateKey = fs.readFileSync(config.privateKeyPath, 'utf8')
66 const publicKeyPem = fs.readFileSync(config.publicKeyPath, 'utf8')
67
68 const dataDir = config.dataDir || join(process.env.HOME || '.', '.config', 'ap-bridge')
69 const followers = new FollowerStore(join(dataDir, 'followers.json'))
70 // What the fediverse sends us — replies, mentions, likes, boosts. Until now these were verified,
71 // acked with 202 and discarded, so ap-bridge could be replied to and never knew.
72 // ⛔ THE CAP IS THE OPERATOR'S. It was fixed at 500 here, and for the fediverse an eviction is not a
73 // cache miss — there is no backlink index to re-ask, so it can be the only copy. See config.js.
74 const inbound = new InboundStore({ file: join(dataDir, 'inbound.json'), cap: config.inboundCap })
75 // ⛔ THE DURABLE HALF OF THE SAME ARRIVAL. `inbound` is a capped timeline for THIS bridge's pages;
76 // an observation is what a registered PEER collects and keeps. Both are written from one inbox POST
77 // and they have different lifetimes, which is why they are two stores rather than one with a flag —
78 // see hyper-social/docs/OBSERVED-INTERACTIONS.md §9.
79 const observations = new ObservationStore({ path: join(dataDir, 'observations.jsonl') })
80 const resolver = new RemoteKeyResolver()
81
82 // Everything this start opens, so stop() can close it. Filled in as each piece comes up.
83 const open = { didPeer: null, didSwarm: null, didStore: null, reg: null, adminServer: null, adminControl: null, browsers: null, browsersStore: null }
84
85 // ⛔ CUSTODY IS NAMED HERE, not implied by passing a key around. This is the primary actor and it
86 // is co-located with its key on this box, so LocalSigner is honest — signer.js: "a RemoteSigner to
87 // a peer ON THE SAME BOX buys nothing". Moving custody to the virtual-ap-device (DESIGN.md §4) is
88 // now a change to this one expression; nothing in deliver.js or http-signature.js moves.
89 //
90 // It also parses the PEM once instead of on every delivery, which is what signRequest did when
91 // handed a PEM string (rsa.js:184 re-parses whenever the key is a string).
92 const sendNow = createDeliverer({
93 signer: new LocalSigner({ keyId: keyId(config), privateKeyPem: privateKey }),
94 log
95 })
96
97 // ⛔ EVERY OUTBOUND ACTIVITY GOES THROUGH THE QUEUE, and is durable before the network is touched.
98 // Both callers benefit: Publisher's Creates, and router.js:287's `Accept` for an inbound Follow —
99 // which logged "will retry" while having no retry that survived a restart. A follow that arrives
100 // while this box is down is the main thing genuinely owed to somebody, and it is now owed durably.
101 // ⛔ THE QUEUE CARRIES A KEY ID, AND THIS TURNS IT BACK INTO A SIGNER. A signer is a live object
102 // and cannot survive a restart in a JSON file, so the durable entry records WHO signs and the
103 // signer is resolved per attempt. Without this every queued activity fell back to the process
104 // signer: a registered user's posts went out signed with the primary's key against the primary's
105 // key id, and Mastodon dropped every one of them after answering 202.
106 //
107 // ⚠ Assigned below, once PerUser exists. A null resolver means a queued entry with a signerId is
108 // HELD rather than sent under the wrong identity — the safe direction.
109 let signerByKeyId = null
110 const outbound = createQueuedDeliverer({
111 queue: new DeliveryQueue(join(dataDir, 'queue.json')),
112 deliver: sendNow,
113 resolveSigner: (keyId) => (signerByKeyId ? signerByKeyId(keyId) : null),
114 log
115 })
116 const deliver = outbound.deliver
117
118 // ⛔ Registration is OFF unless the operator turned it on — `config.registration` is 'open' or
119 // 'invite'. Absent means no registry at all and /ap/register 404s, so a bridge never starts
120 // handing out names because nobody said not to.
121 const registrations = config.registration
122 ? new Registrations({ path: join(dataDir, 'registrations.json'), policy: config.registration })
123 : null
124 if (registrations) log({ msg: 'registration', policy: registrations.policy })
125
126 // ⛔ Holds registered peers' feeds over the SWARM. A registered peer is on somebody else's
127 // machine, so the bridge holds their feed read-only by key — the same thing any follower does —
128 // rather than reaching for a unix socket that only exists when the peer is co-located.
129 const holder = registrations ? await new FeedHolder({ storage: join(dataDir, 'held'), bootstrap, log }).ready() : null
130 // ⛔ ITS OWN KEYPAIR PER USER. Mastodon indexes actors by publicKey.id, and sharing the primary's
131 // key made a registered user resolve AS the primary — a duplicate, not a new account. Distinct
132 // ids fix resolution; distinct key material is what makes the separation real, since a signature
133 // is what proves who acted.
134 const userKeys = registrations ? new UserKeys({ dir: join(dataDir, 'user-keys') }) : null
135 // ⛔ The control half of admission. registrations.js holds the registry and says in its own header
136 // that proving control "belongs at the route where the challenge lives" — this is that challenge.
137 // Without it an identity root, which is public, was accepted as proof of holding it.
138 const { Admission } = await import('./admission.js')
139 const admission = registrations ? new Admission() : null
140
141 let seq = 0
142 const ctx = {
143 registrations,
144 admission,
145 config,
146 publicKeyPem,
147 followers,
148 inbound,
149 observations,
150 resolvePublicKey: (kid) => resolver.resolve(kid),
151 deliver,
152 // Activity ids must be unique and stable enough that a receiver can dedup. Timestamp+counter
153 // is adequate for one actor; a real id scheme belongs with the durable queue.
154 newId: () => `${Date.now().toString(36)}-${(seq++).toString(36)}`,
155 log
156 }
157
158 // ★ THE KEYLESS READ SIDE. Front the DID exactly as atproto-bridge and favela do: replicate the
159 // peer's cores, read records, project to AS2 on demand. No key involved — atproto-did-peer
160 // refuses to front a repo whose signed commit doesn't verify against the DID's published key,
161 // which is what makes reading keylessly safe rather than merely convenient.
162 //
163 // Failure here is NON-FATAL: without a record source the outbox is empty but discovery, the
164 // inbox and Follow/Accept all still work. A peer that is briefly unreachable must not stop the
165 // actor existing.
166 // In this function's scope: the control surface's `reproject` (below) must reach it, and it was
167 // once declared inside the try-block that builds it — in scope for onHead, out of scope for
168 // everything after. node --check does not catch that; only calling reproject would have.
169 let publisher = null
170
171 /**
172 * ⛔ THE FAVELA SOURCE, when one is configured — records read from the peer's own markdown rather
173 * than from the atproto projection of them.
174 *
175 * `favela-markdown/spec/design.md`: both networks derive from the canonical record, neither from
176 * the other. Deriving AS2 from `record.text` gives the fediverse bsky's plain text, so a heading
177 * and a list arrive as paragraphs of literal bullet characters. This path renders the markdown.
178 *
179 * ⛔ AND IT NEEDS NO DID. No atproto identifier, no PDS, no MST, no swarm topic — just the peer's
180 * socket. That is what lets this bridge front an identity holding no atproto credential at all
181 * (identity.md §4.1), which the branch below cannot do.
182 *
183 * ⚠ EXCLUSIVE, and checked rather than assumed. Two sources would mean two sets of object ids for
184 * one author, and whichever ran second would look like a stranger reposting the first.
185 */
186 if (config.favelaSocket) {
187 if (config.did) {
188 throw new Error('ap-bridge: configure favelaSocket OR did, not both — two sources for one ' +
189 'actor produce two object ids per post, and the second reads as somebody else reposting')
190 }
191 ctx.records = new FavelaSource({
192 sock: config.favelaSocket,
193 host: config.host,
194 actorId: actorId(config),
195 followersId: `https://${config.host}/ap/followers`,
196 roomType: config.roomType || 'public'
197 })
198 log({ msg: 'favela source', sock: config.favelaSocket })
199 } else if (config.did) {
200 // GOTCHA (favela hit this): use atproto-store's openCorestore, NOT `new Corestore` from a
201 // separately-installed copy — RepoStore's `instanceof Corestore` fails across duplicated
202 // module copies and surfaces as "path... undefined".
203 const corestore = openCorestore(join(dataDir, 'store'))
204 const swarm = new Hyperswarm(bootstrap ? { bootstrap } : {})
205 open.didStore = corestore
206 open.didSwarm = swarm
207 try {
208 const verifyKey = await resolveDidVerifyKey(config.did)
209
210 // onHead fires on every newly VERIFIED head — the event-driven trigger for delivery. A
211 // polling timer here would be the anti-pattern this fleet keeps having to remove.
212 const peer = new DidPeer(corestore, {
213 did: config.did,
214 verifyKey,
215 // ⛔ A HEAD ADVANCE CARRIES PROFILE CHANGES, NOT JUST POSTS. onHead drained notes and
216 // nothing else, and refreshProfile was called exactly once at boot — so a changed display
217 // name, bio or avatar reached Mastodon only if the process happened to restart afterwards.
218 // Measured 2026-09-09: our actor served a bio prsm had never seen, last Update 21h earlier.
219 // refreshProfile is fingerprint-guarded, so this is a no-op when nothing changed.
220 onHead: () => {
221 if (!publisher) return
222 publisher.onHead()
223 publisher.refreshProfile().catch((err) => log({ msg: 'profile refresh failed', error: err.message }))
224 },
225 onError: (err) => log({ msg: 'peer error', error: err.message })
226 })
227 open.didPeer = peer
228
229 // One replicate per connection; the peer attaches to learn its cores (the keet model).
230 swarm.on('connection', (conn) => peer.attach(conn))
231
232 // ★ JOIN THE DID'S TOPIC. Attaching connections is useless without this — nothing ever
233 // connects, and the only symptom is an outbox that is permanently empty with no error.
234 // {client,server} BOTH: symmetric join is load-bearing for HOLE-PUNCHING, not just
235 // discovery — a client:false member never punches outbound so its NAT mapping never opens.
236 swarm.join(discoveryTopic(config.did), { client: true, server: true })
237 await swarm.flush()
238 await peer.ready({ timeout: 20000 })
239 ctx.records = new RecordSource({
240 peer,
241 did: config.did,
242 host: config.host,
243 actorId: actorId(config),
244 followersId: `https://${config.host}/ap/followers`
245 })
246 publisher = new Publisher({
247 records: ctx.records,
248 followers,
249 deliver,
250 actorId: actorId(config),
251 newId: ctx.newId,
252 log,
253 state: new JsonState(join(dataDir, 'publisher.json')),
254 backfill: config.backfill,
255 // Used ONLY by reproject(): can we actually serve this attachment? A /ap/blobs/<cid> URL is
256 // proxied from the PDS, so the honest check is whether the PDS has the bytes. One post's
257 // blobs were lost before the overlay migration; re-sending it would teach the receiver the
258 // same 404 again and burn the one edit that would fix it if the bytes ever came back.
259 blobResolves: async (url) => {
260 const m = /\/ap\/blobs\/([^/?#]+)$/.exec(String(url || ''))
261 if (!m) return true // not one of ours — not ours to judge
262 try { return !!(await ctx.fetchBlob(m[1])) } catch { return false }
263 },
264 buildActorDoc: async () => buildActor({
265 host: config.host,
266 username: config.username,
267 displayName: config.displayName,
268 summary: config.summary,
269 publicKeyPem,
270 alsoKnownAs: config.alsoKnownAs || [],
271 profile: await ctx.records.getProfile()
272 })
273 })
274 // Prime WITHOUT delivering: existing posts stay undelivered, only new ones go out.
275 await publisher.primeWatermark()
276 await publisher.refreshProfile()
277
278 // ★ Reconcile once at boot. The drain is otherwise triggered ONLY by a head advance, so a
279 // deploy that IMPROVES the projection (images, and later facets/quotes) would sit inert
280 // until the next unrelated post — the same trap the backfill hit. Cheap and self-limiting:
281 // one listNotes plus a fingerprint compare, and nothing is sent if nothing changed.
282 publisher.onHead()
283 // The primary holds posts for the same reason and needs the same kick when somebody follows.
284 ctx.onFollowed = () => { if (publisher) publisher.onHead() }
285
286 ctx.fetchBlob = createBlobFetcher({ did: config.did, pdsUrl: config.pdsUrl, doFetch: httpClient })
287
288 log({ msg: 'fronting DID (keyless read)', did: config.did })
289 } catch (err) {
290 log({ msg: 'could not front DID — outbox will be empty (discovery/inbox unaffected)', did: config.did, error: err.message })
291 }
292 }
293
294 // ⛔ MULTI-USER, AND STRICTLY ADDITIVE. `PerUser` and `Users` have existed and tested since 0.9.51
295 // with nothing constructing them (hyper-social #64 §8, #65 §7). This is that construction.
296 //
297 // The point is a second actor whose records come from FAVELA MARKDOWN rather than from the
298 // atproto projection — `PerUser.records()` builds a FavelaSource from `user.favelaSocket`, which
299 // needs no DID at all.
300 //
301 // ⛔ THE INCUMBENT DOES NOT MOVE. `/ap/actor` and every already-federated
302 // `/ap/objects/<rkey>` must stay byte-identical: prsm.space follows this actor and holds 20 of
303 // its objects, and an actor that changes its id is a stranger to every server that already knows
304 // it. The primary keeps the FLAT paths and deliberately inherits the process-wide instances; new
305 // users live under `/ap/users/<name>/`. A test pins that a single-actor registry produces
306 // identical ids to having no registry at all.
307 //
308 // ⚠ AND SWITCHING THE INCUMBENT'S SOURCE IS NOT AN OPTION, which is why this is additive rather
309 // than a config flip. Both sources mint `objectIdFor({host, rkey})`, but the DID path's rkey is
310 // an atproto TID and FavelaSource's is `row.name` — the favela record's own name. Same content,
311 // different ids. Repointing the live actor would orphan all 20 federated objects and read as a
312 // deletion followed by a repost.
313 //
314 // No `users` in config = no registry = today's behaviour exactly, by the router's own fallback.
315 // ⛔ A REGISTRY IS NEEDED WHENEVER REGISTRATION IS ON, not only when users are listed in config.
316 // This bridge is env-configured with a single AP_USERNAME and no users array — so gating the
317 // registry on `config.users` would have left registration enabled and nothing ever admitted: a
318 // name claimed, a 200 returned, and no actor.
319 //
320 // With no list, the registry starts as the configured primary alone. `Users` with one entry
321 // produces ids identical to having no registry at all, which is pinned by a test — so this stays
322 // invisible to the incumbent.
323 const userEntries = Array.isArray(config.users) && config.users.length
324 ? config.users
325 : (registrations ? [{ username: config.username, primary: true, favelaSocket: config.favelaSocket || null }] : null)
326
327 if (userEntries) {
328 const users = new Users(userEntries, config.host)
329 const perUser = new PerUser({
330 users,
331 dataDir,
332 // ⛔ The overlay's Files, so a held user's avatar resolves from THEIR bytes rather than being
333 // looked for on the primary's PDS, where it is not and never was.
334 files: holder ? holder.files : null,
335 readFile: async (p) => fs.readFileSync(p),
336 log,
337 // ⛔ AFTER ctx.records is built. The primary INHERITS these; constructing the
338 // registry earlier handed it a null source, which is the cross-wiring PerUser exists
339 // to prevent — pointed the wrong way.
340 // ⛔ THESE KEY NAMES ARE THE CONTRACT. PerUser reads shared.records / shared.followerStore /
341 // shared.fetchBlob / shared.signer. Passing `followers` instead of `followerStore` — which is
342 // what shipped — made `_inherits` never match, so the incumbent and every registered user
343 // read the SAME follower list and each per-user publisher found no recipients. Nothing threw.
344 shared: { records: ctx.records, followerStore: followers, fetchBlob: ctx.fetchBlob, signer: null }
345 })
346 ctx.users = users
347 Object.assign(ctx, perUser.resolvers())
348
349 // Resolve a queued entry's key id back to that user's signer. Scanning the registry is right
350 // here: the key id IS the actor id plus '#main-key', so this cannot match somebody else by
351 // accident, and there are a handful of users rather than thousands.
352 signerByKeyId = (keyId) => {
353 for (const u of users.all()) {
354 const s = perUser.signer(u)
355 if (s && s.keyId === keyId) return s
356 }
357 return null
358 }
359
360 /**
361 * A registration becomes a served actor: hold the feed, add the user.
362 *
363 * ⛔ ADDITIVE ONLY. `Users.add` never makes a registrant primary, so the flat paths — and every
364 * one of the incumbent's already-federated object ids — stay exactly where they are.
365 */
366 const admit = async ({ username, root, feedKey }) => {
367 const peer = feedKey && holder ? await holder.hold(feedKey) : null
368 // Minted on first admission, read from disk on every one after — a key that changed would
369 // break every remote server that had cached it.
370 const keys = userKeys ? await userKeys.forUser(username) : null
371 const user = users.add({ username, root, feedKey, peer, keys })
372 log({ msg: 'admitted', username, held: !!peer, keyed: !!keys })
373 // ⛔ AND START PUBLISHING. Without this a registered user's posts reach the OUTBOX and nobody
374 // else: the outbox is a pull surface and no fediverse server backfills one. Every post
375 // ashatron made was visible at /outbox and delivered to zero followers, because the per-user
376 // Publisher was built lazily inside refreshUser and the only method ever called on it was
377 // refreshProfile. Nothing primed a watermark and nothing ever called onHead.
378 if (peer) await startPublishing(user).catch((err) => log({ msg: 'publisher start failed', username, error: err.message }))
379 }
380 ctx.keysFor = (u) => (u && u.keys) || null
381
382 /**
383 * A publisher per registered user, so a profile change reaches their followers.
384 *
385 * ⛔ Signed with THEIR key. deliver() takes a per-delivery signer (0.9.52), so this hands each
386 * user's own signer down — the primary's key against their key id would be rejected by every
387 * receiver, and the actor would resolve while nothing it sent was believed.
388 */
389 const publishers = new Map()
390 const publisherFor = (u) => {
391 if (publishers.has(u.username)) return publishers.get(u.username)
392 const ids = users.ids(u)
393 const signer = perUser.signer(u)
394 const p = new Publisher({
395 records: perUser.records(u),
396 // ⛔ The SAME store instance the router writes an inbound Follow into — PerUser memoises per
397 // username, so a follow accepted at /ap/users/<n>/inbox is a recipient here immediately.
398 followers: perUser.followers(u),
399 deliver: (d) => outbound.deliver({ ...d, signer }),
400 actorId: ids.actor,
401 newId: () => `${Date.now().toString(36)}-${(seq++).toString(36)}`,
402 log,
403 state: new JsonState(join(dataDir, 'publisher-' + u.username + '.json')),
404 // ⚠ Inherited from the bridge's own setting deliberately. A registered user's history is
405 // one-shot and self-disarming exactly as the primary's is (see primeWatermark): those rkeys
406 // enter the delivered-set as they go out, so a restart repeats nothing.
407 backfill: config.backfill,
408 buildActorDoc: async () => actorDocFor(ctx, u)
409 })
410 publishers.set(u.username, p)
411 return p
412 }
413
414 /**
415 * Give a held user a publisher, and an EVENT to drive it.
416 *
417 * ⛔ The primary's trigger is DidPeer's `onHead`. A held favela feed had no equivalent, so this
418 * is it: the feed's writer cores, re-hooked, plus a peer returning. Never a poll — the standing
419 * rule is that events set the latency floor and a timer would only be expressing absence, which
420 * a `connection` event already does.
421 */
422 const startPublishing = async (u) => {
423 const p = publisherFor(u)
424 // ⛔ PRIME ON EVERY ADVANCE, not once at start. A held feed's log replicates asynchronously,
425 // so a prime at boot routinely sees an empty listing — and 0.9.76 correctly declined to record
426 // that, but nothing ever called it again. The publisher stayed unprimed, the backfill never
427 // ran, and the delivered-set stayed empty with the outbox already serving 16 posts. Measured:
428 // primed=true/delivered=0 across two deploys.
429 //
430 // primeWatermark is cheap and returns early the moment it has genuinely primed, so running it
431 // per advance costs one listNotes on a feed that has already settled and is the only thing
432 // that makes "try again when the log arrives" true.
433 // ⛔ refreshProfile BELONGS IN THE ADVANCE, not before it. It used to run once here and never
434 // again, so a registered user could edit their name, bio or avatar and no follower would ever
435 // be told — posts kept flowing, because those ride onHead, which made the gap look like a
436 // profile-specific bug rather than a missing trigger. Same defect the primary had above.
437 const advance = async () => {
438 await p.primeWatermark()
439 p.onHead()
440 await p.refreshProfile()
441 }
442 // watch() fires once immediately, so this still covers the already-settled case at boot.
443 if (holder && u.feedKey) holder.watch(u.feedKey, advance)
444 else await advance()
445 }
446
447 ctx.refreshUser = async (username, force = false) => {
448 const u = users.get(username)
449 if (!u || !u.peer) return { ok: false, error: 'not a held user' }
450 await publisherFor(u).refreshProfile({ force: !!force })
451 return { ok: true, forced: !!force }
452 }
453 ctx.publishUser = async (username) => {
454 const u = users.get(username)
455 if (!u || !u.peer) return { ok: false, error: 'not a held user' }
456 publisherFor(u).onHead()
457 return { ok: true }
458 }
459 // A follow accepted at /ap/users/<n>/inbox drains THAT user's held posts immediately.
460 ctx.drainFor = (u) => { const p = u && publishers.get(u.username); if (p) p.onHead() }
461 ctx.onRegistered = admit
462
463 // ⛔ Releasing must STOP HOLDING. A bridge that kept replicating a feed whose owner had left
464 // would go on serving their posts under a name they no longer hold — which is the reuse hazard
465 // the generation counter exists to prevent, arriving by a different door.
466 ctx.onReleased = async (username) => {
467 const u = users.get(username)
468 if (u && u.feedKey && holder) await holder.release(u.feedKey)
469 if (userKeys) await userKeys.forget(username) // a burned name must not keep signing
470 users.remove(username)
471 log({ msg: 'released', username })
472 }
473
474 // ⛔ THE SWARM DOOR onto the same registration flow. A peer has no outbound HTTP — favela-peer
475 // has none at all — and both sides are already swarm members, so this is how a handset asks for a
476 // name without anybody shelling into anything. The rules are `admit.js`; this is transport.
477 //
478 // ⚠ On the FeedHolder's swarm, not a second one: it already runs one to hold registered peers'
479 // feeds, and two swarms in a process is two NAT mappings and two sets of connections for no gain.
480 if (registrations && holder && holder.swarm) {
481 try {
482 const { serveRegistration } = await import('./register-rpc.js')
483 const reg = serveRegistration({ swarm: holder.swarm, registrations, admission, observations, config, onRegistered: ctx.onRegistered, onReleased: ctx.onReleased, log })
484 await reg.ready()
485 open.reg = reg
486 // ⛔ PRINTED, because it is what a peer needs in order to find this bridge at all. A key that
487 // exists and is written nowhere is the favela-android device-key mistake one layer over.
488 log({ msg: 'registration open over the swarm', bridgeKey: reg.key })
489 } catch (err) {
490 // Non-fatal: the HTTP door still works, and a bridge that federates is worth more than one
491 // that refuses to boot because a second door would not open.
492 log({ msg: 'swarm registration unavailable', error: (err && err.message) || String(err) })
493 }
494 }
495
496 // ⛔ AND ON BOOT, or a restart silently unserves everyone who registered. The registry is
497 // durable; what was held is not, so it has to be re-held from what the registry remembers.
498 if (registrations) {
499 for (const reg of await registrations.list()) {
500 try { await admit(reg) } catch (err) { log({ msg: 'could not re-admit', username: reg.username, error: err.message }) }
501 }
502 }
503 log({ msg: 'multi-user', count: userEntries.length, primary: users.primary && users.primary.username })
504 }
505
506 const server = createServer(ctx)
507
508 await new Promise((resolve, reject) => {
509 server.on('error', reject)
510 server.listen(config.port, config.bind, resolve)
511 })
512 const port = server.address().port
513
514 // ⛔ THE OPERATOR SURFACE IS A SECOND LISTENER, ALWAYS LOOPBACK, AND OFF UNLESS ASKED FOR. It must
515 // not share the public port: that one is proxied to the internet, and this one has NO authentication
516 // in front of it — reach it over SSH. The same decision favela-surface states for itself: "exposing
517 // it must be a decision, never a default." It mints nothing; every action on it is a flow that
518 // already exists (invite, list, release).
519 //
520 // ⚠ `adminPort: 0` asks for any free port (tests); null/undefined leaves the surface off.
521 let adminPort = null
522 if (config.adminPort != null && config.adminPort !== false) {
523 const { serveAdmin } = await import('./admin-surface.js')
524 const { Sessions } = await import('./admin-auth.js')
525 const { parseForm } = await import('./admin-serve.js')
526 const { Browsers } = await import('favela-peer/lib/browsers.js')
527
528 // ⛔ A TOKEN PER BOOT, HELD ONLY IN MEMORY AND ONLY UNTIL CLAIMED. Writing it to disk would make a
529 // standing secret out of something needed exactly once; regenerating it every start costs nothing
530 // because it is meaningless the moment the bridge is claimed.
531 const bootstrapToken = crypto.randomBytes(16).toString('hex')
532 // GOTCHA, same as the record store above: openCorestore, never `new Corestore` from a separately
533 // installed copy — `instanceof Corestore` fails across duplicated module copies.
534 open.browsersStore = openCorestore(join(dataDir, 'admin-identity'))
535 const browsers = new Browsers(open.browsersStore, { bootstrap: bootstrapToken })
536 open.browsers = browsers
537 await browsers.ready()
538
539 // ⛔ PRINTED ONLY WHILE UNCLAIMED, because after that it grants nothing and logging a dead secret
540 // every boot teaches an operator to ignore the line that matters.
541 if (await browsers.unclaimed()) {
542 log({ msg: 'admin surface UNCLAIMED — claim it with this bootstrap token, then it closes for good', bootstrap: bootstrapToken })
543 }
544
545 const adminCtx = { browsers, sessions: new Sessions(), registrations, inbound, config, log, root: new URL('..', import.meta.url).pathname }
546 const adminServer = http.createServer((req, res) => {
547 parseForm(req).then((form) => serveAdmin(
548 {
549 method: req.method,
550 path: (req.url || '/').split('?')[0],
551 // ⚠ Parsed here rather than in the routes, so a handler never sees a raw URL. The timeline
552 // pages on `offset`, and a page control that reads its own query string is the one place a
553 // surface starts re-implementing a URL parser.
554 query: Object.fromEntries(new URLSearchParams((req.url || '').split('?')[1] || '')),
555 form,
556 headers: req.headers || {},
557 // ⛔ FROM THE SOCKET. The recovery route gates on being on this machine, and a header would
558 // let a caller assert that rather than demonstrate it.
559 remoteAddress: (req.socket && req.socket.remoteAddress) || null
560 }, adminCtx))
561 .then((out) => { res.writeHead(out.status, out.headers); res.end(out.body) })
562 .catch((err) => { res.writeHead(500, { 'content-type': 'text/plain' }); res.end(String((err && err.message) || err)) })
563 })
564 await new Promise((resolve, reject) => {
565 adminServer.on('error', reject)
566 // ⛔ ITS OWN BIND, never config.bind — this deployment sets that to 0.0.0.0 for the fediverse
567 // listener, and inheriting it would publish the operator surface as a side effect. Defaults to
568 // loopback; widening it is deliberate, and safe because the surface is authenticated.
569 adminServer.listen(config.adminPort, config.adminBind || '127.0.0.1', resolve)
570 })
571 open.adminServer = adminServer
572 adminPort = adminServer.address().port
573 // ⛔ STILL LOOPBACK EVEN THOUGH IT NOW HAS AUTH, and for a reason that is not caution:
574 // static/anproto-client.js hashes with `crypto.subtle`, which browsers expose only in a SECURE
575 // CONTEXT ("works on localhost + HTTPS" — its own comment). Bound to a public port over plain
576 // HTTP this would serve a login page whose crypto is undefined in the browser that loaded it.
577 // Publish it through the proxy that already terminates TLS for this host.
578 log({ msg: 'admin surface (authenticated)', bind: (config.adminBind || '127.0.0.1') + ':' + adminPort })
579
580 // ⛔ THE LOCAL CONTROL SOCKET — the door that cannot be locked out. Losing every operator browser
581 // otherwise shuts the bridge for good: the bootstrap token gates the FIRST claim only and the
582 // instance never reopens. Owner-gated by the filesystem, which is the fleet's pattern for exactly
583 // this (pgv3 gates on ctx.transport; hpp's UDS surface is "TRUSTED WITHOUT the writer-allowlist"
584 // because only a local process that can open the path connects).
585 try {
586 const { serveAdminControl } = await import('./admin-control.js')
587 const control = await serveAdminControl({ browsers, uds: join(dataDir, 'admin.sock'), log })
588 open.adminControl = control
589 log({ msg: 'admin control socket (operator recovery)', uds: control.uds })
590 } catch (err) {
591 // ⚠ NOT FATAL, and said out loud. A bridge that refuses to serve the fediverse because its
592 // recovery socket would not bind has turned a recovery aid into an outage.
593 log({ msg: 'admin control socket unavailable — recovery from the CLI will not work', error: (err && err.message) || String(err) })
594 }
595 }
596
597 // ★ The first pass picks up whatever the last run left owed — which is the entire point of the
598 // queue being durable. Started after listen so a backlog drain never delays the port opening.
599 outbound.start()
600
601 log({
602 msg: 'ap-bridge listening',
603 bind: `${config.bind}:${port}`,
604 actor: `https://${config.host}/ap/actor`,
605 handle: `@${config.username}@${config.host}`,
606 followers: await followers.count(),
607 queued: await outbound.pending()
608 })
609
610 // The callbacks lib/control.js takes, resolved at CALL time: the publisher is created lazily once
611 // the peer is fronted, so capturing it at boot would capture a null.
612 const callbacks = {
613 status: () => ({ ok: true, actor: `https://${config.host}/ap/actor`, did: config.did, port }),
614 // ⛔ The operator's half of registration. `/ap/register` handles the HOLDER's side; minting the
615 // token that gates it is not something an HTTP caller should be able to assert, which is the same
616 // split the release path already makes.
617 onInvite: registrations ? (() => registrations.invite()) : null,
618 // What has the fediverse actually sent us? Without a way to ASK, an inbound store is
619 // indistinguishable from the 202-and-drop it replaced.
620 // `items` is the raw stored shape, kept for existing callers. `notifications` is the same thing
621 // projected into favela-peer's notification row (peer.js:646) so a view can MERGE it with the
622 // peer's own rows instead of building a second, parallel notification screen. Each carries
623 // source:'fediverse' — its `author` is an actor URI, not a feed key, and a view must not try to
624 // resolve one as the other.
625 onNotifications: async ({ limit = 50 } = {}) => {
626 const items = await inbound.list({ limit })
627 return { ok: true, items, notifications: toNotifications(items, { limit }) }
628 },
629 onReproject: async (opts) => {
630 if (!publisher) return { ok: false, error: 'publisher not ready (peer not fronted yet)' }
631 return { ok: true, ...(await publisher.reproject(opts)) }
632 }
633 }
634
635 const closeServer = (s) => new Promise((resolve) => s.close(() => resolve()))
636
637 return {
638 port,
639 adminPort,
640 bridgeKey: open.reg ? open.reg.key : null,
641 ...callbacks,
642 handlers: bridgeHandlers({ name: 'ap-bridge', version, ...callbacks }),
643 // The stop sequence. The delivery drain first — never the queue itself: anything still owed is on
644 // disk and is picked up by the next boot, which is the difference that module was built to make.
645 // Then the listeners, then the swarms and stores that hold locks.
646 steps: [
647 ['outbound', () => outbound.stop()],
648 ['server', () => closeServer(server)],
649 ['admin-server', () => (open.adminServer ? closeServer(open.adminServer) : null)],
650 ['admin-control', () => (open.adminControl ? open.adminControl.close() : null)],
651 ['registration', () => (open.reg ? open.reg.close() : null)],
652 ['holder', () => (holder ? holder.close() : null)],
653 ['did-peer', () => (open.didPeer ? open.didPeer.close() : null)],
654 ['did-swarm', () => (open.didSwarm ? open.didSwarm.destroy() : null)],
655 ['did-store', () => (open.didStore ? open.didStore.close() : null)],
656 ['admin-identity', async () => {
657 if (open.browsers) await open.browsers.close()
658 if (open.browsersStore) await open.browsersStore.close()
659 }]
660 ]
661 }
662}
663
664export default startApBridge
lib/control.js +50 -36
……@@ -77,6 +77,54 @@ export function maintainSidecarRegistration ({ udsPath, name, runtime = 'bare',
7777 return () => { stopped = true; if (timer) clearInterval(timer) }
7878}
7979
80/**
81 * The control verbs as a plain map, separate from the socket that serves them.
82 *
83 * ⛔ The favela gateway (hyper-social #80) serves these on ITS socket beside the membrane's verbs, and
84 * must not bind ap-bridge's svc.sock or register its name: a release of ap-bridge pushed to a path the
85 * gateway served would restart the wrong process. startControl below registers this same map.
86 *
87 * `release-ready` is not here: whoever owns the process owns its exit.
88 */
89export function bridgeHandlers ({ name = 'ap-bridge', version = null, status = null, onReproject = null, onNotifications = null, onInvite = null } = {}) {
90 const handlers = {
91 status: async () => (status ? status() : { ok: true, name, version })
92 }
93 // Read what the fediverse sent us. An inbound store nothing can query is indistinguishable from
94 // the 202-and-discard it replaced, so the read verb ships with the write path, not after it.
95 if (onNotifications) {
96 handlers.notifications = async (params = {}) => onNotifications({
97 limit: Math.min(Number(params.limit) || 50, 200)
98 })
99 }
100 // ⛔ MINTING AN INVITE IS AN OPERATOR ACT, so it lives on the owner-gated unix socket and nowhere
101 // else. `registrations.js` has had `invite()`, single-use hashes and an `invite` policy since it
102 // was written, and NOTHING has ever called it — so a bridge could be switched to invite-only and
103 // then no invite could be created for it. The token type existed; the door did not.
104 //
105 // ⚠ The token is returned ONCE and stored nowhere: the log holds only its hash, exactly as the
106 // page that displays one already says. A control surface that could list tokens would defeat
107 // that, so this mints and forgets.
108 if (onInvite) handlers.invite = async () => onInvite()
109 // reproject — the operator verb for "the projection improved, tell the receivers".
110 //
111 // Needed because the publisher's drain only runs on onHead(), so an improvement reaches
112 // already-delivered posts only when some unrelated FUTURE post advances the head. #26 recorded
113 // that trap twice ("event-driven quietly meant never happens"); an event-driven system needs a
114 // reconciliation it can be ASKED to run.
115 if (onReproject) {
116 handlers.reproject = async (params = {}) => onReproject({
117 rkey: params.rkey || null,
118 force: !!params.force,
119 // Default ON: never re-send a post whose media cannot be served. Overridable, deliberately,
120 // because an operator staring at a specific case may know better than the probe.
121 verify: params.verify !== false,
122 limit: Number(params.limit) || 50
123 })
124 }
125 return handlers
126}
127
80128// Stand up the control socket. Returns { svc, udsPath } or null when unavailable — the caller keeps
81129// serving either way.
82130//
……@@ -89,42 +137,8 @@ export async function startControl ({ udsPath, name, version = null, status = nu
89137 // decided no live instance owns this app, so an existing file is orphaned.
90138 try { if (fs.existsSync(udsPath)) fs.unlinkSync(udsPath) } catch {}
91139 const svc = new Service({ name, uds: udsPath })
92 svc.handle('status', async () => (status ? status() : { ok: true, name, version }))
93 // reproject — the operator verb for "the projection improved, tell the receivers".
94 //
95 // Needed because the publisher's drain only runs on onHead(), so an improvement reaches
96 // already-delivered posts only when some unrelated FUTURE post advances the head. #26 recorded
97 // that trap twice ("event-driven quietly meant never happens"); an event-driven system needs a
98 // reconciliation it can be ASKED to run.
99 // Read what the fediverse sent us. An inbound store nothing can query is indistinguishable from
100 // the 202-and-discard it replaced, so the read verb ships with the write path, not after it.
101 if (onNotifications) {
102 svc.handle('notifications', async (ctx, params = {}) => onNotifications({
103 limit: Math.min(Number(params.limit) || 50, 200)
104 }))
105 }
106
107 // ⛔ MINTING AN INVITE IS AN OPERATOR ACT, so it lives on the owner-gated unix socket and nowhere
108 // else. `registrations.js` has had `invite()`, single-use hashes and an `invite` policy since it
109 // was written, and NOTHING has ever called it — so a bridge could be switched to invite-only and
110 // then no invite could be created for it. The token type existed; the door did not.
111 //
112 // ⚠ The token is returned ONCE and stored nowhere: the log holds only its hash, exactly as the
113 // page that displays one already says. A control surface that could list tokens would defeat
114 // that, so this mints and forgets.
115 if (onInvite) {
116 svc.handle('invite', async () => onInvite())
117 }
118
119 if (onReproject) {
120 svc.handle('reproject', async (ctx, params = {}) => onReproject({
121 rkey: params.rkey || null,
122 force: !!params.force,
123 // Default ON: never re-send a post whose media cannot be served. Overridable, deliberately,
124 // because an operator staring at a specific case may know better than the probe.
125 verify: params.verify !== false,
126 limit: Number(params.limit) || 50
127 }))
140 for (const [verb, fn] of Object.entries(bridgeHandlers({ name, version, status, onReproject, onNotifications, onInvite }))) {
141 svc.handle(verb, async (ctx, params) => fn(params))
128142 }
129143 if (onRelease && !svc.hasHandler?.('release-ready')) {
130144 svc.handle('release-ready', async (ctx, params = {}) => {
lib/feed-holder.js +5 -2
……@@ -33,12 +33,14 @@ import { FavelaFeed } from './favela-feed.js'
3333const hex = (k) => (typeof k === 'string' ? k : b4a.toString(k, 'hex'))
3434
3535export class FeedHolder {
36 constructor ({ storage, swarm = null, log = () => {} }) {
36 constructor ({ storage, swarm = null, bootstrap = null, log = () => {} }) {
3737 if (!storage) throw new Error('FeedHolder: a storage directory is required')
3838 this.storage = storage
3939 this.corestore = new Corestore(join(storage, 'store'))
4040 this.swarm = swarm
4141 this._ownSwarm = !swarm
42 // Tests pass [] to keep the swarm off the public DHT; production passes nothing.
43 this._bootstrap = bootstrap
4244 this.overlay = null
4345 this.files = null
4446 this._held = new Map() // feed key hex -> FavelaFeed
……@@ -136,7 +138,8 @@ export class FeedHolder {
136138 // swarm is how peers reach this bridge to ask for a name — an address that changes on every
137139 // restart is not an address. See lib/swarm-keypair.js.
138140 const { loadOrCreateSwarmKeyPair } = await import('./swarm-keypair.js')
139 this.swarm = new Hyperswarm({ keyPair: loadOrCreateSwarmKeyPair(join(this.storage, 'swarm-key')) })
141 const keyPair = loadOrCreateSwarmKeyPair(join(this.storage, 'swarm-key'))
142 this.swarm = new Hyperswarm(this._bootstrap ? { keyPair, bootstrap: this._bootstrap } : { keyPair })
140143 }
141144 // ⛔ Every connection replicates every held feed, and the overlay attaches to all of them. One
142145 // connection carries all of it — hypercore multiplexes, and piping each feed separately onto one
package-lock.json +2 -2
……@@ -1,12 +1,12 @@
11{
22 "name": "ap-bridge",
3 "version": "0.10.14",
3 "version": "0.10.15",
44 "lockfileVersion": 3,
55 "requires": true,
66 "packages": {
77 "": {
88 "name": "ap-bridge",
9 "version": "0.10.14",
9 "version": "0.10.15",
1010 "license": "MIT",
1111 "dependencies": {
1212 "atproto-did-peer": "file:../atproto-did-peer",
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "ap-bridge",
3 "version": "0.10.14",
3 "version": "0.10.15",
44 "description": "ActivityPub membrane \u2014 a second projection of the self-custody atproto peer",
55 "type": "module",
66 "main": "index.js",
test/actor.test.js +3 -2
……@@ -152,7 +152,8 @@ test('⛔ favelaSocket and did are mutually exclusive', async (t) => {
152152 const cfg = loadConfig({ AP_FAVELA_SOCKET: '/tmp/p.sock', AP_DID: 'did:plc:x', AP_HOST: 'h' })
153153 t.is(cfg.favelaSocket, '/tmp/p.sock')
154154 t.is(cfg.did, 'did:plc:x')
155 // index.js throws on this pair; the config layer's job is only to carry both so it can be seen.
156 const src = (await import('bare-fs')).default.readFileSync(new URL('../index.js', import.meta.url).pathname, 'utf8')
155 // lib/bridge.js (index.js's main() until 0.10.15) throws on this pair; the config layer's job is
156 // only to carry both so it can be seen.
157 const src = (await import('bare-fs')).default.readFileSync(new URL('../lib/bridge.js', import.meta.url).pathname, 'utf8')
157158 t.ok(/favelaSocket OR did, not both/.test(src), 'and the refusal names the reason')
158159})
test/admin-cli.test.js +4 -2
……@@ -87,8 +87,10 @@ test('★★★ the entry dispatches the subcommand before booting the daemon',
8787 // ⛔ Running the CLI must not also bind the fediverse listener of a bridge that is already running.
8888 const src = fs.readFileSync(new URL('../index.js', import.meta.url).pathname, 'utf8')
8989 const dispatch = src.indexOf("process.argv[2] === 'admin'")
90 const listen = src.indexOf('server.listen(')
90 // Everything that binds lives in lib/bridge.js since 0.10.15; the entry reaches it through one call.
91 const start = src.indexOf('startApBridge(config')
9192 t.ok(dispatch > 0, 'the entry knows the subcommand')
92 t.ok(dispatch < listen, 'and handles it before anything binds')
93 t.ok(start > 0 && dispatch < start, 'and handles it before anything binds')
94 t.absent(/\.listen\(/.test(src), 'and the entry itself binds nothing')
9395 t.ok(/process\.exit\(await runAdminCli/.test(src), 'then exits rather than falling through')
9496})
test/collect.test.js +2 -2
……@@ -205,8 +205,8 @@ test('⛔ a collect proof cannot be replayed as a release', async (t) => {
205205// directly. If index.js forgets to, `admitCollect` returns 404 'not found' — the daemon boots, the
206206// verb answers, the tests all pass, and every peer is told for ever that it has nothing. That is the
207207// exact shape of the `held:false` defect registrations.js records, one door over.
208test('⛔ index.js hands the observation store to the swarm door', async (t) => {
209 const src = fs.readFileSync(new URL('../index.js', import.meta.url), 'utf8')
208test('⛔ the boot wiring (lib/bridge.js since 0.10.15) hands the observation store to the swarm door', async (t) => {
209 const src = fs.readFileSync(new URL('../lib/bridge.js', import.meta.url), 'utf8')
210210 const call = /serveRegistration\(\{([^}]*)\}/.exec(src)
211211 t.ok(call, 'the swarm door is opened at boot')
212212 t.ok(/\bobservations\b/.test(call[1]), 'and it is given the buffer it is meant to serve')
test/start-bridge.test.js +96 -0
……@@ -0,0 +1,96 @@
1// ap-bridge as a module: started and stopped inside one process, with no exit.
2//
3// ⛔ WHY. The favela gateway (hyper-social #80) runs ap-bridge beside the atproto membrane in ONE
4// process. index.js's main() bound two ports, an admin socket, a FeedHolder (Corestore + swarm) and an
5// admin-identity Corestore, then shut down by closing only the public server and calling
6// process.exit — process exit was what released everything else. A role in a shared process cannot
7// rely on that: stopping it has to free every port, socket and store lock it took.
8
9import test from 'brittle'
10import fs from 'bare-fs'
11import os from 'bare-os'
12import path from 'bare-path'
13import http from 'bare-http1'
14import { startApBridge } from '../lib/bridge.js'
15import { TEST_PRIVATE_KEY, TEST_PUBLIC_KEY } from './fixtures/keys.js'
16
17function dataDir () {
18 // ⚠ SHORT: admin.sock lives in here, and a unix socket path past ~107 bytes does not bind.
19 const d = fs.mkdtempSync(path.join('/tmp', 'apb-'))
20 fs.writeFileSync(path.join(d, 'actor.key'), TEST_PRIVATE_KEY)
21 fs.writeFileSync(path.join(d, 'actor.pub'), TEST_PUBLIC_KEY)
22 return d
23}
24
25function configFor (dir, { port = 0, adminPort = 0 } = {}) {
26 return {
27 host: 'ap.test',
28 username: 'p2p',
29 bind: '127.0.0.1',
30 port,
31 adminPort,
32 adminBind: '127.0.0.1',
33 dataDir: dir,
34 privateKeyPath: path.join(dir, 'actor.key'),
35 publicKeyPath: path.join(dir, 'actor.pub'),
36 registration: 'open',
37 backfill: 0,
38 did: null,
39 favelaSocket: null
40 }
41}
42
43function get (port, p) {
44 return new Promise((resolve, reject) => {
45 const req = http.request({ host: '127.0.0.1', port, path: p, method: 'GET' }, (res) => {
46 let body = ''
47 res.on('data', (c) => { body += c })
48 res.on('end', () => resolve({ status: res.statusCode, body }))
49 })
50 req.on('error', reject)
51 req.end()
52 })
53}
54
55async function stop (b) { for (const [, fn] of b.steps) await fn() }
56
57test('★ start → serve → stop → start again on the same ports and data dir, in one process', async (t) => {
58 const dir = dataDir()
59 const one = await startApBridge(configFor(dir), { bootstrap: [], log: () => {} })
60 t.ok(one.port > 0 && one.adminPort > 0, 'bound the public and the admin port')
61 t.is((await get(one.port, '/ap/actor')).status, 200, 'the actor resolves')
62 t.ok(fs.existsSync(path.join(dir, 'admin.sock')), 'the admin recovery socket is up')
63 const key = one.bridgeKey
64 t.ok(key, 'the registration door is open over the swarm')
65
66 await stop(one)
67 t.absent(fs.existsSync(path.join(dir, 'admin.sock')) && await socketAnswers(path.join(dir, 'admin.sock')), 'the admin socket no longer answers')
68
69 // Same ports, same data dir: the listeners are released and the stores' locks are let go.
70 const two = await startApBridge(configFor(dir, { port: one.port, adminPort: one.adminPort }), { bootstrap: [], log: () => {} })
71 t.teardown(() => stop(two))
72 t.is(two.port, one.port, 'the public port binds again')
73 t.is(two.adminPort, one.adminPort, 'and the admin port')
74 t.is((await get(two.port, '/ap/actor')).status, 200, 'and serves')
75 t.is(two.bridgeKey, key, '⛔ the published bridgeKey is the same — registrants find this bridge by it')
76})
77
78test('the stop steps are named, and the control verbs are plain handlers', async (t) => {
79 const b = await startApBridge(configFor(dataDir()), { bootstrap: [], log: () => {} })
80 t.teardown(() => stop(b))
81 const names = b.steps.map(([n]) => n)
82 for (const n of ['server', 'admin-server', 'admin-control', 'holder']) t.ok(names.includes(n), 'stops ' + n)
83 t.is(names[0], 'outbound', 'the delivery drain stops first; what is owed is already on disk')
84 for (const verb of ['status', 'invite', 'notifications', 'reproject']) t.is(typeof b.handlers[verb], 'function', verb)
85 t.is(typeof (await b.handlers.invite()), 'string', 'invite mints a token (returned once, stored as a hash)')
86})
87
88function socketAnswers (p) {
89 return new Promise((resolve) => {
90 import('bare-pipe').then(({ default: Pipe }) => {
91 const s = new Pipe()
92 s.on('error', () => resolve(false))
93 s.connect(p, () => { s.destroy(); resolve(true) })
94 }).catch(() => resolve(false))
95 })
96}