OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-bridge commits / 8f04a9a

0.9.45 (org #34): getRepo?since is honoured — and the response says which CAR you got
`since` was accepted and thrown away: server.js called front.getRepo() with NO arguments, so the parameter never reached the peer and the identical full CAR came back — measured byte for byte, 45,563 bytes for wj62. The hazard was never the wasted bytes. A caller passing `since` got correct results BY ACCIDENT and would have gone silently wrong the day partial CARs landed. They have now landed (atproto-store 0.0.21 + atproto-did-peer 0.2.11), so the contract is nailed down here rather than left to be inferred. A partial CAR is NOT self-contained — the unchanged MST nodes are deliberately absent — so a client that walks one as a repo gets a missing-block error, and one that assumes it is complete is simply wrong. The distinction therefore travels in headers instead of being guessable: atproto-repo-partial: true|false atproto-since-honoured: true|false atproto-since-from/-to: the rev range, when honoured atproto-since-reason: why not, when refused No `since` sets NO headers at all — every existing caller is on that path and must not acquire a new contract it did not ask for. A front too old to know about `since` returns no `partial` field, which reads as false, so an un-upgraded peer degrades to "full CAR, not honoured", stated rather than silent. An unanswerable `since` is still 200: it is not a client error, and the full CAR is a correct superset of what was asked for. BOTH surfaces. server.js and server-bare.js each carry their own copy of the XRPC switch, and patching one of a pair is a root cause already on file (listBlobs shipped into hpp's serve-device.js while the bridge talks to a different responder list). Changed together, deliberately. One bug the test caught, in this change: the reason strings are written for humans and contain em-dashes, and an HTTP header value must be ISO-8859-1 — Node throws ERR_INVALID_CHAR and the response becomes a 500. So the header explaining WHY a request degraded would itself have taken the response down: the exact failure mode this feature removes, reintroduced one layer out. All explanatory header text now goes through headerSafe(). Test: pds/test/get-repo-since.test.js, 18 assertions over the REAL membrane HTTP surface with a real replicated peer. Full pds suite 65/65, 259 asserts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8f04a9a32b48dff325f13b64805e0fc16bbd9562
by plankatron <fleeky@gmail.com> · 2026-08-07T17:21:38.000Z

Changed files (4) +211 -8

package.json +2 -2
……@@ -1,7 +1,7 @@
11{
22 "name": "atproto-bridge",
3 "version": "0.9.44",
4 "description": "Stateless keyless atproto MEMBRANE \u2014 fronts self-custody hyper-proto-peers to bluesky over HTTP/WS, holds no keys and never signs. Bare-runnable. Deploy root / flat closure over the identity+repo+store+pds packages.",
3 "version": "0.9.45",
4 "description": "Stateless keyless atproto MEMBRANE — fronts self-custody hyper-proto-peers to bluesky over HTTP/WS, holds no keys and never signs. Bare-runnable. Deploy root / flat closure over the identity+repo+store+pds packages.",
55 "type": "module",
66 "main": "index.js",
77 "bin": {
pds/lib/server-bare.js +28 -3
……@@ -88,6 +88,11 @@ function sendBytes (res, status, contentType, buf) {
8888 res.end(buf)
8989}
9090
91// An HTTP header value must be ISO-8859-1; a non-ASCII character makes the whole response a 500. Our
92// diagnostic strings are written for humans and carry em-dashes, so a reason explaining WHY a request
93// degraded would itself have taken the response down. Twin of the guard in server.js.
94const headerSafe = (s) => String(s).replace(/[^\x20-\x7e]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 300)
95
9196// The membrane serves ONLY fronted peers. `peers` (PeerRouter) owns the fronted set + live
9297// PeerFronts. serverDid/availableUserDomains answer describeServer (there is no local account).
9398export function createPdsServer ({ peers = null, serverDid = null, availableUserDomains = [], favelaWebUpstream = null } = {}) {
……@@ -198,12 +203,32 @@ export function createPdsServer ({ peers = null, serverDid = null, availableUser
198203 if (q.get('since')) body.sinceIgnored = true
199204 return sendJson(res, 200, body)
200205 }
206 // getRepo — full CAR, or a PARTIAL one when `since` is given and can be honoured. See the twin
207 // in server.js for the full reasoning.
208 //
209 // ⛔ TWO SURFACES, BOTH PATCHED. `listBlobs` shipped into hpp's serve-device.js while the
210 // bridge talks to a DIFFERENT responder list, and the symptom was a deployed handler that the
211 // caller could not reach. This file and server.js are that same pair for the XRPC switch:
212 // changing one and not the other reproduces the bug exactly.
201213 case 'com.atproto.sync.getRepo': {
202214 const front = await frontIfPeer(q.get('did') || undefined)
203215 if (!front) return sendErr(res, 404, 'RepoNotFound', 'repo not fronted here')
204 const { carBytes } = await front.getRepo()
205 const buf = Buffer.from(carBytes)
206 res.writeHead(200, { 'content-type': 'application/vnd.ipld.car', 'content-length': buf.length, ...CORS })
216 const since = q.get('since') || null
217 const r = await front.getRepo(since ? { since } : undefined)
218 const buf = Buffer.from(r.carBytes)
219 const headers = { 'content-type': 'application/vnd.ipld.car', 'content-length': buf.length, ...CORS }
220 if (since) {
221 const honoured = r.partial === true && r.sinceHonoured === true
222 headers['atproto-repo-partial'] = honoured ? 'true' : 'false'
223 headers['atproto-since-honoured'] = honoured ? 'true' : 'false'
224 if (honoured) {
225 if (r.fromRev) headers['atproto-since-from'] = String(r.fromRev)
226 if (r.toRev) headers['atproto-since-to'] = String(r.toRev)
227 } else {
228 headers['atproto-since-reason'] = headerSafe(r.reason || 'peer front too old to honour since')
229 }
230 }
231 res.writeHead(200, headers)
207232 return res.end(buf)
208233 }
209234 // The PROOF-CARRYING single-record read. Distinct from com.atproto.repo.getRecord below, which
pds/lib/server.js +37 -3
……@@ -54,6 +54,13 @@ async function readJson (req) {
5454 return chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {}
5555}
5656
57// An HTTP header value must be ISO-8859-1; Node throws ERR_INVALID_CHAR on anything else and the whole
58// response becomes a 500. Our own diagnostic strings are written for humans and carry em-dashes, so a
59// reason explaining WHY a request degraded would itself have taken the response down — the failure mode
60// this feature exists to remove, reintroduced one layer out. Any explanatory text bound for a header
61// goes through here.
62const headerSafe = (s) => String(s).replace(/[^\x20-\x7e]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 300)
63
5764// The membrane serves ONLY fronted peers. `peers` (PeerRouter) owns the fronted set + live
5865// PeerFronts. serverDid/availableUserDomains answer describeServer (there is no local account).
5966export function createPdsServer ({ peers = null, serverDid = null, availableUserDomains = [], favelaWebUpstream = null } = {}) {
……@@ -146,12 +153,39 @@ export function createPdsServer ({ peers = null, serverDid = null, availableUser
146153 if (q.get('since')) body.sinceIgnored = true
147154 return sendJson(res, 200, body)
148155 }
156 // getRepo — the full repo CAR, or a PARTIAL one when `since` is given and can be honoured.
157 //
158 // `since` was accepted and thrown away until 0.9.45: this called front.getRepo() with no
159 // arguments, so it never reached the peer and the identical full CAR came back (measured
160 // byte-for-byte, 45,563 bytes for wj62). A caller passing it got correct results BY ACCIDENT.
161 //
162 // ⛔ THE RESPONSE SAYS WHICH ONE IT IS. A partial CAR is NOT self-contained — the unchanged MST
163 // nodes are deliberately absent — so a client that walks one as if it were a repo gets a
164 // missing-block error, and a client that assumes it is complete is simply wrong. The
165 // distinction therefore travels in headers rather than being left for the reader to infer:
166 // atproto-repo-partial: true|false
167 // atproto-since-honoured: true|false (+ atproto-since-reason when false)
168 // A front too old to know about `since` returns no `partial` field at all, which reads as false
169 // — so an un-upgraded peer degrades to "full CAR, since not honoured", stated, never silent.
149170 case 'com.atproto.sync.getRepo': {
150171 const front = await frontIfPeer(q.get('did') || undefined)
151172 if (!front) return sendErr(res, 404, 'RepoNotFound', 'repo not fronted here')
152 const { carBytes } = await front.getRepo()
153 const buf = Buffer.from(carBytes)
154 res.writeHead(200, { 'content-type': 'application/vnd.ipld.car', 'content-length': buf.length, ...CORS })
173 const since = q.get('since') || null
174 const r = await front.getRepo(since ? { since } : undefined)
175 const buf = Buffer.from(r.carBytes)
176 const headers = { 'content-type': 'application/vnd.ipld.car', 'content-length': buf.length, ...CORS }
177 if (since) {
178 const honoured = r.partial === true && r.sinceHonoured === true
179 headers['atproto-repo-partial'] = honoured ? 'true' : 'false'
180 headers['atproto-since-honoured'] = honoured ? 'true' : 'false'
181 if (honoured) {
182 if (r.fromRev) headers['atproto-since-from'] = String(r.fromRev)
183 if (r.toRev) headers['atproto-since-to'] = String(r.toRev)
184 } else {
185 headers['atproto-since-reason'] = headerSafe(r.reason || 'peer front too old to honour since')
186 }
187 }
188 res.writeHead(200, headers)
155189 return res.end(buf)
156190 }
157191 case 'com.atproto.repo.getRecord': {
pds/test/get-repo-since.test.js +144 -0
……@@ -0,0 +1,144 @@
1// com.atproto.sync.getRepo?since over the REAL membrane HTTP surface (org #34).
2//
3// Until 0.9.45 `since` was accepted and thrown away: server.js called front.getRepo() with NO arguments,
4// so the parameter never reached the peer and the identical full CAR came back — measured byte for byte,
5// 45,563 bytes for wj62. The hazard was never the wasted bytes. It is that a caller passing `since` got
6// correct results BY ACCIDENT, and would have become silently wrong the day partial CARs landed.
7//
8// So the contract under test is not "a smaller CAR comes back". It is:
9// 1. the response STATES which kind of CAR it is, in headers, on every path
10// 2. a partial CAR is genuinely partial — not self-contained, blocks deliberately absent
11// 3. an unanswerable `since` degrades to the FULL CAR and says it was not honoured
12// 4. no `since` is byte-identical to before, because every existing caller passes nothing
13// (2) is what makes (1) load-bearing: a consumer that guesses wrong on a partial CAR gets a
14// missing-block error, so guessing must never be necessary.
15
16import test from 'brittle'
17import { mkdtempSync, rmSync } from 'node:fs'
18import { tmpdir } from 'node:os'
19import { join } from 'node:path'
20import c from 'compact-encoding'
21import Hyperswarm from 'hyperswarm'
22import createTestnet from '@hyperswarm/testnet'
23import ProtomuxRPC from 'protomux-rpc'
24import { pushAnnounce } from './serve-announce.js'
25import { openCorestore } from 'atproto-store/lib/corestore.js'
26import { RepoStore } from 'atproto-store/lib/repo-store.js'
27import { RepoManager } from 'atproto-store/lib/repo-manager.js'
28import { FirehoseSequencer } from 'atproto-store/lib/sequencer.js'
29import { keyFromPrivate } from 'atproto-identity'
30import { discoveryTopic } from 'atproto-identity/lib/discovery.js'
31import { readCar, listRecords, loadCommit } from 'atproto-repo/lib/repo.js'
32import { createPdsServer } from '../lib/server.js'
33import { DidPeerRouter } from 'atproto-did-peer'
34
35process.env.PGH_SIDECAR_SOCK = join(tmpdir(), `no-sidecar-since-${process.pid}.sock`)
36
37const DID = 'did:plc:sincetest'
38const HANDLE = 'sincetest.7rnx.net'
39const PRIV = new Uint8Array(32).fill(7)
40const SERVER_DID = 'did:web:pds.7rnx.net'
41
42async function waitFor (fn, timeout = 15000) {
43 const end = Date.now() + timeout
44 while (Date.now() < end) { if (await fn()) return true; await new Promise((r) => setTimeout(r, 100)) }
45 return false
46}
47
48test('getRepo?since serves a real partial CAR and declares it in headers', async (t) => {
49 const testnet = await createTestnet(3)
50
51 // --- a self-custody peer with THREE commits, so there is a real range to diff over ---
52 const peerDir = mkdtempSync(join(tmpdir(), 'since-peer-'))
53 const peerStore = openCorestore(peerDir)
54 const repo = await new RepoStore(peerStore, { name: `atproto-repo:${DID}` }).ready()
55 let micros = 1_700_000_000_000_000n
56 const mgr = await new RepoManager(repo, { did: DID, privateKey: PRIV, clock: () => (micros += 1_000_000n) }).init()
57 const seq = await new FirehoseSequencer(peerStore.get({ name: 'firehose' })).ready()
58
59 let t0 = Date.parse('2026-08-07T00:00:00.000Z')
60 const write = async (rkey, text) => {
61 const w = await mgr.applyWrite({ action: 'create', collection: 'app.bsky.feed.post', rkey, record: { $type: 'app.bsky.feed.post', text, createdAt: new Date(t0 += 1000).toISOString() } })
62 await seq.sequenceCommit(w, { time: new Date(t0).toISOString() })
63 return w
64 }
65 const w1 = await write('a1', 'first')
66 await write('a2', 'second')
67 const w3 = await write('a3', 'third')
68
69 const peerSwarm = new Hyperswarm({ bootstrap: testnet.bootstrap })
70 peerSwarm.on('connection', (conn) => {
71 peerStore.replicate(conn)
72 // eslint-disable-next-line no-new
73 new ProtomuxRPC(conn, { protocol: 'hyper-proto-peer/rpc/v1', valueEncoding: c.json })
74 pushAnnounce(conn, { did: DID, handle: HANDLE, repo, seq })
75 })
76 await peerSwarm.join(discoveryTopic(DID), { server: true, client: false }).flushed()
77
78 // --- the membrane, constructed exactly as pds-serve.js does ---
79 const routerStore = openCorestore(mkdtempSync(join(tmpdir(), 'since-router-')))
80 const routerSwarm = new Hyperswarm({ bootstrap: testnet.bootstrap })
81 const router = new DidPeerRouter(routerStore, {
82 swarm: routerSwarm,
83 join: (did) => { try { routerSwarm.join(discoveryTopic(did), { server: true, client: true }); routerSwarm.flush().catch(() => {}) } catch {} },
84 fronted: [DID],
85 resolveVerifyKey: async () => keyFromPrivate(PRIV).publicKey,
86 rpc: true
87 })
88 const server = createPdsServer({ peers: router, serverDid: SERVER_DID, availableUserDomains: ['.7rnx.net'] })
89 await new Promise((r) => server.listen(0, '127.0.0.1', r))
90 const xrpc = (m) => `http://127.0.0.1:${server.address().port}/xrpc/${m}`
91
92 t.teardown(async () => {
93 server.close(); await router.close()
94 await peerSwarm.destroy(); await routerSwarm.destroy()
95 await seq.close(); await repo.close(); await peerStore.close(); await routerStore.close()
96 await testnet.destroy()
97 rmSync(peerDir, { recursive: true, force: true })
98 })
99
100 const get = (qs) => fetch(xrpc('com.atproto.sync.getRepo') + qs)
101 // The diff is assembled FROM the replicated frames, so a front holding only the head frame would
102 // legitimately refuse and every assertion below would prove nothing. Wait for all three.
103 t.ok(await waitFor(async () => {
104 const r = await get('?did=' + DID)
105 if (!r.ok) return false
106 try { return listRecords(await readCar(new Uint8Array(await r.arrayBuffer()))).length === 3 } catch { return false }
107 }), 'the membrane serves the complete 3-record repo (peer fully replicated)')
108
109 // --- 1. NO since: unchanged, and no since-headers at all ---
110 // Every existing caller is on this path; a header appearing here would be a new, unrequested contract.
111 const plain = await get('?did=' + DID)
112 const plainCar = await readCar(new Uint8Array(await plain.arrayBuffer()))
113 t.is(listRecords(plainCar).length, 3, 'no-since getRepo still returns the complete repo')
114 t.is(plain.headers.get('atproto-repo-partial'), null, 'and sets NO partial header — nothing was asked')
115 t.is(plain.headers.get('atproto-since-honoured'), null, 'nor a honoured header')
116
117 // --- 2. since=<rev>: a genuine partial CAR, self-declared ---
118 const part = await get('?did=' + DID + '&since=' + w1.rev)
119 t.is(part.status, 200, 'since is answered 200, not an error')
120 t.is(part.headers.get('atproto-repo-partial'), 'true', 'declared PARTIAL in the response headers')
121 t.is(part.headers.get('atproto-since-honoured'), 'true', 'and declared honoured')
122 t.is(part.headers.get('atproto-since-from'), w1.rev, 'reporting the rev it diffed from')
123 t.is(part.headers.get('atproto-since-to'), w3.rev, 'and the rev it diffed to')
124
125 const partCar = await readCar(new Uint8Array(await part.arrayBuffer()))
126 t.ok(loadCommit(partCar).cid.equals(loadCommit(plainCar).cid), 'rooted at the same head as the full CAR')
127 // The property that makes the headers load-bearing. If this ever stops throwing we have silently
128 // regressed to serving a full CAR while claiming a diff.
129 t.exception(() => listRecords(partCar), 'the partial CAR is NOT self-contained — walking it alone throws')
130
131 // --- 3. an unanswerable since: FULL CAR + an explicit not-honoured, never a silent full CAR ---
132 // '2222222222222' is below every real rev: the TID alphabet starts at '2', not 'a'.
133 const fb = await get('?did=' + DID + '&since=2222222222222')
134 t.is(fb.status, 200, 'still 200 — a since we cannot honour is not a client error')
135 t.is(fb.headers.get('atproto-repo-partial'), 'false', 'declared NOT partial')
136 t.is(fb.headers.get('atproto-since-honoured'), 'false', 'and explicitly NOT honoured — the anti-silence property')
137 t.ok((fb.headers.get('atproto-since-reason') || '').length > 0, 'with a reason: ' + fb.headers.get('atproto-since-reason'))
138 t.is(listRecords(await readCar(new Uint8Array(await fb.arrayBuffer()))).length, 3, 'and the fallback CAR is genuinely complete')
139
140 // --- 4. since == head: an empty diff is a real answer ---
141 const empty = await get('?did=' + DID + '&since=' + w3.rev)
142 t.is(empty.headers.get('atproto-repo-partial'), 'true', 'since == head is still a partial answer')
143 t.is((await readCar(new Uint8Array(await empty.arrayBuffer()))).blocks.size, 1, 'carrying the commit block alone')
144})