| … | … | @@ -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 | |
| 16 | import test from 'brittle' |
| 17 | import { mkdtempSync, rmSync } from 'node:fs' |
| 18 | import { tmpdir } from 'node:os' |
| 19 | import { join } from 'node:path' |
| 20 | import c from 'compact-encoding' |
| 21 | import Hyperswarm from 'hyperswarm' |
| 22 | import createTestnet from '@hyperswarm/testnet' |
| 23 | import ProtomuxRPC from 'protomux-rpc' |
| 24 | import { pushAnnounce } from './serve-announce.js' |
| 25 | import { openCorestore } from 'atproto-store/lib/corestore.js' |
| 26 | import { RepoStore } from 'atproto-store/lib/repo-store.js' |
| 27 | import { RepoManager } from 'atproto-store/lib/repo-manager.js' |
| 28 | import { FirehoseSequencer } from 'atproto-store/lib/sequencer.js' |
| 29 | import { keyFromPrivate } from 'atproto-identity' |
| 30 | import { discoveryTopic } from 'atproto-identity/lib/discovery.js' |
| 31 | import { readCar, listRecords, loadCommit } from 'atproto-repo/lib/repo.js' |
| 32 | import { createPdsServer } from '../lib/server.js' |
| 33 | import { DidPeerRouter } from 'atproto-did-peer' |
| 34 | |
| 35 | process.env.PGH_SIDECAR_SOCK = join(tmpdir(), `no-sidecar-since-${process.pid}.sock`) |
| 36 | |
| 37 | const DID = 'did:plc:sincetest' |
| 38 | const HANDLE = 'sincetest.7rnx.net' |
| 39 | const PRIV = new Uint8Array(32).fill(7) |
| 40 | const SERVER_DID = 'did:web:pds.7rnx.net' |
| 41 | |
| 42 | async 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 | |
| 48 | test('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 | }) |