@pdsjs/spaces 1.0.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Chad Miller
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@pdsjs/spaces",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "main": "./src/index.js",
6
+ "types": "./src/index.d.ts",
7
+ "exports": {
8
+ ".": "./src/index.js",
9
+ "./blake3": "./src/blake3.js",
10
+ "./lthash": "./src/lthash.js",
11
+ "./path": "./src/path.js",
12
+ "./uri": "./src/uri.js",
13
+ "./mac": "./src/mac.js",
14
+ "./verifier": "./src/verifier.js",
15
+ "./commit": "./src/commit.js",
16
+ "./memory-storage": "./src/memory-storage.js",
17
+ "./routes": "./src/routes.js",
18
+ "./space-row": "./src/space-row.js",
19
+ "./writer": "./src/writer.js",
20
+ "./car": "./src/car.js",
21
+ "./token": "./src/token.js",
22
+ "./authority": "./src/authority.js",
23
+ "./service-auth": "./src/service-auth.js"
24
+ },
25
+ "dependencies": {
26
+ "@pdsjs/core": "1.0.0"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "license": "MIT",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://tangled.org/chadtmiller.com/pds.js",
35
+ "directory": "packages/spaces"
36
+ },
37
+ "files": [
38
+ "src"
39
+ ]
40
+ }
@@ -0,0 +1,47 @@
1
+ export declare const SPACE_KEY_ID = "#atproto_space";
2
+ export declare const SPACE_HOST_ID = "#atproto_space_host";
3
+ export declare const ATPROTO_KEY_ID = "#atproto";
4
+ export declare const ATPROTO_PDS_ID = "#atproto_pds";
5
+ export declare class SpaceAuthorityError extends Error {
6
+ code: string;
7
+ /** @param {string} message @param {string} [code] */
8
+ constructor(message: string, code?: string);
9
+ }
10
+ /**
11
+ * The key that verifies a space authority's credentials.
12
+ *
13
+ * @param {any} didDoc
14
+ * @param {string} did
15
+ * @returns {string} did:key
16
+ */
17
+ export declare function spaceSigningKey(didDoc: any, did: string): string;
18
+ /**
19
+ * The key that verifies an ordinary account's signatures — delegation tokens and
20
+ * repo commits. Always the account's `#atproto` key.
21
+ *
22
+ * @param {any} didDoc
23
+ * @param {string} did
24
+ * @returns {string} did:key
25
+ */
26
+ export declare function atprotoSigningKey(didDoc: any, did: string): string;
27
+ /**
28
+ * Where a space's authority serves its host endpoints.
29
+ *
30
+ * @param {any} didDoc
31
+ * @param {string} did
32
+ * @returns {string}
33
+ */
34
+ export declare function spaceHostEndpoint(didDoc: any, did: string): string;
35
+ /**
36
+ * The `aud` a delegation token must carry for a given authority.
37
+ * @param {string} spaceDid
38
+ * @returns {string}
39
+ */
40
+ export declare function spaceHostAudience(spaceDid: string): string;
41
+ /**
42
+ * Build the `getSigningKey` callback verifySpaceToken expects.
43
+ *
44
+ * @param {(did: string) => Promise<any>} resolveDid
45
+ * @returns {(iss: string, kid?: string) => Promise<string>}
46
+ */
47
+ export declare function didKeyResolver(resolveDid: (did: string) => Promise<any>): (iss: string, kid?: string) => Promise<string>;
@@ -0,0 +1,148 @@
1
+ // @pdsjs/spaces/authority - resolving a space authority from its DID document.
2
+ //
3
+ // Proposal 0016 gives a space authority two optional DID document entries, each
4
+ // with a fallback to the account's ordinary atproto entry:
5
+ //
6
+ // verification method #atproto_space -> falls back to #atproto
7
+ // service #atproto_space_host -> falls back to #atproto_pds
8
+ //
9
+ // The service fallback is why a space-unaware PDS still receives space requests:
10
+ // clients resolve the space host to its ordinary PDS endpoint.
11
+
12
+ export const SPACE_KEY_ID = '#atproto_space';
13
+ export const SPACE_HOST_ID = '#atproto_space_host';
14
+ export const ATPROTO_KEY_ID = '#atproto';
15
+ export const ATPROTO_PDS_ID = '#atproto_pds';
16
+
17
+ export class SpaceAuthorityError extends Error {
18
+ /** @param {string} message @param {string} [code] */
19
+ constructor(message, code = 'SpaceAuthorityNotFound') {
20
+ super(message);
21
+ this.name = 'SpaceAuthorityError';
22
+ this.code = code;
23
+ }
24
+ }
25
+
26
+ /**
27
+ * @param {any} didDoc
28
+ * @param {string} did
29
+ * @param {string} id - fragment, e.g. '#atproto'
30
+ * @returns {string|null} did:key, or null when absent
31
+ */
32
+ function verificationMethod(didDoc, did, id) {
33
+ const methods = didDoc?.verificationMethod ?? [];
34
+ for (const method of methods) {
35
+ // A DID document may write the id either fully-qualified or as a bare
36
+ // fragment; accept both.
37
+ if (method?.id === id || method?.id === `${did}${id}`) {
38
+ return method.publicKeyMultibase
39
+ ? `did:key:${method.publicKeyMultibase}`
40
+ : null;
41
+ }
42
+ }
43
+ return null;
44
+ }
45
+
46
+ /**
47
+ * @param {any} didDoc
48
+ * @param {string} did
49
+ * @param {string} id
50
+ * @returns {string|null}
51
+ */
52
+ function serviceEndpoint(didDoc, did, id) {
53
+ const services = didDoc?.service ?? [];
54
+ for (const service of services) {
55
+ if (service?.id === id || service?.id === `${did}${id}`) {
56
+ return typeof service.serviceEndpoint === 'string'
57
+ ? service.serviceEndpoint
58
+ : null;
59
+ }
60
+ }
61
+ return null;
62
+ }
63
+
64
+ /**
65
+ * The key that verifies a space authority's credentials.
66
+ *
67
+ * @param {any} didDoc
68
+ * @param {string} did
69
+ * @returns {string} did:key
70
+ */
71
+ export function spaceSigningKey(didDoc, did) {
72
+ const key =
73
+ verificationMethod(didDoc, did, SPACE_KEY_ID) ??
74
+ verificationMethod(didDoc, did, ATPROTO_KEY_ID);
75
+ if (!key) {
76
+ throw new SpaceAuthorityError(
77
+ `No space signing key for ${did}`,
78
+ 'SpaceKeyNotFound',
79
+ );
80
+ }
81
+ return key;
82
+ }
83
+
84
+ /**
85
+ * The key that verifies an ordinary account's signatures — delegation tokens and
86
+ * repo commits. Always the account's `#atproto` key.
87
+ *
88
+ * @param {any} didDoc
89
+ * @param {string} did
90
+ * @returns {string} did:key
91
+ */
92
+ export function atprotoSigningKey(didDoc, did) {
93
+ const key = verificationMethod(didDoc, did, ATPROTO_KEY_ID);
94
+ if (!key) {
95
+ throw new SpaceAuthorityError(
96
+ `No atproto signing key for ${did}`,
97
+ 'SigningKeyNotFound',
98
+ );
99
+ }
100
+ return key;
101
+ }
102
+
103
+ /**
104
+ * Where a space's authority serves its host endpoints.
105
+ *
106
+ * @param {any} didDoc
107
+ * @param {string} did
108
+ * @returns {string}
109
+ */
110
+ export function spaceHostEndpoint(didDoc, did) {
111
+ const endpoint =
112
+ serviceEndpoint(didDoc, did, SPACE_HOST_ID) ??
113
+ serviceEndpoint(didDoc, did, ATPROTO_PDS_ID);
114
+ if (!endpoint) {
115
+ throw new SpaceAuthorityError(
116
+ `No space host for ${did}`,
117
+ 'SpaceHostNotFound',
118
+ );
119
+ }
120
+ return endpoint;
121
+ }
122
+
123
+ /**
124
+ * The `aud` a delegation token must carry for a given authority.
125
+ * @param {string} spaceDid
126
+ * @returns {string}
127
+ */
128
+ export function spaceHostAudience(spaceDid) {
129
+ return `${spaceDid}${SPACE_HOST_ID}`;
130
+ }
131
+
132
+ /**
133
+ * Build the `getSigningKey` callback verifySpaceToken expects.
134
+ *
135
+ * @param {(did: string) => Promise<any>} resolveDid
136
+ * @returns {(iss: string, kid?: string) => Promise<string>}
137
+ */
138
+ export function didKeyResolver(resolveDid) {
139
+ return async (iss, kid) => {
140
+ const didDoc = await resolveDid(iss);
141
+ if (!didDoc) {
142
+ throw new SpaceAuthorityError(`Could not resolve ${iss}`, 'DidNotFound');
143
+ }
144
+ return kid === SPACE_KEY_ID
145
+ ? spaceSigningKey(didDoc, iss)
146
+ : atprotoSigningKey(didDoc, iss);
147
+ };
148
+ }
@@ -0,0 +1,14 @@
1
+ export type Output = {
2
+ cv: Uint32Array;
3
+ block: Uint32Array;
4
+ counter: number;
5
+ blockLen: number;
6
+ flags: number;
7
+ };
8
+ /**
9
+ * Unkeyed BLAKE3 with extendable output.
10
+ * @param {Uint8Array} input
11
+ * @param {number} [outLen] - output length in bytes, default 32
12
+ * @returns {Uint8Array}
13
+ */
14
+ export declare function blake3(input: Uint8Array, outLen?: number): Uint8Array;
package/src/blake3.js ADDED
@@ -0,0 +1,272 @@
1
+ // @pdsjs/spaces/blake3 - vendored BLAKE3 with extendable output.
2
+ //
3
+ // This is the only hand-written cryptographic primitive in pds.js. WebCrypto
4
+ // has no BLAKE3, and LtHash (proposal 0016) is defined in terms of it. Verified
5
+ // against the official BLAKE3 test vectors in test/fixtures/blake3-vectors.json;
6
+ // do not modify without re-running packages/spaces/test/blake3.test.js.
7
+ //
8
+ // Transliterated from the BLAKE3 reference implementation
9
+ // (https://github.com/BLAKE3-team/BLAKE3, reference_impl/reference_impl.rs),
10
+ // per the spec at https://github.com/BLAKE3-team/BLAKE3-specs. Only the
11
+ // unkeyed, extendable-output mode is implemented — no keyed hash, no
12
+ // derive-key mode.
13
+
14
+ const IV = new Uint32Array([
15
+ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c,
16
+ 0x1f83d9ab, 0x5be0cd19,
17
+ ]);
18
+
19
+ const MSG_PERMUTATION = [2, 6, 3, 10, 7, 0, 4, 13, 1, 11, 12, 5, 9, 14, 15, 8];
20
+
21
+ const CHUNK_START = 1 << 0;
22
+ const CHUNK_END = 1 << 1;
23
+ const PARENT = 1 << 2;
24
+ const ROOT = 1 << 3;
25
+
26
+ const BLOCK_LEN = 64;
27
+ const CHUNK_LEN = 1024;
28
+
29
+ /**
30
+ * A deferred compression: the final block of a chunk or parent, not yet
31
+ * compressed, so the ROOT flag can still be applied to the last one.
32
+ * @typedef {Object} Output
33
+ * @property {Uint32Array} cv
34
+ * @property {Uint32Array} block
35
+ * @property {number} counter
36
+ * @property {number} blockLen
37
+ * @property {number} flags
38
+ */
39
+
40
+ /**
41
+ * @param {number} x
42
+ * @param {number} n
43
+ * @returns {number}
44
+ */
45
+ const rotr = (x, n) => ((x >>> n) | (x << (32 - n))) >>> 0;
46
+
47
+ /**
48
+ * The quarter-round. Rotations are 16, 12, 8, 7.
49
+ * @param {Uint32Array} s - 16-word compression state, mutated in place
50
+ * @param {number} a
51
+ * @param {number} b
52
+ * @param {number} c
53
+ * @param {number} d
54
+ * @param {number} mx
55
+ * @param {number} my
56
+ * @returns {void}
57
+ */
58
+ function g(s, a, b, c, d, mx, my) {
59
+ s[a] = (s[a] + s[b] + mx) >>> 0;
60
+ s[d] = rotr(s[d] ^ s[a], 16);
61
+ s[c] = (s[c] + s[d]) >>> 0;
62
+ s[b] = rotr(s[b] ^ s[c], 12);
63
+ s[a] = (s[a] + s[b] + my) >>> 0;
64
+ s[d] = rotr(s[d] ^ s[a], 8);
65
+ s[c] = (s[c] + s[d]) >>> 0;
66
+ s[b] = rotr(s[b] ^ s[c], 7);
67
+ }
68
+
69
+ /**
70
+ * Four column mixes then four diagonal mixes.
71
+ * @param {Uint32Array} s - 16-word compression state, mutated in place
72
+ * @param {Uint32Array} m - 16 message words
73
+ * @returns {void}
74
+ */
75
+ function round(s, m) {
76
+ g(s, 0, 4, 8, 12, m[0], m[1]);
77
+ g(s, 1, 5, 9, 13, m[2], m[3]);
78
+ g(s, 2, 6, 10, 14, m[4], m[5]);
79
+ g(s, 3, 7, 11, 15, m[6], m[7]);
80
+ g(s, 0, 5, 10, 15, m[8], m[9]);
81
+ g(s, 1, 6, 11, 12, m[10], m[11]);
82
+ g(s, 2, 7, 8, 13, m[12], m[13]);
83
+ g(s, 3, 4, 9, 14, m[14], m[15]);
84
+ }
85
+
86
+ /**
87
+ * @param {Uint32Array} m - 16 message words
88
+ * @returns {Uint32Array} the message words reordered by MSG_PERMUTATION
89
+ */
90
+ function permute(m) {
91
+ const out = new Uint32Array(16);
92
+ for (let i = 0; i < 16; i++) out[i] = m[MSG_PERMUTATION[i]];
93
+ return out;
94
+ }
95
+
96
+ /**
97
+ * @param {Uint32Array} cv - 8-word chaining value
98
+ * @param {Uint32Array} block - 16 message words
99
+ * @param {number} counter
100
+ * @param {number} blockLen
101
+ * @param {number} flags
102
+ * @returns {Uint32Array} all 16 state words — the first 8 are the chaining
103
+ * value, all 16 are needed for extended output.
104
+ */
105
+ function compress(cv, block, counter, blockLen, flags) {
106
+ const s = new Uint32Array(16);
107
+ s.set(cv, 0);
108
+ s[8] = IV[0];
109
+ s[9] = IV[1];
110
+ s[10] = IV[2];
111
+ s[11] = IV[3];
112
+ s[12] = counter >>> 0;
113
+ s[13] = Math.floor(counter / 2 ** 32) >>> 0;
114
+ s[14] = blockLen;
115
+ s[15] = flags;
116
+
117
+ let m = block;
118
+ for (let i = 0; i < 7; i++) {
119
+ round(s, m);
120
+ if (i < 6) m = permute(m);
121
+ }
122
+ for (let i = 0; i < 8; i++) {
123
+ s[i] = (s[i] ^ s[i + 8]) >>> 0;
124
+ s[i + 8] = (s[i + 8] ^ cv[i]) >>> 0;
125
+ }
126
+ return s;
127
+ }
128
+
129
+ /**
130
+ * Read 16 little-endian uint32s from a 64-byte block.
131
+ * @param {Uint8Array} bytes
132
+ * @param {number} offset
133
+ * @returns {Uint32Array}
134
+ */
135
+ function wordsFromBlock(bytes, offset) {
136
+ const m = new Uint32Array(16);
137
+ for (let i = 0; i < 16; i++) {
138
+ const o = offset + i * 4;
139
+ m[i] =
140
+ (bytes[o] |
141
+ (bytes[o + 1] << 8) |
142
+ (bytes[o + 2] << 16) |
143
+ (bytes[o + 3] << 24)) >>>
144
+ 0;
145
+ }
146
+ return m;
147
+ }
148
+
149
+ /**
150
+ * @param {Output} o
151
+ * @returns {Uint32Array} the 8-word chaining value
152
+ */
153
+ function outputChainingValue(o) {
154
+ return compress(o.cv, o.block, o.counter, o.blockLen, o.flags).slice(0, 8);
155
+ }
156
+
157
+ /**
158
+ * @param {Output} o
159
+ * @param {number} outLen - output length in bytes
160
+ * @returns {Uint8Array}
161
+ */
162
+ function rootOutputBytes(o, outLen) {
163
+ const out = new Uint8Array(outLen);
164
+ let i = 0;
165
+ let counter = 0;
166
+ while (i < outLen) {
167
+ const words = compress(o.cv, o.block, counter, o.blockLen, o.flags | ROOT);
168
+ for (let w = 0; w < 16 && i < outLen; w++) {
169
+ for (let b = 0; b < 4 && i < outLen; b++) {
170
+ out[i++] = (words[w] >>> (8 * b)) & 0xff;
171
+ }
172
+ }
173
+ counter++;
174
+ }
175
+ return out;
176
+ }
177
+
178
+ /**
179
+ * @param {Uint8Array} input
180
+ * @param {number} start - byte offset of the chunk within input
181
+ * @param {number} len - chunk length in bytes
182
+ * @param {number} chunkCounter
183
+ * @returns {Output}
184
+ */
185
+ function chunkOutput(input, start, len, chunkCounter) {
186
+ let cv = IV.slice(0, 8);
187
+ let blockFlags = CHUNK_START;
188
+ let pos = 0;
189
+ while (len - pos > BLOCK_LEN) {
190
+ const m = wordsFromBlock(input, start + pos);
191
+ cv = compress(cv, m, chunkCounter, BLOCK_LEN, blockFlags).slice(0, 8);
192
+ pos += BLOCK_LEN;
193
+ blockFlags = 0;
194
+ }
195
+ const blockLen = len - pos;
196
+ const padded = new Uint8Array(BLOCK_LEN);
197
+ padded.set(input.subarray(start + pos, start + len));
198
+ return {
199
+ cv,
200
+ block: wordsFromBlock(padded, 0),
201
+ counter: chunkCounter,
202
+ blockLen,
203
+ flags: blockFlags | CHUNK_END,
204
+ };
205
+ }
206
+
207
+ /**
208
+ * @param {Uint32Array} leftCv - 8-word chaining value
209
+ * @param {Uint32Array} rightCv - 8-word chaining value
210
+ * @returns {Output}
211
+ */
212
+ function parentOutput(leftCv, rightCv) {
213
+ const block = new Uint32Array(16);
214
+ block.set(leftCv, 0);
215
+ block.set(rightCv, 8);
216
+ return {
217
+ cv: IV.slice(0, 8),
218
+ block,
219
+ counter: 0,
220
+ blockLen: BLOCK_LEN,
221
+ flags: PARENT,
222
+ };
223
+ }
224
+
225
+ /**
226
+ * Unkeyed BLAKE3 with extendable output.
227
+ * @param {Uint8Array} input
228
+ * @param {number} [outLen] - output length in bytes, default 32
229
+ * @returns {Uint8Array}
230
+ */
231
+ export function blake3(input, outLen = 32) {
232
+ const chunks = Math.max(1, Math.ceil(input.length / CHUNK_LEN));
233
+ /** @type {Uint32Array[]} */
234
+ const stack = [];
235
+ /** @type {Output|undefined} */
236
+ let output;
237
+
238
+ for (let i = 0; i < chunks; i++) {
239
+ const start = i * CHUNK_LEN;
240
+ const len = Math.min(CHUNK_LEN, Math.max(0, input.length - start));
241
+ const chunk = chunkOutput(input, start, len, i);
242
+
243
+ // The last chunk stays an Output so the ROOT flag can reach it.
244
+ if (i === chunks - 1) {
245
+ output = chunk;
246
+ break;
247
+ }
248
+
249
+ // Merge while the completed-chunk count has a trailing zero bit.
250
+ let cv = outputChainingValue(chunk);
251
+ let n = i + 1;
252
+ while ((n & 1) === 0) {
253
+ // Guaranteed non-empty: n's trailing zero count never exceeds the
254
+ // number of merges already pushed onto the stack.
255
+ cv = outputChainingValue(
256
+ parentOutput(/** @type {Uint32Array} */ (stack.pop()), cv),
257
+ );
258
+ n >>= 1;
259
+ }
260
+ stack.push(cv);
261
+ }
262
+
263
+ // At least one chunk always runs, so `output` is set by the time either
264
+ // loop below reads it.
265
+ while (stack.length > 0) {
266
+ output = parentOutput(
267
+ /** @type {Uint32Array} */ (stack.pop()),
268
+ outputChainingValue(/** @type {Output} */ (output)),
269
+ );
270
+ }
271
+ return rootOutputBytes(/** @type {Output} */ (output), outLen);
272
+ }
package/src/car.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Canonical DAG-CBOR map key order: shortest key first, then bytewise.
3
+ *
4
+ * This is NOT plain lexicographic order — `a/b` sorts before `aa/b`. A consumer
5
+ * walks the index in the order the CBOR encoder emitted its keys and expects the
6
+ * record blocks to follow, so both have to use this comparison.
7
+ *
8
+ * @param {string} a
9
+ * @param {string} b
10
+ * @returns {number}
11
+ */
12
+ export declare function byCanonicalKey(a: string, b: string): number;
13
+ export type SerializedRecord = {
14
+ collection: string;
15
+ rkey: string;
16
+ cid: string;
17
+ /**
18
+ * - DAG-CBOR encoded record
19
+ */
20
+ value: Uint8Array;
21
+ };
22
+ /**
23
+ * @typedef {Object} SerializedRecord
24
+ * @property {string} collection
25
+ * @property {string} rkey
26
+ * @property {string} cid
27
+ * @property {Uint8Array} value - DAG-CBOR encoded record
28
+ */
29
+ /**
30
+ * Serialize a repo as a CAR: two roots in order — the signed commit, then the
31
+ * index — followed by one block per index entry, in the index's order. Blobs are
32
+ * excluded.
33
+ *
34
+ * Records are collected up front because the index has to precede the blocks it
35
+ * describes.
36
+ *
37
+ * @param {import('./commit.js').SignedCommit} commit
38
+ * @param {Iterable<SerializedRecord>} records
39
+ * @returns {Promise<Uint8Array>}
40
+ */
41
+ export declare function serializeRepo(commit: import('./commit.js').SignedCommit, records: Iterable<SerializedRecord>): Promise<Uint8Array>;
package/src/car.js ADDED
@@ -0,0 +1,98 @@
1
+ // @pdsjs/spaces/car - permissioned repo CAR serialization.
2
+
3
+ import {
4
+ buildCarFile,
5
+ CID,
6
+ cborEncodeDagCbor,
7
+ cidToBytes,
8
+ cidToString,
9
+ createCid,
10
+ } from '@pdsjs/core/repo';
11
+ import { formatRecordPath } from './path.js';
12
+
13
+ /**
14
+ * Canonical DAG-CBOR map key order: shortest key first, then bytewise.
15
+ *
16
+ * This is NOT plain lexicographic order — `a/b` sorts before `aa/b`. A consumer
17
+ * walks the index in the order the CBOR encoder emitted its keys and expects the
18
+ * record blocks to follow, so both have to use this comparison.
19
+ *
20
+ * @param {string} a
21
+ * @param {string} b
22
+ * @returns {number}
23
+ */
24
+ export function byCanonicalKey(a, b) {
25
+ if (a.length !== b.length) return a.length - b.length;
26
+ return a < b ? -1 : a > b ? 1 : 0;
27
+ }
28
+
29
+ /**
30
+ * @typedef {Object} SerializedRecord
31
+ * @property {string} collection
32
+ * @property {string} rkey
33
+ * @property {string} cid
34
+ * @property {Uint8Array} value - DAG-CBOR encoded record
35
+ */
36
+
37
+ /**
38
+ * Serialize a repo as a CAR: two roots in order — the signed commit, then the
39
+ * index — followed by one block per index entry, in the index's order. Blobs are
40
+ * excluded.
41
+ *
42
+ * Records are collected up front because the index has to precede the blocks it
43
+ * describes.
44
+ *
45
+ * @param {import('./commit.js').SignedCommit} commit
46
+ * @param {Iterable<SerializedRecord>} records
47
+ * @returns {Promise<Uint8Array>}
48
+ */
49
+ export async function serializeRepo(commit, records) {
50
+ /** @type {Map<string, SerializedRecord>} */
51
+ const byPath = new Map();
52
+ for (const record of records) {
53
+ byPath.set(formatRecordPath(record.collection, record.rkey), record);
54
+ }
55
+ const paths = [...byPath.keys()].sort(byCanonicalKey);
56
+
57
+ /** @type {Record<string, any>} */
58
+ const index = {};
59
+ for (const path of paths) {
60
+ // A CID instance is what the DAG-CBOR encoder emits as a tag-42 link.
61
+ index[path] = new CID(
62
+ cidToBytes(/** @type {SerializedRecord} */ (byPath.get(path)).cid),
63
+ );
64
+ }
65
+
66
+ const commitBytes = cborEncodeDagCbor(toCommitBlock(commit));
67
+ const indexBytes = cborEncodeDagCbor(index);
68
+ const commitRoot = cidToString(await createCid(commitBytes));
69
+ const indexRoot = cidToString(await createCid(indexBytes));
70
+
71
+ const blocks = [
72
+ { cid: commitRoot, data: commitBytes },
73
+ { cid: indexRoot, data: indexBytes },
74
+ ...paths.map((path) => {
75
+ const record = /** @type {SerializedRecord} */ (byPath.get(path));
76
+ return { cid: record.cid, data: record.value };
77
+ }),
78
+ ];
79
+
80
+ return buildCarFile([commitRoot, indexRoot], blocks);
81
+ }
82
+
83
+ /**
84
+ * The commit block is plain DAG-CBOR with byte fields, not the JSON `$bytes`
85
+ * shape used on the wire elsewhere.
86
+ * @param {import('./commit.js').SignedCommit} commit
87
+ * @returns {Object}
88
+ */
89
+ function toCommitBlock(commit) {
90
+ return {
91
+ ver: commit.ver,
92
+ hash: commit.hash,
93
+ ikm: commit.ikm,
94
+ sig: commit.sig,
95
+ mac: commit.mac,
96
+ rev: commit.rev,
97
+ };
98
+ }