OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge commits / e70bb99

ap-bridge 0.9.85: the queue erased WHO was signing, so every registered user's activity went out as the primary
THE ROOT CAUSE of "delivered 202, nothing appears". createDeliverer grew a per-delivery `signer` in 0.9.52 so one bridge could front many people. createQueuedDeliverer kept the call shape it was written with, for a single actor, and dropped the signer at BOTH boundaries — the enqueue destructure `deliver({ to, inbox, activity })`, and the send `deliver({ to, inbox, activity })`. So every queued activity fell back to the process signer. A registered user's posts and profile Updates therefore went out signed with the PRIMARY's key, against the primary's key id, while the activity's `actor` was the registered user. Mastodon verifies that signature happily — it is a real key — resolves the signing account to the primary, sees an actor that does not match, and drops the activity, after already answering 202. Seventeen activities accepted, none stored, no fetch, and no error anywhere on either side. Measured on the wire once deliver() was instrumented (0.9.84): actor https://ap.7rnx.net/ap/users/ashatron/actor keyId https://ap.7rnx.net/ap/actor#main-key A signer is a live object and cannot survive a restart in a JSON file, so the entry now records the PUBLIC key id and resolves it to a signer per attempt — which keeps the queue's "holds nothing secret" property intact. ⛔ An unresolvable signer HOLDS the entry rather than falling back. Falling back is precisely this bug; signing as somebody else is worse than not sending. Three tests, verified red. 230/230. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
e70bb99cd88b5197409188321a2fb1a05328d268
by plankatron <fleeky@gmail.com> · 2026-09-09T01:36:55.000Z

Changed files (5) +145 -16

.pgh-lock.json +10 -10
……@@ -1,9 +1,9 @@
11{
22 "name": "ap-bridge",
3 "version": "0.9.83",
3 "version": "0.9.84",
44 "runtime": "bare",
5 "rootTreeHash": "f82982ad11e6c8dd975397eea77a9275e482d586f00d73698f22743e490357a2",
6 "publishedAt": "2026-09-09T01:28:01.706Z",
5 "rootTreeHash": "56aa2c4ac316ad8079e304e67d3c11883811150e7112b6fd21cd06d8a9168787",
6 "publishedAt": "2026-09-09T01:31:20.229Z",
77 "deps": [
88 {
99 "name": "@hyperswarm/secret-stream",
……@@ -3929,7 +3929,7 @@
39293929 }
39303930 ],
39313931 "reason": "version-splits only",
3932 "acceptedAt": "2026-09-09T01:28:01.743Z"
3932 "acceptedAt": "2026-09-09T01:31:20.241Z"
39333933 },
39343934 {
39353935 "type": "VERSION_CONFLICT",
……@@ -3944,7 +3944,7 @@
39443944 }
39453945 ],
39463946 "reason": "version-splits only",
3947 "acceptedAt": "2026-09-09T01:28:01.743Z"
3947 "acceptedAt": "2026-09-09T01:31:20.241Z"
39483948 },
39493949 {
39503950 "type": "VERSION_CONFLICT",
……@@ -3969,7 +3969,7 @@
39693969 }
39703970 ],
39713971 "reason": "version-splits only",
3972 "acceptedAt": "2026-09-09T01:28:01.743Z"
3972 "acceptedAt": "2026-09-09T01:31:20.241Z"
39733973 },
39743974 {
39753975 "type": "VERSION_CONFLICT",
……@@ -3984,7 +3984,7 @@
39843984 }
39853985 ],
39863986 "reason": "version-splits only",
3987 "acceptedAt": "2026-09-09T01:28:01.743Z"
3987 "acceptedAt": "2026-09-09T01:31:20.241Z"
39883988 },
39893989 {
39903990 "type": "VERSION_CONFLICT",
……@@ -3999,7 +3999,7 @@
39993999 }
40004000 ],
40014001 "reason": "version-splits only",
4002 "acceptedAt": "2026-09-09T01:28:01.743Z"
4002 "acceptedAt": "2026-09-09T01:31:20.241Z"
40034003 },
40044004 {
40054005 "type": "VERSION_CONFLICT",
……@@ -4014,7 +4014,7 @@
40144014 }
40154015 ],
40164016 "reason": "version-splits only",
4017 "acceptedAt": "2026-09-09T01:28:01.743Z"
4017 "acceptedAt": "2026-09-09T01:31:20.241Z"
40184018 },
40194019 {
40204020 "type": "VERSION_CONFLICT",
……@@ -4034,7 +4034,7 @@
40344034 }
40354035 ],
40364036 "reason": "version-splits only",
4037 "acceptedAt": "2026-09-09T01:28:01.743Z"
4037 "acceptedAt": "2026-09-09T01:31:20.241Z"
40384038 }
40394039 ]
40404040}
index.js +21 -0
……@@ -74,9 +74,19 @@ async function main () {
7474 // Both callers benefit: Publisher's Creates, and router.js:287's `Accept` for an inbound Follow —
7575 // which logged "will retry" while having no retry that survived a restart. A follow that arrives
7676 // while this box is down is the main thing genuinely owed to somebody, and it is now owed durably.
77 // ⛔ THE QUEUE CARRIES A KEY ID, AND THIS TURNS IT BACK INTO A SIGNER. A signer is a live object
78 // and cannot survive a restart in a JSON file, so the durable entry records WHO signs and the
79 // signer is resolved per attempt. Without this every queued activity fell back to the process
80 // signer: a registered user's posts went out signed with the primary's key against the primary's
81 // key id, and Mastodon dropped every one of them after answering 202.
82 //
83 // ⚠ Assigned below, once PerUser exists. A null resolver means a queued entry with a signerId is
84 // HELD rather than sent under the wrong identity — the safe direction.
85 let signerByKeyId = null
7786 const outbound = createQueuedDeliverer({
7887 queue: new DeliveryQueue(join(dataDir, 'queue.json')),
7988 deliver: sendNow,
89 resolveSigner: (keyId) => (signerByKeyId ? signerByKeyId(keyId) : null),
8090 log
8191 })
8292 const deliver = outbound.deliver
……@@ -293,6 +303,17 @@ async function main () {
293303 ctx.users = users
294304 Object.assign(ctx, perUser.resolvers())
295305
306 // Resolve a queued entry's key id back to that user's signer. Scanning the registry is right
307 // here: the key id IS the actor id plus '#main-key', so this cannot match somebody else by
308 // accident, and there are a handful of users rather than thousands.
309 signerByKeyId = (keyId) => {
310 for (const u of users.all()) {
311 const s = perUser.signer(u)
312 if (s && s.keyId === keyId) return s
313 }
314 return null
315 }
316
296317 /**
297318 * A registration becomes a served actor: hold the feed, add the user.
298319 *
lib/queue.js +30 -5
……@@ -37,7 +37,7 @@ export const BACKOFF = [60_000, 300_000, 900_000, 3_600_000, 10_800_000, 21_600_
3737const keyFor = (inbox, activity) => `${activity && activity.id ? activity.id : '?'}|${inbox}`
3838
3939class BaseQueue {
40 async add ({ to, inbox, activity }) {
40 async add ({ to, inbox, activity, signerId = null }) {
4141 const state = await this._read()
4242 const id = keyFor(inbox, activity)
4343 if (state.pending[id] || state.dead[id]) return { id, added: false }
……@@ -46,6 +46,19 @@ class BaseQueue {
4646 to,
4747 inbox,
4848 activity,
49 // ⛔ WHICH IDENTITY SIGNS THIS, by its PUBLIC key id, resolved to a live signer at send time.
50 // A signer is a live object and cannot survive a restart in a JSON file, so carrying it by
51 // reference was never possible — but carrying NOTHING meant every queued activity fell back to
52 // the PROCESS signer. Every post and profile Update for a registered user went out signed with
53 // the primary's key, against the primary's key id, while the activity's `actor` was the
54 // registered user. Mastodon verifies that signature happily — it is a real key — resolves the
55 // signing account to the primary, sees an actor that does not match, and DROPS the activity
56 // after already answering 202. Seventeen accepted, none stored, no error anywhere. Measured on
57 // the wire: actor .../users/ashatron/actor signed with keyId https://host/ap/actor#main-key.
58 //
59 // A keyId is public — it is in every actor document — so this keeps the queue's "holds nothing
60 // secret" property while making the durable path know whose delivery it carries.
61 signerId,
4962 attempts: 0,
5063 nextAt: 0, // due immediately
5164 createdAt: Date.now(),
……@@ -160,7 +173,7 @@ export class MemoryDeliveryQueue extends BaseQueue {
160173 * this one retries slow (hours) for a server that is actually down. They answer different
161174 * questions and collapsing them would lose one of the answers.
162175 */
163export function createQueuedDeliverer ({ queue, deliver, log = () => {}, now = Date.now, intervalMs = 60_000, setTimer = setInterval, clearTimer = clearInterval }) {
176export function createQueuedDeliverer ({ queue, deliver, resolveSigner = null, log = () => {}, now = Date.now, intervalMs = 60_000, setTimer = setInterval, clearTimer = clearInterval }) {
164177 if (!queue) throw new Error('createQueuedDeliverer: a queue is required')
165178 if (typeof deliver !== 'function') throw new Error('createQueuedDeliverer: a deliver function is required')
166179
……@@ -169,7 +182,19 @@ export function createQueuedDeliverer ({ queue, deliver, log = () => {}, now = D
169182
170183 async function attempt (e) {
171184 try {
172 const res = await deliver({ to: e.to, inbox: e.inbox, activity: e.activity })
185 // ⛔ AN UNRESOLVABLE SIGNER IS NOT A REASON TO USE ANOTHER. Falling through to the process
186 // signer is precisely the defect above: it signs one person's activity with another's key.
187 // The entry stays queued — a released user, or a key not loaded yet, is a state to wait in,
188 // not one to send from somebody else's identity.
189 let signer = null
190 if (e.signerId) {
191 signer = resolveSigner ? resolveSigner(e.signerId) : null
192 if (!signer) {
193 log({ msg: 'queued delivery HELD — cannot resolve its signer', to: e.to, signerId: e.signerId })
194 return
195 }
196 }
197 const res = await deliver({ to: e.to, inbox: e.inbox, activity: e.activity, signer })
173198 if (res && res.ok) {
174199 await queue.succeed(e.id)
175200 log({ msg: 'queued delivery sent', to: e.to, inbox: e.inbox, attempts: e.attempts })
……@@ -202,8 +227,8 @@ export function createQueuedDeliverer ({ queue, deliver, log = () => {}, now = D
202227
203228 return {
204229 /** Enqueue. Durable before the network is touched, and does not block the caller on it. */
205 async deliver ({ to, inbox = null, activity }) {
206 const { id, added } = await queue.add({ to, inbox, activity })
230 async deliver ({ to, inbox = null, activity, signer = null }) {
231 const { id, added } = await queue.add({ to, inbox, activity, signerId: (signer && signer.keyId) || null })
207232 if (!added) {
208233 log({ msg: 'delivery already queued, not duplicating', to, id })
209234 return { queued: false, id }
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "ap-bridge",
3 "version": "0.9.84",
3 "version": "0.9.85",
44 "description": "ActivityPub membrane — a second projection of the self-custody atproto peer",
55 "type": "module",
66 "main": "index.js",
test/queue.test.js +83 -0
……@@ -197,3 +197,86 @@ test('★ enqueue returns immediately — Publisher fires and forgets', async (t
197197 t.absent(started, 'accepting the activity does not block on the network')
198198 t.is((await queue.list()).length, 1, 'it is durable before anything is attempted')
199199})
200
201// ⛔⛔ THE QUEUE ERASED WHO WAS SIGNING, AND EVERY PER-USER DELIVERY WENT OUT AS THE PRIMARY.
202//
203// createDeliverer grew a per-delivery `signer` in 0.9.52 so one bridge could front many people.
204// This wrapper kept "createDeliverer's signature" as it was written for a single actor and called
205// `deliver({ to, inbox, activity })` — dropping it. A signer is a live object and cannot be written
206// to a JSON file, so nothing about the durable path could have carried it as-is.
207//
208// The result was invisible from our side and fatal on theirs: every activity for a registered user
209// was signed with the PRIMARY's key against the primary's key id, while the activity's `actor` was
210// the registered user. Mastodon verifies the signature (it is a real key), resolves the signing
211// account to the primary, sees an actor that does not match, and DROPS the activity — after having
212// already returned 202. Measured on the wire: actor .../users/ashatron/actor signed with
213// keyId https://ap.7rnx.net/ap/actor#main-key. 17 activities accepted, none stored, no refetch.
214//
215// ⛔ AND AN UNRESOLVABLE SIGNER MUST NOT FALL BACK. Falling back to the process signer is exactly
216// the bug; signing as somebody else is worse than not sending.
217test('⛔⛔ a queued delivery is signed by the identity that queued it', async (t) => {
218 const seen = []
219 const q = new MemoryDeliveryQueue()
220 const signers = { ash: { keyId: 'https://h/ap/users/ash/actor#main-key' } }
221 const qd = createQueuedDeliverer({
222 queue: q,
223 deliver: async (d) => { seen.push(d); return { ok: true } },
224 resolveSigner: (id) => Object.values(signers).find((s) => s.keyId === id) || null,
225 setTimer: () => null,
226 clearTimer: () => {},
227 log: () => {}
228 })
229 const deliver = qd.deliver
230
231 await deliver({
232 to: 'https://remote/users/bob',
233 inbox: 'https://remote/inbox',
234 activity: { id: 'a1', type: 'Create', actor: 'https://h/ap/users/ash/actor' },
235 signer: signers.ash
236 })
237 await qd.drain()
238
239 t.is(seen.length, 1)
240 t.ok(seen[0].signer, 'a signer reached the wire')
241 t.is(seen[0].signer.keyId, signers.ash.keyId, 'and it is THEIRS, not the process default')
242})
243
244test('⛔ a queued delivery whose signer cannot be resolved is NOT sent as somebody else', async (t) => {
245 const seen = []
246 const q = new MemoryDeliveryQueue()
247 const qd = createQueuedDeliverer({
248 queue: q,
249 deliver: async (d) => { seen.push(d); return { ok: true } },
250 resolveSigner: () => null, // the user was released, or their key is gone
251 setTimer: () => null,
252 clearTimer: () => {},
253 log: () => {}
254 })
255 const deliver = qd.deliver
256
257 await deliver({
258 to: 'https://remote/users/bob',
259 inbox: 'https://remote/inbox',
260 activity: { id: 'a2', type: 'Create', actor: 'https://h/ap/users/gone/actor' },
261 signer: { keyId: 'https://h/ap/users/gone/actor#main-key' }
262 })
263 await qd.drain()
264
265 t.is(seen.length, 0, 'nothing went out under the wrong identity')
266 t.is(await q.size(), 1, 'and it is still queued rather than silently dropped')
267})
268
269test('★ a delivery with no signer still uses the process default, exactly as before', async (t) => {
270 const seen = []
271 const qd = createQueuedDeliverer({
272 queue: new MemoryDeliveryQueue(),
273 deliver: async (d) => { seen.push(d); return { ok: true } },
274 setTimer: () => null,
275 clearTimer: () => {},
276 log: () => {}
277 })
278 await qd.deliver({ to: 'https://remote/users/bob', inbox: 'https://remote/inbox', activity: { id: 'a3', type: 'Create' } })
279 await qd.drain()
280 t.is(seen.length, 1)
281 t.absent(seen[0].signer, 'the primary keeps the deliverer it always had')
282})