@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.
@@ -0,0 +1,462 @@
1
+ // @pdsjs/spaces/handlers/write - com.atproto.space write endpoints.
2
+ //
3
+ // createRecord, putRecord, deleteRecord and applyWrites are thin shells over
4
+ // applyWrites() in ../writer.js: they validate the request, translate it into
5
+ // write descriptors, and shape the response. All the repo logic lives there.
6
+ //
7
+ // Authorization is two checks: the caller may only write its own repo, and the
8
+ // token's `space:` scope must cover the target space, action and collection.
9
+
10
+ import { createTid } from '@pdsjs/core/repo';
11
+ import { ScopePermissions } from '@pdsjs/core/scope';
12
+ import { spaceHostEndpoint } from '../authority.js';
13
+ import { LtHash } from '../lthash.js';
14
+ import { createServiceAuth } from '../service-auth.js';
15
+ import { parseSpaceUri } from '../uri.js';
16
+ import { applyWrites, SpaceWriteError } from '../writer.js';
17
+
18
+ const MAX_JSON_BODY = 512 * 1024;
19
+
20
+ /**
21
+ * @param {string} error
22
+ * @param {string} message
23
+ * @param {number} [status]
24
+ * @returns {Response}
25
+ */
26
+ function errorResponse(error, message, status = 400) {
27
+ return Response.json({ error, message }, { status });
28
+ }
29
+
30
+ /**
31
+ * Parse a JSON body, rejecting oversized or malformed input.
32
+ * @param {Request} request
33
+ * @returns {Promise<{ok: true, body: any}|{ok: false, response: Response}>}
34
+ */
35
+ async function readJson(request) {
36
+ const text = await request.text();
37
+ if (text.length > MAX_JSON_BODY) {
38
+ return {
39
+ ok: false,
40
+ response: errorResponse('InvalidRequest', 'Request body too large', 413),
41
+ };
42
+ }
43
+ try {
44
+ return { ok: true, body: JSON.parse(text) };
45
+ } catch {
46
+ return {
47
+ ok: false,
48
+ response: errorResponse('InvalidRequest', 'Invalid JSON body'),
49
+ };
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Space URIs arrive typed only as `at-uri`, so a malformed one is a request
55
+ * error rather than a 500.
56
+ * @param {string} space
57
+ * @returns {Response|null}
58
+ */
59
+ function validateSpaceUri(space) {
60
+ if (typeof space !== 'string') {
61
+ return errorResponse('InvalidRequest', 'space is required');
62
+ }
63
+ try {
64
+ parseSpaceUri(space);
65
+ return null;
66
+ } catch {
67
+ return errorResponse('InvalidSpaceUri', `Not a space uri: ${space}`);
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Assert the token's `space:` scope covers this operation.
73
+ *
74
+ * A grant that names no `collection` should fall back to the space type's
75
+ * declared collections. Resolving a space-type declaration means fetching its
76
+ * lexicon, which this implementation does not do yet — so such a grant
77
+ * currently authorizes no write targets. That fails closed, and a grant that
78
+ * names its collections explicitly works as specified.
79
+ *
80
+ * @param {{did: string, scope?: string}|null} auth
81
+ * @param {string} space
82
+ * @param {{action: string, collection?: string}} op
83
+ * @returns {Response|null}
84
+ */
85
+ function checkSpaceScope(auth, space, op) {
86
+ const { spaceDid, spaceType, skey } = parseSpaceUri(space);
87
+ const permissions = new ScopePermissions(auth?.scope);
88
+ const allowed = permissions.allowsSpace({
89
+ spaceType,
90
+ authority: spaceDid,
91
+ skey,
92
+ action: op.action,
93
+ collection: op.collection,
94
+ userDid: auth?.did,
95
+ });
96
+ if (!allowed) {
97
+ return errorResponse(
98
+ 'InvalidToken',
99
+ `Scope does not permit ${op.action} in ${space}`,
100
+ 403,
101
+ );
102
+ }
103
+ return null;
104
+ }
105
+
106
+ /**
107
+ * A writer may only write its own repo.
108
+ * @param {string} repo
109
+ * @param {string} did
110
+ * @returns {Response|null}
111
+ */
112
+ function validateRepo(repo, did) {
113
+ if (repo !== did) {
114
+ return errorResponse(
115
+ 'Forbidden',
116
+ 'repo must match authenticated user',
117
+ 403,
118
+ );
119
+ }
120
+ return null;
121
+ }
122
+
123
+ /**
124
+ * @param {unknown} err
125
+ * @returns {Response}
126
+ */
127
+ function writeErrorResponse(err) {
128
+ if (err instanceof SpaceWriteError) {
129
+ return errorResponse(err.code, err.message);
130
+ }
131
+ throw err;
132
+ }
133
+
134
+ /**
135
+ * @param {string} space
136
+ * @param {string} did
137
+ * @param {string} collection
138
+ * @param {string} rkey
139
+ * @returns {string}
140
+ */
141
+ function recordUri(space, did, collection, rkey) {
142
+ return `${space}/${did}/${collection}/${rkey}`;
143
+ }
144
+
145
+ /**
146
+ * @param {Object} ctx
147
+ * @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
148
+ * @param {() => Promise<string|null>} ctx.getDid
149
+ * @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
150
+ * @param {(did: string) => Promise<any>} ctx.resolveDid
151
+ * @param {typeof fetch} [ctx.fetch]
152
+ * @returns {import('@pdsjs/core/pds').Routes}
153
+ */
154
+ export function createWriteRoutes(ctx) {
155
+ const { spaceStorage, getDid, getSigner, resolveDid } = ctx;
156
+
157
+ /**
158
+ * Tell the space authority this repo advanced, so it can maintain the writer
159
+ * set that listRepos enumerates and fan out to syncers.
160
+ *
161
+ * Best-effort by design: sync correctness rests on comparing set hashes, so a
162
+ * dropped notification is recovered by a later write or a periodic sweep.
163
+ *
164
+ * @param {string} space
165
+ * @param {{rev: string, setHash: Uint8Array}} commit
166
+ * @returns {Promise<void>}
167
+ */
168
+ async function fireNotifyWrite(space, commit) {
169
+ try {
170
+ const { spaceDid } = parseSpaceUri(space);
171
+ const did = await getDid();
172
+ if (!did) return;
173
+ // Writing into our own space: record the writer directly instead of
174
+ // notifying ourselves over HTTP. Skipping entirely would leave the
175
+ // authority out of its own writer set, and every syncer walking
176
+ // listRepos would silently miss this repo.
177
+ if (spaceDid === did) {
178
+ const hash = await new LtHash(commit.setHash).digest();
179
+ await spaceStorage.putSpaceWriter(space, did, commit.rev, hash);
180
+ return;
181
+ }
182
+ const didDoc = await resolveDid(spaceDid);
183
+ if (!didDoc) return;
184
+ const endpoint = spaceHostEndpoint(didDoc, spaceDid);
185
+ const token = await createServiceAuth({
186
+ iss: did,
187
+ aud: spaceDid,
188
+ lxm: 'com.atproto.space.notifyWrite',
189
+ signer: await getSigner(),
190
+ });
191
+ const hash = await new LtHash(commit.setHash).digest();
192
+ let binary = '';
193
+ for (const b of hash) binary += String.fromCharCode(b);
194
+ await (ctx.fetch ?? fetch)(
195
+ `${endpoint}/xrpc/com.atproto.space.notifyWrite`,
196
+ {
197
+ method: 'POST',
198
+ headers: {
199
+ 'content-type': 'application/json',
200
+ authorization: `Bearer ${token}`,
201
+ },
202
+ body: JSON.stringify({
203
+ space,
204
+ repo: did,
205
+ rev: commit.rev,
206
+ hash: { $bytes: btoa(binary).replace(/=+$/, '') },
207
+ }),
208
+ },
209
+ );
210
+ } catch {
211
+ // Best effort.
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Shared shell for the three single-write endpoints.
217
+ * @param {Request} request
218
+ * @param {{did: string, scope?: string}|null} auth
219
+ * @param {(body: any) => {action: 'create'|'put'|'delete', collection: string, rkey: string, record?: any}} toWrite
220
+ * @param {(body: any, result: import('../writer.js').SpaceWriteResult, did: string) => any} toBody
221
+ * @returns {Promise<Response>}
222
+ */
223
+ async function singleWrite(request, auth, toWrite, toBody) {
224
+ const parsed = await readJson(request);
225
+ if (!parsed.ok) return parsed.response;
226
+ const body = parsed.body;
227
+ const did = /** @type {{did: string}} */ (auth).did;
228
+
229
+ const uriError = validateSpaceUri(body.space);
230
+ if (uriError) return uriError;
231
+ const repoError = validateRepo(body.repo, did);
232
+ if (repoError) return repoError;
233
+ if (typeof body.collection !== 'string') {
234
+ return errorResponse('InvalidRequest', 'collection is required');
235
+ }
236
+
237
+ let write;
238
+ try {
239
+ write = toWrite(body);
240
+ } catch (err) {
241
+ return writeErrorResponse(err);
242
+ }
243
+
244
+ // A put may create or update, so resolve which before checking scope, the
245
+ // way the reference implementation does.
246
+ /** @type {string} */
247
+ let action = write.action;
248
+ if (action === 'put') {
249
+ const existing = await spaceStorage.getSpaceRecordCid(
250
+ body.space,
251
+ write.collection,
252
+ write.rkey,
253
+ );
254
+ action = existing ? 'update' : 'create';
255
+ }
256
+ const scopeError = checkSpaceScope(auth, body.space, {
257
+ action,
258
+ collection: write.collection,
259
+ });
260
+ if (scopeError) return scopeError;
261
+
262
+ try {
263
+ const commit = await applyWrites(spaceStorage, {
264
+ space: body.space,
265
+ writes: [write],
266
+ });
267
+ await fireNotifyWrite(body.space, commit);
268
+ return Response.json(toBody(body, commit.results[0], did));
269
+ } catch (err) {
270
+ return writeErrorResponse(err);
271
+ }
272
+ }
273
+
274
+ return {
275
+ '/xrpc/com.atproto.space.createRecord': {
276
+ method: 'POST',
277
+ auth: 'required',
278
+ handler: (request, _url, auth) =>
279
+ singleWrite(
280
+ request,
281
+ auth,
282
+ (body) => {
283
+ if (body.record === undefined) {
284
+ throw new SpaceWriteError('record is required', 'InvalidRequest');
285
+ }
286
+ return {
287
+ action: 'create',
288
+ collection: body.collection,
289
+ rkey: body.rkey ?? createTid(),
290
+ record: body.record,
291
+ };
292
+ },
293
+ (body, result, did) => ({
294
+ uri: recordUri(body.space, did, result.collection, result.rkey),
295
+ cid: result.cid,
296
+ }),
297
+ ),
298
+ },
299
+
300
+ '/xrpc/com.atproto.space.putRecord': {
301
+ method: 'POST',
302
+ auth: 'required',
303
+ handler: (request, _url, auth) =>
304
+ singleWrite(
305
+ request,
306
+ auth,
307
+ (body) => {
308
+ if (typeof body.rkey !== 'string') {
309
+ throw new SpaceWriteError('rkey is required', 'InvalidRequest');
310
+ }
311
+ if (body.record === undefined) {
312
+ throw new SpaceWriteError('record is required', 'InvalidRequest');
313
+ }
314
+ return {
315
+ action: 'put',
316
+ collection: body.collection,
317
+ rkey: body.rkey,
318
+ record: body.record,
319
+ };
320
+ },
321
+ (body, result, did) => ({
322
+ uri: recordUri(body.space, did, result.collection, result.rkey),
323
+ cid: result.cid,
324
+ }),
325
+ ),
326
+ },
327
+
328
+ '/xrpc/com.atproto.space.deleteRecord': {
329
+ method: 'POST',
330
+ auth: 'required',
331
+ handler: (request, _url, auth) =>
332
+ singleWrite(
333
+ request,
334
+ auth,
335
+ (body) => {
336
+ if (typeof body.rkey !== 'string') {
337
+ throw new SpaceWriteError('rkey is required', 'InvalidRequest');
338
+ }
339
+ return {
340
+ action: 'delete',
341
+ collection: body.collection,
342
+ rkey: body.rkey,
343
+ };
344
+ },
345
+ () => ({}),
346
+ ),
347
+ },
348
+
349
+ '/xrpc/com.atproto.space.applyWrites': {
350
+ method: 'POST',
351
+ auth: 'required',
352
+ handler: async (request, _url, auth) => {
353
+ const parsed = await readJson(request);
354
+ if (!parsed.ok) return parsed.response;
355
+ const body = parsed.body;
356
+ const did = /** @type {{did: string}} */ (auth).did;
357
+
358
+ const uriError = validateSpaceUri(body.space);
359
+ if (uriError) return uriError;
360
+ const repoError = validateRepo(body.repo, did);
361
+ if (repoError) return repoError;
362
+ if (!Array.isArray(body.writes)) {
363
+ return errorResponse('InvalidRequest', 'writes must be an array');
364
+ }
365
+
366
+ /** @type {import('../writer.js').SpaceWriteInput[]} */
367
+ const writes = [];
368
+ for (const w of body.writes) {
369
+ const type = w?.$type;
370
+ const collection = w?.collection;
371
+ if (typeof collection !== 'string') {
372
+ return errorResponse(
373
+ 'InvalidRequest',
374
+ 'each write needs a collection',
375
+ );
376
+ }
377
+ if (type === 'com.atproto.space.applyWrites#create') {
378
+ writes.push({
379
+ action: 'create',
380
+ collection,
381
+ rkey: w.rkey ?? createTid(),
382
+ record: w.value,
383
+ });
384
+ } else if (type === 'com.atproto.space.applyWrites#update') {
385
+ writes.push({
386
+ action: 'update',
387
+ collection,
388
+ rkey: w.rkey,
389
+ record: w.value,
390
+ });
391
+ } else if (type === 'com.atproto.space.applyWrites#delete') {
392
+ writes.push({ action: 'delete', collection, rkey: w.rkey });
393
+ } else {
394
+ return errorResponse(
395
+ 'InvalidRequest',
396
+ `Unsupported write type: ${type}`,
397
+ );
398
+ }
399
+ }
400
+
401
+ for (const w of writes) {
402
+ const scopeError = checkSpaceScope(auth, body.space, {
403
+ action: w.action,
404
+ collection: w.collection,
405
+ });
406
+ if (scopeError) return scopeError;
407
+ }
408
+
409
+ try {
410
+ const commit = await applyWrites(spaceStorage, {
411
+ space: body.space,
412
+ writes,
413
+ });
414
+ await fireNotifyWrite(body.space, commit);
415
+ return Response.json({
416
+ results: commit.results.map((r) =>
417
+ r.action === 'delete'
418
+ ? { $type: 'com.atproto.space.applyWrites#deleteResult' }
419
+ : {
420
+ $type: `com.atproto.space.applyWrites#${r.action}Result`,
421
+ uri: recordUri(body.space, did, r.collection, r.rkey),
422
+ cid: r.cid,
423
+ },
424
+ ),
425
+ });
426
+ } catch (err) {
427
+ return writeErrorResponse(err);
428
+ }
429
+ },
430
+ },
431
+
432
+ '/xrpc/com.atproto.space.listSpaces': {
433
+ auth: 'required',
434
+ handler: async (_request, url) => {
435
+ const params = url.searchParams;
436
+ const limitParam = params.get('limit');
437
+ let limit = limitParam === null ? 50 : Number(limitParam);
438
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
439
+ if (limitParam !== null) {
440
+ return errorResponse(
441
+ 'InvalidRequest',
442
+ 'limit must be an integer between 1 and 100',
443
+ );
444
+ }
445
+ limit = 50;
446
+ }
447
+
448
+ const { spaces, cursor } = await spaceStorage.listSpaces({
449
+ type: params.get('type'),
450
+ did: params.get('did'),
451
+ cursor: params.get('cursor'),
452
+ limit,
453
+ });
454
+
455
+ return Response.json({
456
+ ...(cursor ? { cursor } : {}),
457
+ spaces: spaces.map((s) => ({ uri: s.uri, isOwner: s.isOwner })),
458
+ });
459
+ },
460
+ },
461
+ };
462
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ export { ATPROTO_KEY_ID, ATPROTO_PDS_ID, atprotoSigningKey, didKeyResolver, SPACE_HOST_ID, SPACE_KEY_ID, SpaceAuthorityError, spaceHostAudience, spaceHostEndpoint, spaceSigningKey, } from './authority.js';
2
+ export { blake3 } from './blake3.js';
3
+ export { byCanonicalKey, serializeRepo } from './car.js';
4
+ export { RepoCommit, verifyCommit } from './commit.js';
5
+ export { verifySpaceCredential } from './handlers/auth.js';
6
+ export { LTHASH_STATE_BYTES, LtHash } from './lthash.js';
7
+ export { bytesEqual, COMMIT_VERSION, computeMac, encodeCommitCtx, hkdfExpandSha256, hmacSha256, } from './mac.js';
8
+ export { createMemorySpaceStorage } from './memory-storage.js';
9
+ export { formatRecordPath, formatSetHashElement, parseRecordPath, } from './path.js';
10
+ export { createSpaceRoutes } from './routes.js';
11
+ export { makeSpaceRow } from './space-row.js';
12
+ export { createSpaceToken, parseSpaceToken, SPACE_TOKEN_TYPES, SpaceTokenError, verifySpaceToken, } from './token.js';
13
+ export { formatSpaceUri, isSpaceUri, parseSpaceUri, SPACE_MARKER, } from './uri.js';
14
+ export { createVerifier, parseDidKey } from './verifier.js';
15
+ export { applyWrites, MAX_WRITES_PER_COMMIT, SpaceRecordAlreadyExistsError, SpaceRecordNotFoundError, SpaceWriteError, } from './writer.js';
package/src/index.js ADDED
@@ -0,0 +1,56 @@
1
+ // @pdsjs/spaces - AT Protocol permissioned data (proposal 0016)
2
+
3
+ export {
4
+ ATPROTO_KEY_ID,
5
+ ATPROTO_PDS_ID,
6
+ atprotoSigningKey,
7
+ didKeyResolver,
8
+ SPACE_HOST_ID,
9
+ SPACE_KEY_ID,
10
+ SpaceAuthorityError,
11
+ spaceHostAudience,
12
+ spaceHostEndpoint,
13
+ spaceSigningKey,
14
+ } from './authority.js';
15
+ export { blake3 } from './blake3.js';
16
+ export { byCanonicalKey, serializeRepo } from './car.js';
17
+ export { RepoCommit, verifyCommit } from './commit.js';
18
+ export { verifySpaceCredential } from './handlers/auth.js';
19
+ export { LTHASH_STATE_BYTES, LtHash } from './lthash.js';
20
+ export {
21
+ bytesEqual,
22
+ COMMIT_VERSION,
23
+ computeMac,
24
+ encodeCommitCtx,
25
+ hkdfExpandSha256,
26
+ hmacSha256,
27
+ } from './mac.js';
28
+ export { createMemorySpaceStorage } from './memory-storage.js';
29
+ export {
30
+ formatRecordPath,
31
+ formatSetHashElement,
32
+ parseRecordPath,
33
+ } from './path.js';
34
+ export { createSpaceRoutes } from './routes.js';
35
+ export { makeSpaceRow } from './space-row.js';
36
+ export {
37
+ createSpaceToken,
38
+ parseSpaceToken,
39
+ SPACE_TOKEN_TYPES,
40
+ SpaceTokenError,
41
+ verifySpaceToken,
42
+ } from './token.js';
43
+ export {
44
+ formatSpaceUri,
45
+ isSpaceUri,
46
+ parseSpaceUri,
47
+ SPACE_MARKER,
48
+ } from './uri.js';
49
+ export { createVerifier, parseDidKey } from './verifier.js';
50
+ export {
51
+ applyWrites,
52
+ MAX_WRITES_PER_COMMIT,
53
+ SpaceRecordAlreadyExistsError,
54
+ SpaceRecordNotFoundError,
55
+ SpaceWriteError,
56
+ } from './writer.js';
@@ -0,0 +1,24 @@
1
+ export declare const LTHASH_STATE_BYTES: number;
2
+ /**
3
+ * A homomorphic set hash. Each element expands to 1024 little-endian u16 lanes
4
+ * summed into the state mod 2^16. Addition and subtraction commute, so the state
5
+ * depends only on the current set, not on insertion order.
6
+ */
7
+ export declare class LtHash {
8
+ bytes: Uint8Array<ArrayBuffer>;
9
+ lanes: Uint16Array<ArrayBuffer>;
10
+ /** @param {Uint8Array|null} [state] */
11
+ constructor(state?: Uint8Array | null);
12
+ /** @param {string} element @returns {this} */
13
+ add(element: string): this;
14
+ /** @param {string} element @returns {this} */
15
+ remove(element: string): this;
16
+ /** @returns {Uint8Array} the full state, for persistence */
17
+ state(): Uint8Array;
18
+ /** @returns {Promise<Uint8Array>} */
19
+ digest(): Promise<Uint8Array>;
20
+ /** @returns {boolean} */
21
+ isEmpty(): boolean;
22
+ /** @param {LtHash} other @returns {boolean} */
23
+ equals(other: LtHash): boolean;
24
+ }
package/src/lthash.js ADDED
@@ -0,0 +1,86 @@
1
+ // @pdsjs/spaces/lthash - homomorphic set hash for permissioned repo commits.
2
+
3
+ import { blake3 } from './blake3.js';
4
+
5
+ const LANES = 1024;
6
+ // Cannot change without reworking the Uint16Array impl. `new Uint16Array(buffer)`
7
+ // reads/writes in host byte order, which is little-endian on every platform
8
+ // pds.js targets (Node, Deno, browsers, Cloudflare Workers) and matches the
9
+ // little-endian lanes described above and by the spec; digests would only
10
+ // diverge from the reference on a big-endian host.
11
+ const LANE_BYTES = 2;
12
+ export const LTHASH_STATE_BYTES = LANES * LANE_BYTES; // 2048
13
+
14
+ /**
15
+ * A homomorphic set hash. Each element expands to 1024 little-endian u16 lanes
16
+ * summed into the state mod 2^16. Addition and subtraction commute, so the state
17
+ * depends only on the current set, not on insertion order.
18
+ */
19
+ export class LtHash {
20
+ /** @param {Uint8Array|null} [state] */
21
+ constructor(state) {
22
+ if (state && state.length !== LTHASH_STATE_BYTES) {
23
+ throw new Error(
24
+ `LtHash state must be ${LTHASH_STATE_BYTES} bytes, got ${state.length}`,
25
+ );
26
+ }
27
+ const buffer = new ArrayBuffer(LTHASH_STATE_BYTES);
28
+ this.bytes = new Uint8Array(buffer);
29
+ this.lanes = new Uint16Array(buffer);
30
+ if (state) this.bytes.set(state);
31
+ }
32
+
33
+ /** @param {string} element @returns {this} */
34
+ add(element) {
35
+ const lanes = expand(element);
36
+ for (let i = 0; i < LANES; i++) this.lanes[i] += lanes[i];
37
+ return this;
38
+ }
39
+
40
+ /** @param {string} element @returns {this} */
41
+ remove(element) {
42
+ const lanes = expand(element);
43
+ for (let i = 0; i < LANES; i++) this.lanes[i] -= lanes[i];
44
+ return this;
45
+ }
46
+
47
+ /** @returns {Uint8Array} the full state, for persistence */
48
+ state() {
49
+ return new Uint8Array(this.bytes);
50
+ }
51
+
52
+ /** @returns {Promise<Uint8Array>} */
53
+ async digest() {
54
+ const hash = await crypto.subtle.digest('SHA-256', this.bytes);
55
+ return new Uint8Array(hash);
56
+ }
57
+
58
+ /** @returns {boolean} */
59
+ isEmpty() {
60
+ for (let i = 0; i < LANES; i++) if (this.lanes[i] !== 0) return false;
61
+ return true;
62
+ }
63
+
64
+ /** @param {LtHash} other @returns {boolean} */
65
+ equals(other) {
66
+ for (let i = 0; i < LANES; i++) {
67
+ if (this.lanes[i] !== other.lanes[i]) return false;
68
+ }
69
+ return true;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Expand an element to 1024 little-endian u16 lanes with BLAKE3 in XOF mode.
75
+ * @param {string} element
76
+ * @returns {Uint16Array}
77
+ */
78
+ function expand(element) {
79
+ const expanded = blake3(
80
+ new TextEncoder().encode(element),
81
+ LTHASH_STATE_BYTES,
82
+ );
83
+ const buffer = new ArrayBuffer(LTHASH_STATE_BYTES);
84
+ new Uint8Array(buffer).set(expanded);
85
+ return new Uint16Array(buffer);
86
+ }
package/src/mac.d.ts ADDED
@@ -0,0 +1,50 @@
1
+ export declare const COMMIT_VERSION = 1;
2
+ /**
3
+ * ctx = "atproto-space-v1"
4
+ * || uint16be(len(space)) || space
5
+ * || uint16be(len(author)) || author
6
+ * || uint16be(len(rev)) || rev
7
+ * || uint16be(len(ikm)) || ikm
8
+ *
9
+ * Length prefixes are big-endian per the TLS variable-length vector convention,
10
+ * deliberately the opposite byte order from the set hash's lanes.
11
+ *
12
+ * @param {{space: string, author: string, rev: string}} ctx
13
+ * @param {Uint8Array} ikm
14
+ * @returns {Uint8Array}
15
+ */
16
+ export declare function encodeCommitCtx(ctx: {
17
+ space: string;
18
+ author: string;
19
+ rev: string;
20
+ }, ikm: Uint8Array): Uint8Array;
21
+ /**
22
+ * @param {Uint8Array} key
23
+ * @param {Uint8Array} data
24
+ * @returns {Promise<Uint8Array>}
25
+ */
26
+ export declare function hmacSha256(key: Uint8Array, data: Uint8Array): Promise<Uint8Array>;
27
+ /**
28
+ * HKDF-Expand (RFC 5869 §2.3) with SHA-256 and L = 32, matching the reference's
29
+ * `expand(sha256, ikm, info, 32)`. `ikm` is used directly as the PRK — there is
30
+ * no extract step, which is why crypto.subtle's HKDF cannot be used here. With
31
+ * L equal to the hash length this is exactly one block.
32
+ *
33
+ * @param {Uint8Array} ikm
34
+ * @param {Uint8Array} info
35
+ * @returns {Promise<Uint8Array>}
36
+ */
37
+ export declare function hkdfExpandSha256(ikm: Uint8Array, info: Uint8Array): Promise<Uint8Array>;
38
+ /**
39
+ * @param {Uint8Array} ikm
40
+ * @param {Uint8Array} ctxBytes
41
+ * @param {Uint8Array} hash
42
+ * @returns {Promise<Uint8Array>}
43
+ */
44
+ export declare function computeMac(ikm: Uint8Array, ctxBytes: Uint8Array, hash: Uint8Array): Promise<Uint8Array>;
45
+ /**
46
+ * @param {Uint8Array} a
47
+ * @param {Uint8Array} b
48
+ * @returns {boolean}
49
+ */
50
+ export declare function bytesEqual(a: Uint8Array, b: Uint8Array): boolean;