@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 +21 -0
- package/package.json +40 -0
- package/src/authority.d.ts +47 -0
- package/src/authority.js +148 -0
- package/src/blake3.d.ts +14 -0
- package/src/blake3.js +272 -0
- package/src/car.d.ts +41 -0
- package/src/car.js +98 -0
- package/src/commit.d.ts +109 -0
- package/src/commit.js +152 -0
- package/src/handlers/auth.d.ts +67 -0
- package/src/handlers/auth.js +436 -0
- package/src/handlers/manage.d.ts +20 -0
- package/src/handlers/manage.js +508 -0
- package/src/handlers/read.d.ts +18 -0
- package/src/handlers/read.js +394 -0
- package/src/handlers/write.d.ts +18 -0
- package/src/handlers/write.js +462 -0
- package/src/index.d.ts +15 -0
- package/src/index.js +56 -0
- package/src/lthash.d.ts +24 -0
- package/src/lthash.js +86 -0
- package/src/mac.d.ts +50 -0
- package/src/mac.js +107 -0
- package/src/memory-storage.d.ts +4 -0
- package/src/memory-storage.js +244 -0
- package/src/path.d.ts +22 -0
- package/src/path.js +33 -0
- package/src/routes.d.ts +23 -0
- package/src/routes.js +42 -0
- package/src/service-auth.d.ts +42 -0
- package/src/service-auth.js +153 -0
- package/src/space-row.d.ts +15 -0
- package/src/space-row.js +33 -0
- package/src/token.d.ts +95 -0
- package/src/token.js +223 -0
- package/src/uri.d.ts +32 -0
- package/src/uri.js +89 -0
- package/src/verifier.d.ts +15 -0
- package/src/verifier.js +74 -0
- package/src/writer.d.ts +77 -0
- package/src/writer.js +195 -0
|
@@ -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';
|
package/src/lthash.d.ts
ADDED
|
@@ -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;
|