@atproto/space 0.0.0-spaces-alpha-20260818022935

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/LICENSE.txt +7 -0
  3. package/dist/commit.d.ts +25 -0
  4. package/dist/commit.d.ts.map +1 -0
  5. package/dist/commit.js +80 -0
  6. package/dist/commit.js.map +1 -0
  7. package/dist/credential.d.ts +77 -0
  8. package/dist/credential.d.ts.map +1 -0
  9. package/dist/credential.js +160 -0
  10. package/dist/credential.js.map +1 -0
  11. package/dist/dpop.d.ts +40 -0
  12. package/dist/dpop.d.ts.map +1 -0
  13. package/dist/dpop.js +96 -0
  14. package/dist/dpop.js.map +1 -0
  15. package/dist/error.d.ts +12 -0
  16. package/dist/error.d.ts.map +1 -0
  17. package/dist/error.js +21 -0
  18. package/dist/error.js.map +1 -0
  19. package/dist/index.d.ts +9 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +9 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/lthash.d.ts +18 -0
  24. package/dist/lthash.d.ts.map +1 -0
  25. package/dist/lthash.js +68 -0
  26. package/dist/lthash.js.map +1 -0
  27. package/dist/repo-commit.d.ts +51 -0
  28. package/dist/repo-commit.d.ts.map +1 -0
  29. package/dist/repo-commit.js +137 -0
  30. package/dist/repo-commit.js.map +1 -0
  31. package/dist/space-members.d.ts +27 -0
  32. package/dist/space-members.d.ts.map +1 -0
  33. package/dist/space-members.js +111 -0
  34. package/dist/space-members.js.map +1 -0
  35. package/dist/space-repo.d.ts +33 -0
  36. package/dist/space-repo.d.ts.map +1 -0
  37. package/dist/space-repo.js +136 -0
  38. package/dist/space-repo.js.map +1 -0
  39. package/dist/storage/index.d.ts +3 -0
  40. package/dist/storage/index.d.ts.map +1 -0
  41. package/dist/storage/index.js +19 -0
  42. package/dist/storage/index.js.map +1 -0
  43. package/dist/storage/memory-members-storage.d.ts +12 -0
  44. package/dist/storage/memory-members-storage.d.ts.map +1 -0
  45. package/dist/storage/memory-members-storage.js +36 -0
  46. package/dist/storage/memory-members-storage.js.map +1 -0
  47. package/dist/storage/memory-repo-storage.d.ts +17 -0
  48. package/dist/storage/memory-repo-storage.d.ts.map +1 -0
  49. package/dist/storage/memory-repo-storage.js +56 -0
  50. package/dist/storage/memory-repo-storage.js.map +1 -0
  51. package/dist/storage/types.d.ts +14 -0
  52. package/dist/storage/types.d.ts.map +1 -0
  53. package/dist/storage/types.js +3 -0
  54. package/dist/storage/types.js.map +1 -0
  55. package/dist/sync/car.d.ts +13 -0
  56. package/dist/sync/car.d.ts.map +1 -0
  57. package/dist/sync/car.js +185 -0
  58. package/dist/sync/car.js.map +1 -0
  59. package/dist/sync/consumer.d.ts +39 -0
  60. package/dist/sync/consumer.d.ts.map +1 -0
  61. package/dist/sync/consumer.js +89 -0
  62. package/dist/sync/consumer.js.map +1 -0
  63. package/dist/sync/index.d.ts +3 -0
  64. package/dist/sync/index.d.ts.map +1 -0
  65. package/dist/sync/index.js +3 -0
  66. package/dist/sync/index.js.map +1 -0
  67. package/dist/sync/provider.d.ts +25 -0
  68. package/dist/sync/provider.d.ts.map +1 -0
  69. package/dist/sync/provider.js +60 -0
  70. package/dist/sync/provider.js.map +1 -0
  71. package/dist/sync/repo-index.d.ts +19 -0
  72. package/dist/sync/repo-index.d.ts.map +1 -0
  73. package/dist/sync/repo-index.js +58 -0
  74. package/dist/sync/repo-index.js.map +1 -0
  75. package/dist/types.d.ts +49 -0
  76. package/dist/types.d.ts.map +1 -0
  77. package/dist/types.js +35 -0
  78. package/dist/types.js.map +1 -0
  79. package/dist/util.d.ts +6 -0
  80. package/dist/util.d.ts.map +1 -0
  81. package/dist/util.js +14 -0
  82. package/dist/util.js.map +1 -0
  83. package/package.json +44 -0
  84. package/src/credential.ts +262 -0
  85. package/src/dpop.ts +166 -0
  86. package/src/error.ts +38 -0
  87. package/src/index.ts +8 -0
  88. package/src/lthash.ts +80 -0
  89. package/src/repo-commit.ts +171 -0
  90. package/src/sync/consumer.ts +149 -0
  91. package/src/sync/index.ts +2 -0
  92. package/src/sync/provider.ts +82 -0
  93. package/src/types.ts +62 -0
  94. package/src/util.ts +21 -0
  95. package/tests/credential.test.ts +323 -0
  96. package/tests/dpop.test.ts +234 -0
  97. package/tests/lthash.test.ts +97 -0
  98. package/tests/repo-commit.test.ts +281 -0
  99. package/tests/sync.test.ts +385 -0
  100. package/tsconfig.build.json +16 -0
  101. package/tsconfig.build.tsbuildinfo +1 -0
  102. package/tsconfig.json +7 -0
  103. package/tsconfig.test.json +11 -0
  104. package/tsconfig.test.tsbuildinfo +1 -0
  105. package/tsconfig.tests.tsbuildinfo +1 -0
  106. package/vitest.config.ts +5 -0
@@ -0,0 +1,281 @@
1
+ import { beforeAll, describe, expect, it } from 'vitest'
2
+ import { type Keypair, Secp256k1Keypair } from '@atproto/crypto'
3
+ import { parseCid } from '@atproto/lex-data'
4
+ import {
5
+ type CommitCtx,
6
+ LTHASH_STATE_BYTES,
7
+ RepoCommit,
8
+ type SignedCommit,
9
+ encodeCommitCtx,
10
+ formatSetHashElement,
11
+ verifyCommit,
12
+ } from '../src/index.js'
13
+
14
+ // cids of `{ text: 'hello' }` and `{ text: 'world' }`
15
+ const CID_A = parseCid(
16
+ 'bafyreidefdycgbfy3oglcb6ism3eqhyp5llsrpzxjsuac2gsy4mtrtx244',
17
+ )
18
+ const CID_B = parseCid(
19
+ 'bafyreidpw4cbv6gr4ukh33z23pvvrpr3wi4gnpmi4doamlsl3sa4rgri2a',
20
+ )
21
+
22
+ const ctx: CommitCtx = {
23
+ space: 'at://did:example:space/space/app.bsky.group/test',
24
+ author: 'did:example:alice',
25
+ rev: '3kbcq3p7ad400',
26
+ }
27
+
28
+ describe('RepoCommit', () => {
29
+ let keypair: Keypair
30
+
31
+ beforeAll(async () => {
32
+ keypair = await Secp256k1Keypair.create()
33
+ })
34
+
35
+ const sameContents = (a: RepoCommit, b: RepoCommit) =>
36
+ a.setHash.equals(b.setHash)
37
+
38
+ describe('contents', () => {
39
+ it('starts empty', () => {
40
+ expect(new RepoCommit().setHash.isEmpty()).toBe(true)
41
+ expect(new RepoCommit().setHash.state()).toHaveLength(LTHASH_STATE_BYTES)
42
+ })
43
+
44
+ it('add then remove returns to empty', () => {
45
+ const repo = new RepoCommit().add('c.a', '1', CID_A)
46
+ expect(repo.setHash.isEmpty()).toBe(false)
47
+ repo.remove('c.a', '1', CID_A)
48
+ expect(repo.setHash.isEmpty()).toBe(true)
49
+ })
50
+
51
+ it('is order-independent', () => {
52
+ const a = new RepoCommit().add('c.a', '1', CID_A).add('c.b', '2', CID_B)
53
+ const b = new RepoCommit().add('c.b', '2', CID_B).add('c.a', '1', CID_A)
54
+ expect(sameContents(a, b)).toBe(true)
55
+ })
56
+
57
+ it('distinguishes the same cid at different paths', () => {
58
+ const a = new RepoCommit().add('c.a', '1', CID_A)
59
+ const b = new RepoCommit().add('c.a', '2', CID_A)
60
+ expect(sameContents(a, b)).toBe(false)
61
+ })
62
+
63
+ it('distinguishes different cids at the same path', () => {
64
+ const a = new RepoCommit().add('c.a', '1', CID_A)
65
+ const b = new RepoCommit().add('c.a', '1', CID_B)
66
+ expect(sameContents(a, b)).toBe(false)
67
+ })
68
+
69
+ it('round-trips through persisted state', () => {
70
+ const repo = new RepoCommit().add('c.a', '1', CID_A)
71
+ const resumed = RepoCommit.fromState(repo.setHash.state())
72
+ expect(sameContents(resumed, repo)).toBe(true)
73
+ expect(RepoCommit.fromState(null).setHash.isEmpty()).toBe(true)
74
+ })
75
+
76
+ it('fromRecords matches incremental adds', () => {
77
+ const records = [
78
+ { collection: 'c.a', rkey: '1', cid: CID_A },
79
+ { collection: 'c.b', rkey: '2', cid: CID_B },
80
+ ]
81
+ const incremental = new RepoCommit()
82
+ for (const r of records) incremental.add(r.collection, r.rkey, r.cid)
83
+ expect(sameContents(RepoCommit.fromRecords(records), incremental)).toBe(
84
+ true,
85
+ )
86
+ })
87
+ })
88
+
89
+ describe('applyOp', () => {
90
+ it('treats a null prev as a create', () => {
91
+ const viaOp = new RepoCommit().applyOp({
92
+ collection: 'c.a',
93
+ rkey: '1',
94
+ cid: CID_A,
95
+ prev: null,
96
+ })
97
+ expect(sameContents(viaOp, new RepoCommit().add('c.a', '1', CID_A))).toBe(
98
+ true,
99
+ )
100
+ })
101
+
102
+ it('treats a null cid as a delete', () => {
103
+ const repo = new RepoCommit().add('c.a', '1', CID_A)
104
+ repo.applyOp({ collection: 'c.a', rkey: '1', cid: null, prev: CID_A })
105
+ expect(repo.setHash.isEmpty()).toBe(true)
106
+ })
107
+
108
+ it('swaps both cids on an update', () => {
109
+ const repo = new RepoCommit().add('c.a', '1', CID_A)
110
+ repo.applyOp({ collection: 'c.a', rkey: '1', cid: CID_B, prev: CID_A })
111
+ expect(sameContents(repo, new RepoCommit().add('c.a', '1', CID_B))).toBe(
112
+ true,
113
+ )
114
+ })
115
+
116
+ it('applyOps replays a batch', () => {
117
+ const repo = new RepoCommit().applyOps([
118
+ { collection: 'c.a', rkey: '1', cid: CID_A, prev: null },
119
+ { collection: 'c.a', rkey: '1', cid: CID_B, prev: CID_A },
120
+ { collection: 'c.a', rkey: '1', cid: null, prev: CID_B },
121
+ ])
122
+ expect(repo.setHash.isEmpty()).toBe(true)
123
+ })
124
+
125
+ it('converges regardless of the order ops are applied', () => {
126
+ const ops = [
127
+ { collection: 'c.a', rkey: '1', cid: CID_A, prev: null },
128
+ { collection: 'c.b', rkey: '2', cid: CID_B, prev: null },
129
+ ]
130
+ const forward = new RepoCommit().applyOps(ops)
131
+ const backward = new RepoCommit().applyOps([...ops].reverse())
132
+ expect(sameContents(forward, backward)).toBe(true)
133
+ })
134
+ })
135
+
136
+ describe('signing', () => {
137
+ let repo: RepoCommit
138
+
139
+ beforeAll(() => {
140
+ repo = new RepoCommit().add('app.bsky.feed.post', '1', CID_A)
141
+ })
142
+
143
+ it('produces a well-formed commit', async () => {
144
+ const commit = await repo.sign(ctx, keypair)
145
+ expect(commit.ver).toBe(1)
146
+ expect(commit.rev).toBe(ctx.rev)
147
+ expect(commit.hash).toEqual(repo.setHash.digest())
148
+ expect(commit.hash).toHaveLength(32)
149
+ expect(commit.ikm).toHaveLength(32)
150
+ expect(commit.mac).toHaveLength(32)
151
+ expect(commit.sig.length).toBeGreaterThan(0)
152
+ })
153
+
154
+ it('verifies its own commit', async () => {
155
+ const commit = await repo.sign(ctx, keypair)
156
+ expect(await verifyCommit(commit, ctx, keypair.did())).toBe(true)
157
+ expect(repo.matches(commit)).toBe(true)
158
+ })
159
+
160
+ it('uses a fresh ikm per commit, for deniability', async () => {
161
+ const a = await repo.sign(ctx, keypair)
162
+ const b = await repo.sign(ctx, keypair)
163
+ expect(a.ikm).not.toEqual(b.ikm)
164
+ expect(a.mac).not.toEqual(b.mac)
165
+ expect(a.sig).not.toEqual(b.sig)
166
+ expect(a.hash).toEqual(b.hash)
167
+ })
168
+
169
+ it('rejects a signature from a different key', async () => {
170
+ const other = await Secp256k1Keypair.create()
171
+ const commit = await repo.sign(ctx, keypair)
172
+ expect(await verifyCommit(commit, ctx, other.did())).toBe(false)
173
+ })
174
+
175
+ it.each([
176
+ ['space', { space: 'at://did:example:space/space/app.bsky.group/other' }],
177
+ ['author', { author: 'did:example:bob' }],
178
+ ['rev', { rev: '3kbcq3p7ad999' }],
179
+ ])('does not verify under a different %s', async (_field, override) => {
180
+ const commit = await repo.sign(ctx, keypair)
181
+ const otherCtx = { ...ctx, ...override }
182
+ expect(await verifyCommit(commit, otherCtx, keypair.did())).toBe(false)
183
+ })
184
+
185
+ it('does not verify a tampered hash', async () => {
186
+ const commit = await repo.sign(ctx, keypair)
187
+ const tampered = { ...commit, hash: new RepoCommit().setHash.digest() }
188
+ // The signature over the ctx is still valid — it never covered the hash.
189
+ // The MAC is what catches this, and what a leaked commit can't prove.
190
+ expect(await verifyCommit(tampered, ctx, keypair.did())).toBe(false)
191
+ })
192
+
193
+ it('rejects a commit whose rev disagrees with the ctx', async () => {
194
+ const commit = await repo.sign(ctx, keypair)
195
+ expect(
196
+ await verifyCommit(
197
+ { ...commit, rev: '3kbcq3p7ad999' },
198
+ ctx,
199
+ keypair.did(),
200
+ ),
201
+ ).toBe(false)
202
+ })
203
+
204
+ it('rejects an unknown commit version', async () => {
205
+ const commit = await repo.sign(ctx, keypair)
206
+ const future = { ...commit, ver: 2 } as unknown as SignedCommit
207
+ expect(await verifyCommit(future, ctx, keypair.did())).toBe(false)
208
+ })
209
+
210
+ it('matches() is false once the repo changes', async () => {
211
+ const commit = await repo.sign(ctx, keypair)
212
+ const advanced = RepoCommit.fromState(repo.setHash.state()).add(
213
+ 'app.bsky.feed.post',
214
+ '2',
215
+ CID_B,
216
+ )
217
+ expect(advanced.matches(commit)).toBe(false)
218
+ })
219
+ })
220
+
221
+ /**
222
+ * The set hash element is a bare `collection/rkey/cid` concat. Two records
223
+ * hashing to one element would let one's addition cancel the other's — silently
224
+ * and permanently — so what keeps the encoding injective is worth stating.
225
+ *
226
+ * It is *not* the length prefixing that {@link encodeCommitCtx} uses. It rests
227
+ * entirely on the outer two fields being slash-free: a collection is an NSID and
228
+ * a cid is base32, so the first slash always ends the collection and the last
229
+ * always begins the cid. That leaves the rkey unambiguous no matter what it
230
+ * contains. This package validates none of that itself — the invariant is
231
+ * inherited from the lexicon's `nsid` and `record-key` string formats.
232
+ */
233
+ describe('formatSetHashElement', () => {
234
+ it('separates the three fields', () => {
235
+ expect(formatSetHashElement('c.a', '1', CID_A)).toBe(
236
+ `c.a/1/${CID_A.toString()}`,
237
+ )
238
+ })
239
+
240
+ it('stays unambiguous for an rkey containing a slash', () => {
241
+ // Not reachable through a validated write, but it shows the boundaries are
242
+ // fixed by the outer fields rather than by the rkey's shape.
243
+ const element = formatSetHashElement('c.a', 'b/1', CID_A)
244
+ expect(element.slice(0, element.indexOf('/'))).toBe('c.a')
245
+ expect(element.slice(element.lastIndexOf('/') + 1)).toBe(CID_A.toString())
246
+ })
247
+
248
+ it('distinguishes records that differ only in where the rkey starts', () => {
249
+ expect(formatSetHashElement('c.a', 'b/1', CID_A)).not.toBe(
250
+ formatSetHashElement('c.a', 'b/2', CID_A),
251
+ )
252
+ expect(new RepoCommit().add('c.a', 'b/1', CID_A).setHash).not.toEqual(
253
+ new RepoCommit().add('c.a', 'b/2', CID_A).setHash,
254
+ )
255
+ })
256
+ })
257
+
258
+ describe('encodeCommitCtx', () => {
259
+ const ikm = new Uint8Array(32).fill(7)
260
+
261
+ it('is length-prefixed and domain-separated', () => {
262
+ const encoded = encodeCommitCtx(ctx, ikm)
263
+ expect(Buffer.from(encoded.subarray(0, 16)).toString()).toBe(
264
+ 'atproto-space-v1',
265
+ )
266
+ const spaceLen = (encoded[16] << 8) | encoded[17]
267
+ expect(spaceLen).toBe(ctx.space.length)
268
+ })
269
+
270
+ it('is unambiguous across field boundaries', () => {
271
+ // Without length prefixes these two would encode identically.
272
+ const a = encodeCommitCtx({ space: 'ab', author: 'c', rev: 'd' }, ikm)
273
+ const b = encodeCommitCtx({ space: 'a', author: 'bc', rev: 'd' }, ikm)
274
+ expect(a).not.toEqual(b)
275
+ })
276
+
277
+ it('is deterministic', () => {
278
+ expect(encodeCommitCtx(ctx, ikm)).toEqual(encodeCommitCtx(ctx, ikm))
279
+ })
280
+ })
281
+ })
@@ -0,0 +1,385 @@
1
+ import { beforeAll, describe, expect, it } from 'vitest'
2
+ import { encodeCarBlock, encodeCarHeader, readCarStream } from '@atproto/common'
3
+ import { type Keypair, Secp256k1Keypair } from '@atproto/crypto'
4
+ import { encode } from '@atproto/lex-cbor'
5
+ import { type Cid, cidForCbor } from '@atproto/lex-data'
6
+ import {
7
+ type CommitCtx,
8
+ RepoCommit,
9
+ RepoVerificationError,
10
+ type SerializedRecord,
11
+ type SignedCommit,
12
+ serializeRecord,
13
+ serializeRepo,
14
+ verifyCommit,
15
+ verifyRepoCar,
16
+ verifyRepoCarFull,
17
+ } from '../src/index.js'
18
+
19
+ const SPACE = 'at://did:example:space/space/app.bsky.group/test'
20
+ const AUTHOR = 'did:example:alice'
21
+ const REV = '3kbcq3p7ad400'
22
+
23
+ const ctx: CommitCtx = { space: SPACE, author: AUTHOR, rev: REV }
24
+
25
+ const collect = async (
26
+ stream: AsyncIterable<Uint8Array>,
27
+ ): Promise<Uint8Array> => {
28
+ const chunks: Uint8Array[] = []
29
+ for await (const chunk of stream) chunks.push(chunk)
30
+ return Buffer.concat(chunks)
31
+ }
32
+
33
+ describe('space sync', () => {
34
+ let keypair: Keypair
35
+ let records: SerializedRecord[]
36
+
37
+ beforeAll(async () => {
38
+ keypair = await Secp256k1Keypair.create()
39
+ records = await Promise.all([
40
+ serializeRecord('app.bsky.feed.post', '3kbcq3p7ad401', {
41
+ text: 'hello',
42
+ }),
43
+ serializeRecord('app.bsky.feed.post', '3kbcq3p7ad402', {
44
+ text: 'world',
45
+ }),
46
+ serializeRecord('app.bsky.feed.like', '3kbcq3p7ad403', {
47
+ subject: 'at://x',
48
+ }),
49
+ ])
50
+ })
51
+
52
+ const commitFor = (recs: SerializedRecord[]): Promise<SignedCommit> =>
53
+ RepoCommit.fromRecords(recs).sign(ctx, keypair)
54
+
55
+ const carFor = async (recs: SerializedRecord[]): Promise<Uint8Array> =>
56
+ collect(serializeRepo(await commitFor(recs), recs))
57
+
58
+ it('declares the commit and index as roots, in order', async () => {
59
+ const car = await carFor(records)
60
+ const { roots, blocks } = await readCarStream([car])
61
+ expect(roots).toHaveLength(2)
62
+
63
+ const seen: Cid[] = []
64
+ for await (const block of blocks) seen.push(block.cid)
65
+ // Two roots, then one block per record.
66
+ expect(seen).toHaveLength(2 + records.length)
67
+ expect(seen[0].equals(roots[0])).toBe(true)
68
+ expect(seen[1].equals(roots[1])).toBe(true)
69
+ })
70
+
71
+ it('round-trips through verification', async () => {
72
+ const car = await carFor(records)
73
+ const recovered = await verifyRepoCarFull([car], {
74
+ space: SPACE,
75
+ author: AUTHOR,
76
+ didKey: keypair.did(),
77
+ })
78
+
79
+ expect(recovered.commit.rev).toBe(REV)
80
+ expect(Object.keys(recovered.index)).toHaveLength(records.length)
81
+ expect(recovered.records).toHaveLength(records.length)
82
+ expect(recovered.repo.matches(recovered.commit)).toBe(true)
83
+
84
+ for (const original of records) {
85
+ const found = recovered.records.find(
86
+ (r) => r.collection === original.collection && r.rkey === original.rkey,
87
+ )
88
+ expect(found).toBeDefined()
89
+ expect(found!.cid.equals(original.cid)).toBe(true)
90
+ }
91
+ })
92
+
93
+ it('recovers record values intact', async () => {
94
+ const car = await carFor(records)
95
+ const { records: recovered } = await verifyRepoCarFull([car], {
96
+ space: SPACE,
97
+ author: AUTHOR,
98
+ didKey: keypair.did(),
99
+ })
100
+ const post = recovered.find((r) => r.rkey === '3kbcq3p7ad401')
101
+ expect(post?.record).toEqual({ text: 'hello' })
102
+ })
103
+
104
+ it('serializes an empty repo', async () => {
105
+ const car = await carFor([])
106
+ const recovered = await verifyRepoCarFull([car], {
107
+ space: SPACE,
108
+ author: AUTHOR,
109
+ didKey: keypair.did(),
110
+ })
111
+ expect(Object.keys(recovered.index)).toHaveLength(0)
112
+ expect(recovered.records).toHaveLength(0)
113
+ expect(recovered.repo.setHash.isEmpty()).toBe(true)
114
+ })
115
+
116
+ it('emits record blocks in the index order', async () => {
117
+ const car = await carFor(records)
118
+ const { index } = await verifyRepoCarFull([car], {
119
+ space: SPACE,
120
+ author: AUTHOR,
121
+ didKey: keypair.did(),
122
+ })
123
+ // Re-read the raw blocks and check they follow the index's declared order.
124
+ const { blocks } = await readCarStream([car])
125
+ const cids: Cid[] = []
126
+ for await (const block of blocks) cids.push(block.cid)
127
+ const recordCids = cids.slice(2).map((c) => c.toString())
128
+ const indexCids = Object.values(index).map((c) => c.toString())
129
+ expect(recordCids).toEqual(indexCids)
130
+ })
131
+
132
+ it('authenticates the index without reading records', async () => {
133
+ const car = await carFor(records)
134
+ // verifyRepoCar validates commit + index eagerly; records stay lazy.
135
+ const { index, repo, commit } = await verifyRepoCar([car], {
136
+ space: SPACE,
137
+ author: AUTHOR,
138
+ didKey: keypair.did(),
139
+ })
140
+ expect(repo.matches(commit)).toBe(true)
141
+ expect(Object.keys(index)).toHaveLength(records.length)
142
+ })
143
+
144
+ describe('rejects tampering', () => {
145
+ it('rejects a commit signed by another key', async () => {
146
+ const other = await Secp256k1Keypair.create()
147
+ const car = await carFor(records)
148
+ await expect(
149
+ verifyRepoCarFull([car], {
150
+ space: SPACE,
151
+ author: AUTHOR,
152
+ didKey: other.did(),
153
+ }),
154
+ ).rejects.toThrow(RepoVerificationError)
155
+ })
156
+
157
+ it('rejects a repo verified against the wrong space', async () => {
158
+ const car = await carFor(records)
159
+ await expect(
160
+ verifyRepoCarFull([car], {
161
+ space: 'at://did:example:space/space/app.bsky.group/other',
162
+ author: AUTHOR,
163
+ didKey: keypair.did(),
164
+ }),
165
+ ).rejects.toThrow(/commit failed verification/)
166
+ })
167
+
168
+ it('rejects a repo verified against the wrong author', async () => {
169
+ const car = await carFor(records)
170
+ await expect(
171
+ verifyRepoCarFull([car], {
172
+ space: SPACE,
173
+ author: 'did:example:bob',
174
+ didKey: keypair.did(),
175
+ }),
176
+ ).rejects.toThrow(/commit failed verification/)
177
+ })
178
+
179
+ it('rejects an index that disagrees with the commit hash', async () => {
180
+ // A commit over two records, but an index listing all three.
181
+ const commit = await commitFor(records.slice(0, 2))
182
+ const car = await collect(serializeRepo(commit, records))
183
+ await expect(
184
+ verifyRepoCarFull([car], {
185
+ space: SPACE,
186
+ author: AUTHOR,
187
+ didKey: keypair.did(),
188
+ }),
189
+ ).rejects.toThrow(/index does not match the commit hash/)
190
+ })
191
+
192
+ it('rejects a record block whose bytes do not match its cid', async () => {
193
+ const commit = await commitFor(records)
194
+ const index: Record<string, Cid> = {}
195
+ for (const r of records) {
196
+ index[`${r.collection}/${r.rkey}`] = r.cid
197
+ }
198
+ const commitBytes = encode(commit)
199
+ const indexBytes = encode(index)
200
+ const [commitCid, indexCid] = await Promise.all([
201
+ cidForCbor(commitBytes),
202
+ cidForCbor(indexBytes),
203
+ ])
204
+
205
+ const byPath = new Map(
206
+ records.map((r) => [`${r.collection}/${r.rkey}`, r]),
207
+ )
208
+ const parts = [
209
+ encodeCarHeader([commitCid, indexCid]),
210
+ encodeCarBlock({ cid: commitCid, bytes: commitBytes }),
211
+ encodeCarBlock({ cid: indexCid, bytes: indexBytes }),
212
+ ...Object.keys(index).map((path, i) => {
213
+ const record = byPath.get(path)!
214
+ // Swap in bytes that don't hash to the advertised cid.
215
+ const bytes = i === 0 ? encode({ text: 'tampered' }) : record.bytes
216
+ return encodeCarBlock({ cid: record.cid, bytes })
217
+ }),
218
+ ]
219
+
220
+ const { records: stream } = await verifyRepoCar([Buffer.concat(parts)], {
221
+ space: SPACE,
222
+ author: AUTHOR,
223
+ didKey: keypair.did(),
224
+ })
225
+ await expect(async () => {
226
+ for await (const _ of stream) {
227
+ // drain
228
+ }
229
+ }).rejects.toThrow(/not a valid cid for bytes/i)
230
+ })
231
+
232
+ it('rejects a CAR missing a record named in the index', async () => {
233
+ const commit = await commitFor(records)
234
+ // Serialize the full index but drop the last block.
235
+ const full = await collect(serializeRepo(commit, records))
236
+ const { blocks } = await readCarStream([full])
237
+ const kept: Uint8Array[] = []
238
+ const all = []
239
+ for await (const block of blocks) all.push(block)
240
+ for (const block of all.slice(0, -1)) kept.push(encodeCarBlock(block))
241
+
242
+ const { roots } = await readCarStream([full])
243
+ const truncated = Buffer.concat([encodeCarHeader(roots), ...kept])
244
+
245
+ const { records: stream } = await verifyRepoCar([truncated], {
246
+ space: SPACE,
247
+ author: AUTHOR,
248
+ didKey: keypair.did(),
249
+ })
250
+ await expect(async () => {
251
+ for await (const _ of stream) {
252
+ // drain
253
+ }
254
+ }).rejects.toThrow(/missing 1 record\(s\) named in the index/)
255
+ })
256
+ })
257
+
258
+ describe('repo index', () => {
259
+ it('folds into the same set hash as the records it describes', async () => {
260
+ const car = await carFor(records)
261
+ const { index, repo } = await verifyRepoCarFull([car], {
262
+ space: SPACE,
263
+ author: AUTHOR,
264
+ didKey: keypair.did(),
265
+ })
266
+ expect(
267
+ RepoCommit.fromIndex(index).setHash.equals(
268
+ RepoCommit.fromRecords(records).setHash,
269
+ ),
270
+ ).toBe(true)
271
+ expect(repo.setHash.equals(RepoCommit.fromRecords(records).setHash)).toBe(
272
+ true,
273
+ )
274
+ })
275
+
276
+ it('rejects an index whose values are not cids', async () => {
277
+ const commit = await commitFor(records)
278
+ const commitBytes = encode(commit)
279
+ const indexBytes = encode({ 'app.bsky.feed.post/1': 'not-a-cid' })
280
+ const [commitRoot, indexRoot] = await Promise.all([
281
+ cidForCbor(commitBytes),
282
+ cidForCbor(indexBytes),
283
+ ])
284
+ const car = Buffer.concat([
285
+ encodeCarHeader([commitRoot, indexRoot]),
286
+ encodeCarBlock({ cid: commitRoot, bytes: commitBytes }),
287
+ encodeCarBlock({ cid: indexRoot, bytes: indexBytes }),
288
+ ])
289
+
290
+ await expect(
291
+ verifyRepoCarFull([car], {
292
+ space: SPACE,
293
+ author: AUTHOR,
294
+ didKey: keypair.did(),
295
+ }),
296
+ ).rejects.toThrow(/invalid repo index/)
297
+ })
298
+ })
299
+
300
+ // A syncer advances its own copy over the oplog, then checks it against the
301
+ // repo's signed commit. A mismatch means it must fall back to full recovery.
302
+ describe('incremental sync', () => {
303
+ const createOps = (recs: SerializedRecord[]) =>
304
+ recs.map((r) => ({
305
+ collection: r.collection,
306
+ rkey: r.rkey,
307
+ cid: r.cid,
308
+ prev: null,
309
+ }))
310
+
311
+ it('catches up to a commit by replaying the oplog', async () => {
312
+ const commit = await commitFor(records)
313
+ const local = new RepoCommit().applyOps(createOps(records))
314
+
315
+ expect(await verifyCommit(commit, ctx, keypair.did())).toBe(true)
316
+ expect(local.matches(commit)).toBe(true)
317
+ })
318
+
319
+ it('detects divergence when an op was missed', async () => {
320
+ const commit = await commitFor(records)
321
+ const local = new RepoCommit().applyOps(createOps(records.slice(0, 2)))
322
+ expect(local.matches(commit)).toBe(false)
323
+ })
324
+
325
+ it('replays deletes and updates', () => {
326
+ const [a, b] = records
327
+ const local = RepoCommit.fromRecords([a]).applyOps([
328
+ { collection: a.collection, rkey: a.rkey, cid: b.cid, prev: a.cid },
329
+ { collection: a.collection, rkey: a.rkey, cid: null, prev: b.cid },
330
+ ])
331
+ expect(local.setHash.isEmpty()).toBe(true)
332
+ })
333
+ })
334
+
335
+ describe('CAR framing', () => {
336
+ // Bad framing surfaces from the CAR reader as a plain Error, as it does for
337
+ // public repos.
338
+ it('rejects a truncated CAR', async () => {
339
+ const car = await carFor(records)
340
+ await expect(
341
+ verifyRepoCar([car.subarray(0, 3)], {
342
+ space: SPACE,
343
+ author: AUTHOR,
344
+ didKey: keypair.did(),
345
+ }),
346
+ ).rejects.toThrow(/not enough data/)
347
+ })
348
+
349
+ it('rejects a CAR with the wrong root count', async () => {
350
+ const commit = await commitFor(records)
351
+ const commitBytes = encode({
352
+ ver: commit.ver,
353
+ hash: commit.hash,
354
+ ikm: commit.ikm,
355
+ sig: commit.sig,
356
+ mac: commit.mac,
357
+ rev: commit.rev,
358
+ })
359
+ const commitCid = await cidForCbor(commitBytes)
360
+ const car = Buffer.concat([
361
+ encodeCarHeader([commitCid]),
362
+ encodeCarBlock({ cid: commitCid, bytes: commitBytes }),
363
+ ])
364
+ await expect(
365
+ verifyRepoCar([car], {
366
+ space: SPACE,
367
+ author: AUTHOR,
368
+ didKey: keypair.did(),
369
+ }),
370
+ ).rejects.toThrow(/expected 2 car roots/)
371
+ })
372
+
373
+ it('reads a CAR delivered in arbitrary chunk boundaries', async () => {
374
+ const car = await carFor(records)
375
+ // One byte at a time — exercises the reader's buffering.
376
+ const chunks = Array.from(car, (byte) => new Uint8Array([byte]))
377
+ const recovered = await verifyRepoCarFull(chunks, {
378
+ space: SPACE,
379
+ author: AUTHOR,
380
+ didKey: keypair.did(),
381
+ })
382
+ expect(recovered.records).toHaveLength(records.length)
383
+ })
384
+ })
385
+ })
@@ -0,0 +1,16 @@
1
+ {
2
+ "extends": ["../../tsconfig/node.tsconfig.json"],
3
+ "include": ["./src"],
4
+ "exclude": ["**/*.test.ts"],
5
+ "references": [
6
+ { "path": "../common/tsconfig.build.json" },
7
+ { "path": "../crypto/tsconfig.build.json" },
8
+ { "path": "../lex/lex-cbor/tsconfig.build.json" },
9
+ { "path": "../lex/lex-data/tsconfig.build.json" },
10
+ { "path": "../oauth/jwk/tsconfig.build.json" },
11
+ ],
12
+ "compilerOptions": {
13
+ "rootDir": "./src",
14
+ "outDir": "./dist",
15
+ },
16
+ }