| … | … | @@ -1,44 +1,16 @@ |
| 1 | 1 | // ap-bridge — the ActivityPub membrane. See DESIGN.md. |
| 2 | 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. | |
| 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. | |
| 7 | 7 | |
| 8 | 8 | import './lib/bare-globals.js' // FIRST — deps touch TextEncoder at module load |
| 9 | 9 | import fs from 'bare-fs' |
| 10 | 10 | import process from 'bare-process' |
| 11 | 11 | import { join } from 'bare-path' |
| 12 | 12 | import { loadConfig, assertUsable } from './lib/config.js' |
| 13 | import crypto from 'bare-crypto' | |
| 14 | import http from 'bare-http1' | |
| 15 | import { createServer } from './lib/server.js' | |
| 16 | import { actorDocFor } from './lib/router.js' | |
| 17 | import { FollowerStore } from './lib/followers.js' | |
| 18 | import { InboundStore } from './lib/inbound-store.js' | |
| 19 | import { ObservationStore } from './lib/observations.js' | |
| 20 | import { RemoteKeyResolver } from './lib/remote-key.js' | |
| 21 | import { createDeliverer } from './lib/deliver.js' | |
| 22 | import { LocalSigner } from './lib/signer.js' | |
| 23 | import { DeliveryQueue, createQueuedDeliverer } from './lib/queue.js' | |
| 24 | import { toNotifications } from './lib/inbound-notifications.js' | |
| 25 | import { Users } from './lib/users.js' | |
| 26 | import { PerUser } from './lib/per-user.js' | |
| 27 | import { Registrations } from './lib/registrations.js' | |
| 28 | import { FeedHolder } from './lib/feed-holder.js' | |
| 29 | import { UserKeys } from './lib/user-keys.js' | |
| 30 | import { keyId, actorId, buildActor } from './lib/actor.js' | |
| 31 | import { RecordSource } from './lib/records.js' | |
| 32 | import { createBlobFetcher } from './lib/blobs.js' | |
| 33 | import { httpClient } from './lib/https.js' | |
| 34 | import { Publisher } from './lib/publisher.js' | |
| 35 | import { JsonState } from './lib/state.js' | |
| 36 | import { DidPeer } from 'atproto-did-peer' | |
| 37 | import { FavelaSource } from './lib/favela-source.js' | |
| 38 | import { resolveDidVerifyKey } from './lib/plc.js' | |
| 39 | import Hyperswarm from 'hyperswarm' | |
| 40 | import { openCorestore } from 'atproto-store/lib/corestore.js' | |
| 41 | import { discoveryTopic } from 'atproto-identity/lib/discovery.js' | |
| 13 | import { startApBridge } from './lib/bridge.js' | |
| 42 | 14 | |
| 43 | 15 | const log = (o) => console.log(JSON.stringify({ t: new Date().toISOString(), ...o })) |
| 44 | 16 | |
| … | … | @@ -58,8 +30,8 @@ async function main () { |
| 58 | 30 | if (process.argv[2] === 'admin') { |
| 59 | 31 | const { runAdminCli } = await import('./lib/admin-cli.js') |
| 60 | 32 | // ⛔ 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. | |
| 63 | 35 | // |
| 64 | 36 | // ⚠ loadConfig, NOT assertUsable — the CLI must work on a bridge whose keys or host are |
| 65 | 37 | // misconfigured. Refusing to hand out a recovery invite because the thing needing recovery is |
| … | … | @@ -70,549 +42,25 @@ async function main () { |
| 70 | 42 | } |
| 71 | 43 | |
| 72 | 44 | const config = assertUsable(loadConfig()) |
| 45 | const version = pkgVersion() | |
| 46 | const bridge = await startApBridge(config, { log, version }) | |
| 73 | 47 | |
| 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 | |
| 606 | 52 | const shutdown = (code = 0) => { |
| 53 | if (stopping) return stopping | |
| 607 | 54 | 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. | |
| 614 | 55 | 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 | |
| 616 | 64 | } |
| 617 | 65 | process.on('SIGINT', () => shutdown(0)) |
| 618 | 66 | process.on('SIGTERM', () => shutdown(0)) |
| … | … | @@ -620,34 +68,16 @@ async function main () { |
| 620 | 68 | // OTA + control surface. Best-effort: if it cannot come up we keep federating (see lib/control.js). |
| 621 | 69 | const { startControl } = await import('./lib/control.js') |
| 622 | 70 | 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, | |
| 627 | 72 | udsPath: process.env.AP_SOCK || join(config.dataDir, 'svc.sock'), |
| 628 | 73 | 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, | |
| 631 | 76 | // Exit 75 AFTER the RPC has answered — the supervisor's respawn signal. Close the server first |
| 632 | 77 | // so the port is free for the replacement rather than racing it. |
| 633 | 78 | 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, | |
| 651 | 81 | log |
| 652 | 82 | }) |
| 653 | 83 | } |
| … | … | @@ -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 | ||
| 20 | import './bare-globals.js' // FIRST — deps touch TextEncoder at module load | |
| 21 | import fs from 'bare-fs' | |
| 22 | import process from 'bare-process' | |
| 23 | import { join } from 'bare-path' | |
| 24 | import crypto from 'bare-crypto' | |
| 25 | import http from 'bare-http1' | |
| 26 | import { createServer } from './server.js' | |
| 27 | import { actorDocFor } from './router.js' | |
| 28 | import { FollowerStore } from './followers.js' | |
| 29 | import { InboundStore } from './inbound-store.js' | |
| 30 | import { ObservationStore } from './observations.js' | |
| 31 | import { RemoteKeyResolver } from './remote-key.js' | |
| 32 | import { createDeliverer } from './deliver.js' | |
| 33 | import { LocalSigner } from './signer.js' | |
| 34 | import { DeliveryQueue, createQueuedDeliverer } from './queue.js' | |
| 35 | import { toNotifications } from './inbound-notifications.js' | |
| 36 | import { Users } from './users.js' | |
| 37 | import { PerUser } from './per-user.js' | |
| 38 | import { Registrations } from './registrations.js' | |
| 39 | import { FeedHolder } from './feed-holder.js' | |
| 40 | import { UserKeys } from './user-keys.js' | |
| 41 | import { keyId, actorId, buildActor } from './actor.js' | |
| 42 | import { RecordSource } from './records.js' | |
| 43 | import { createBlobFetcher } from './blobs.js' | |
| 44 | import { httpClient } from './https.js' | |
| 45 | import { Publisher } from './publisher.js' | |
| 46 | import { JsonState } from './state.js' | |
| 47 | import { bridgeHandlers } from './control.js' | |
| 48 | import { DidPeer } from 'atproto-did-peer' | |
| 49 | import { FavelaSource } from './favela-source.js' | |
| 50 | import { resolveDidVerifyKey } from './plc.js' | |
| 51 | import Hyperswarm from 'hyperswarm' | |
| 52 | import { openCorestore } from 'atproto-store/lib/corestore.js' | |
| 53 | import { discoveryTopic } from 'atproto-identity/lib/discovery.js' | |
| 54 | ||
| 55 | const 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 | */ | |
| 64 | export 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 | ||
| 664 | export default startApBridge |
| … | … | @@ -77,6 +77,54 @@ export function maintainSidecarRegistration ({ udsPath, name, runtime = 'bare', |
| 77 | 77 | return () => { stopped = true; if (timer) clearInterval(timer) } |
| 78 | 78 | } |
| 79 | 79 | |
| 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 | */ | |
| 89 | export 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 | ||
| 80 | 128 | // Stand up the control socket. Returns { svc, udsPath } or null when unavailable — the caller keeps |
| 81 | 129 | // serving either way. |
| 82 | 130 | // |
| … | … | @@ -89,42 +137,8 @@ export async function startControl ({ udsPath, name, version = null, status = nu |
| 89 | 137 | // decided no live instance owns this app, so an existing file is orphaned. |
| 90 | 138 | try { if (fs.existsSync(udsPath)) fs.unlinkSync(udsPath) } catch {} |
| 91 | 139 | 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)) | |
| 128 | 142 | } |
| 129 | 143 | if (onRelease && !svc.hasHandler?.('release-ready')) { |
| 130 | 144 | svc.handle('release-ready', async (ctx, params = {}) => { |
| … | … | @@ -33,12 +33,14 @@ import { FavelaFeed } from './favela-feed.js' |
| 33 | 33 | const hex = (k) => (typeof k === 'string' ? k : b4a.toString(k, 'hex')) |
| 34 | 34 | |
| 35 | 35 | export class FeedHolder { |
| 36 | constructor ({ storage, swarm = null, log = () => {} }) { | |
| 36 | constructor ({ storage, swarm = null, bootstrap = null, log = () => {} }) { | |
| 37 | 37 | if (!storage) throw new Error('FeedHolder: a storage directory is required') |
| 38 | 38 | this.storage = storage |
| 39 | 39 | this.corestore = new Corestore(join(storage, 'store')) |
| 40 | 40 | this.swarm = swarm |
| 41 | 41 | this._ownSwarm = !swarm |
| 42 | // Tests pass [] to keep the swarm off the public DHT; production passes nothing. | |
| 43 | this._bootstrap = bootstrap | |
| 42 | 44 | this.overlay = null |
| 43 | 45 | this.files = null |
| 44 | 46 | this._held = new Map() // feed key hex -> FavelaFeed |
| … | … | @@ -136,7 +138,8 @@ export class FeedHolder { |
| 136 | 138 | // swarm is how peers reach this bridge to ask for a name — an address that changes on every |
| 137 | 139 | // restart is not an address. See lib/swarm-keypair.js. |
| 138 | 140 | 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 }) | |
| 140 | 143 | } |
| 141 | 144 | // ⛔ Every connection replicates every held feed, and the overlay attaches to all of them. One |
| 142 | 145 | // connection carries all of it — hypercore multiplexes, and piping each feed separately onto one |
| … | … | @@ -1,12 +1,12 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "ap-bridge", |
| 3 | "version": "0.10.14", | |
| 3 | "version": "0.10.15", | |
| 4 | 4 | "lockfileVersion": 3, |
| 5 | 5 | "requires": true, |
| 6 | 6 | "packages": { |
| 7 | 7 | "": { |
| 8 | 8 | "name": "ap-bridge", |
| 9 | "version": "0.10.14", | |
| 9 | "version": "0.10.15", | |
| 10 | 10 | "license": "MIT", |
| 11 | 11 | "dependencies": { |
| 12 | 12 | "atproto-did-peer": "file:../atproto-did-peer", |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "ap-bridge", |
| 3 | "version": "0.10.14", | |
| 3 | "version": "0.10.15", | |
| 4 | 4 | "description": "ActivityPub membrane \u2014 a second projection of the self-custody atproto peer", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -152,7 +152,8 @@ test('⛔ favelaSocket and did are mutually exclusive', async (t) => { |
| 152 | 152 | const cfg = loadConfig({ AP_FAVELA_SOCKET: '/tmp/p.sock', AP_DID: 'did:plc:x', AP_HOST: 'h' }) |
| 153 | 153 | t.is(cfg.favelaSocket, '/tmp/p.sock') |
| 154 | 154 | 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') | |
| 157 | 158 | t.ok(/favelaSocket OR did, not both/.test(src), 'and the refusal names the reason') |
| 158 | 159 | }) |
| … | … | @@ -87,8 +87,10 @@ test('★★★ the entry dispatches the subcommand before booting the daemon', |
| 87 | 87 | // ⛔ Running the CLI must not also bind the fediverse listener of a bridge that is already running. |
| 88 | 88 | const src = fs.readFileSync(new URL('../index.js', import.meta.url).pathname, 'utf8') |
| 89 | 89 | 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') | |
| 91 | 92 | 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') | |
| 93 | 95 | t.ok(/process\.exit\(await runAdminCli/.test(src), 'then exits rather than falling through') |
| 94 | 96 | }) |
| … | … | @@ -205,8 +205,8 @@ test('⛔ a collect proof cannot be replayed as a release', async (t) => { |
| 205 | 205 | // directly. If index.js forgets to, `admitCollect` returns 404 'not found' — the daemon boots, the |
| 206 | 206 | // verb answers, the tests all pass, and every peer is told for ever that it has nothing. That is the |
| 207 | 207 | // exact shape of the `held:false` defect registrations.js records, one door over. |
| 208 | test('⛔ 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') | |
| 208 | test('⛔ 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') | |
| 210 | 210 | const call = /serveRegistration\(\{([^}]*)\}/.exec(src) |
| 211 | 211 | t.ok(call, 'the swarm door is opened at boot') |
| 212 | 212 | t.ok(/\bobservations\b/.test(call[1]), 'and it is given the buffer it is meant to serve') |
| … | … | @@ -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 | ||
| 9 | import test from 'brittle' | |
| 10 | import fs from 'bare-fs' | |
| 11 | import os from 'bare-os' | |
| 12 | import path from 'bare-path' | |
| 13 | import http from 'bare-http1' | |
| 14 | import { startApBridge } from '../lib/bridge.js' | |
| 15 | import { TEST_PRIVATE_KEY, TEST_PUBLIC_KEY } from './fixtures/keys.js' | |
| 16 | ||
| 17 | function 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 | ||
| 25 | function 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 | ||
| 43 | function 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 | ||
| 55 | async function stop (b) { for (const [, fn] of b.steps) await fn() } | |
| 56 | ||
| 57 | test('★ 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 | ||
| 78 | test('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 | ||
| 88 | function 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 | } |