@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,394 @@
|
|
|
1
|
+
// @pdsjs/spaces/handlers/read - com.atproto.space read and sync endpoints.
|
|
2
|
+
//
|
|
3
|
+
// These are the repo-host role: an application syncing a space pulls each
|
|
4
|
+
// member's repo from its PDS through here.
|
|
5
|
+
//
|
|
6
|
+
// Two ways in: the hosted account's own session, or a space credential issued
|
|
7
|
+
// by the space's authority. The credential is what lets a syncer read a repo it
|
|
8
|
+
// does not own — it is signed by the authority, so it verifies without
|
|
9
|
+
// contacting them.
|
|
10
|
+
|
|
11
|
+
import { cborDecode } from '@pdsjs/core/repo';
|
|
12
|
+
import { serializeRepo } from '../car.js';
|
|
13
|
+
import { RepoCommit } from '../commit.js';
|
|
14
|
+
import { SpaceTokenError } from '../token.js';
|
|
15
|
+
import { parseSpaceUri } from '../uri.js';
|
|
16
|
+
import { verifySpaceCredential } from './auth.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {string} error
|
|
20
|
+
* @param {string} message
|
|
21
|
+
* @param {number} [status]
|
|
22
|
+
* @returns {Response}
|
|
23
|
+
*/
|
|
24
|
+
function errorResponse(error, message, status = 400) {
|
|
25
|
+
return Response.json({ error, message }, { status });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {URL} url
|
|
30
|
+
* @returns {{space: string, repo: string|null}|Response}
|
|
31
|
+
*/
|
|
32
|
+
function readTarget(url) {
|
|
33
|
+
const space = url.searchParams.get('space');
|
|
34
|
+
if (!space) return errorResponse('InvalidRequest', 'space is required');
|
|
35
|
+
try {
|
|
36
|
+
parseSpaceUri(space);
|
|
37
|
+
} catch {
|
|
38
|
+
return errorResponse('InvalidSpaceUri', `Not a space uri: ${space}`);
|
|
39
|
+
}
|
|
40
|
+
return { space, repo: url.searchParams.get('repo') };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {URL} url
|
|
45
|
+
* @param {string} name
|
|
46
|
+
* @param {number} def
|
|
47
|
+
* @param {number} max
|
|
48
|
+
* @returns {number|Response}
|
|
49
|
+
*/
|
|
50
|
+
function readLimit(url, name, def, max) {
|
|
51
|
+
const raw = url.searchParams.get(name);
|
|
52
|
+
if (raw === null) return def;
|
|
53
|
+
const value = Number(raw);
|
|
54
|
+
if (!Number.isInteger(value) || value < 1 || value > max) {
|
|
55
|
+
return errorResponse(
|
|
56
|
+
'InvalidRequest',
|
|
57
|
+
`${name} must be an integer between 1 and ${max}`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* atproto's JSON representation of a byte string.
|
|
65
|
+
* @param {Uint8Array} bytes
|
|
66
|
+
* @returns {{$bytes: string}}
|
|
67
|
+
*/
|
|
68
|
+
function toJsonBytes(bytes) {
|
|
69
|
+
let binary = '';
|
|
70
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
71
|
+
return { $bytes: btoa(binary).replace(/=+$/, '') };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @param {import('../commit.js').SignedCommit} commit
|
|
76
|
+
* @returns {Object}
|
|
77
|
+
*/
|
|
78
|
+
function toJsonCommit(commit) {
|
|
79
|
+
return {
|
|
80
|
+
ver: commit.ver,
|
|
81
|
+
hash: toJsonBytes(commit.hash),
|
|
82
|
+
ikm: toJsonBytes(commit.ikm),
|
|
83
|
+
sig: toJsonBytes(commit.sig),
|
|
84
|
+
mac: toJsonBytes(commit.mac),
|
|
85
|
+
rev: commit.rev,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Build a signed commit over the repo's current state. Undefined when the repo
|
|
91
|
+
* has never been written to.
|
|
92
|
+
*
|
|
93
|
+
* @param {Object} opts
|
|
94
|
+
* @param {import('@pdsjs/core/ports').SpaceStoragePort} opts.spaceStorage
|
|
95
|
+
* @param {string} opts.space
|
|
96
|
+
* @param {string} opts.author
|
|
97
|
+
* @param {{sign: (bytes: Uint8Array) => Promise<Uint8Array>}} opts.signer
|
|
98
|
+
* @returns {Promise<import('../commit.js').SignedCommit|undefined>}
|
|
99
|
+
*/
|
|
100
|
+
async function buildSignedCommit({ spaceStorage, space, author, signer }) {
|
|
101
|
+
const state = await spaceStorage.getSpaceRepo(space);
|
|
102
|
+
if (!state?.setHash || !state.rev) return undefined;
|
|
103
|
+
return RepoCommit.fromState(state.setHash).sign(
|
|
104
|
+
{ space, author, rev: state.rev },
|
|
105
|
+
signer,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @param {Object} ctx
|
|
111
|
+
* @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
|
|
112
|
+
* @param {() => Promise<string|null>} ctx.getDid
|
|
113
|
+
* @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
|
|
114
|
+
* @param {(did: string) => Promise<any>} ctx.resolveDid
|
|
115
|
+
* @param {import('@pdsjs/core/ports').SignatureVerifierPort} ctx.verifier
|
|
116
|
+
* @returns {import('@pdsjs/core/pds').Routes}
|
|
117
|
+
*/
|
|
118
|
+
export function createReadRoutes(ctx) {
|
|
119
|
+
const { spaceStorage, getDid, getSigner, resolveDid, verifier } = ctx;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Authorize a read: the hosted account itself, or a valid space credential for
|
|
123
|
+
* the space being read.
|
|
124
|
+
*
|
|
125
|
+
* @param {Request} request
|
|
126
|
+
* @param {string} space
|
|
127
|
+
* @param {{did: string}|null} auth
|
|
128
|
+
* @returns {Promise<Response|null>} a Response to return, or null to proceed
|
|
129
|
+
*/
|
|
130
|
+
async function authorizeRead(request, space, auth) {
|
|
131
|
+
const hosted = await getDid();
|
|
132
|
+
if (auth && auth.did === hosted) return null;
|
|
133
|
+
|
|
134
|
+
const header = request.headers.get('authorization') ?? '';
|
|
135
|
+
const match = header.match(/^Bearer\s+(.+)$/i);
|
|
136
|
+
if (!match) {
|
|
137
|
+
return errorResponse(
|
|
138
|
+
'AuthenticationRequired',
|
|
139
|
+
'A session or space credential is required',
|
|
140
|
+
401,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
await verifySpaceCredential({
|
|
145
|
+
credential: match[1],
|
|
146
|
+
space,
|
|
147
|
+
resolveDid,
|
|
148
|
+
verifier,
|
|
149
|
+
});
|
|
150
|
+
return null;
|
|
151
|
+
} catch (err) {
|
|
152
|
+
if (err instanceof SpaceTokenError) {
|
|
153
|
+
return errorResponse(err.code, err.message, 401);
|
|
154
|
+
}
|
|
155
|
+
throw err;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The repo a request targets. `repo` is optional on the wire; this PDS hosts
|
|
161
|
+
* exactly one account, so anything else is a miss rather than a proxy.
|
|
162
|
+
* @param {string|null} repo
|
|
163
|
+
* @returns {Promise<string|Response>}
|
|
164
|
+
*/
|
|
165
|
+
async function resolveRepo(repo) {
|
|
166
|
+
const did = await getDid();
|
|
167
|
+
if (!did) return errorResponse('RepoNotFound', 'Server not initialised');
|
|
168
|
+
if (repo && repo !== did) {
|
|
169
|
+
return errorResponse('RepoNotFound', `Not hosted here: ${repo}`, 404);
|
|
170
|
+
}
|
|
171
|
+
return did;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
'/xrpc/com.atproto.space.getRecord': {
|
|
176
|
+
auth: 'optional',
|
|
177
|
+
handler: async (request, url, auth) => {
|
|
178
|
+
const target = readTarget(url);
|
|
179
|
+
if (target instanceof Response) return target;
|
|
180
|
+
const denied = await authorizeRead(request, target.space, auth);
|
|
181
|
+
if (denied) return denied;
|
|
182
|
+
const did = await resolveRepo(target.repo);
|
|
183
|
+
if (did instanceof Response) return did;
|
|
184
|
+
|
|
185
|
+
const collection = url.searchParams.get('collection');
|
|
186
|
+
const rkey = url.searchParams.get('rkey');
|
|
187
|
+
if (!collection || !rkey) {
|
|
188
|
+
return errorResponse(
|
|
189
|
+
'InvalidRequest',
|
|
190
|
+
'collection and rkey are required',
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const row = await spaceStorage.getSpaceRecord(
|
|
195
|
+
target.space,
|
|
196
|
+
collection,
|
|
197
|
+
rkey,
|
|
198
|
+
);
|
|
199
|
+
if (!row) {
|
|
200
|
+
return errorResponse(
|
|
201
|
+
'RecordNotFound',
|
|
202
|
+
`Record not found: ${collection}/${rkey}`,
|
|
203
|
+
404,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
return Response.json({
|
|
207
|
+
uri: `${target.space}/${did}/${collection}/${rkey}`,
|
|
208
|
+
cid: row.cid,
|
|
209
|
+
value: cborDecode(row.value),
|
|
210
|
+
});
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
'/xrpc/com.atproto.space.listRecords': {
|
|
215
|
+
auth: 'optional',
|
|
216
|
+
handler: async (request, url, auth) => {
|
|
217
|
+
const target = readTarget(url);
|
|
218
|
+
if (target instanceof Response) return target;
|
|
219
|
+
const denied = await authorizeRead(request, target.space, auth);
|
|
220
|
+
if (denied) return denied;
|
|
221
|
+
const did = await resolveRepo(target.repo);
|
|
222
|
+
if (did instanceof Response) return did;
|
|
223
|
+
|
|
224
|
+
const limit = readLimit(url, 'limit', 50, 100);
|
|
225
|
+
if (limit instanceof Response) return limit;
|
|
226
|
+
|
|
227
|
+
const collection = url.searchParams.get('collection');
|
|
228
|
+
if (!collection) {
|
|
229
|
+
return errorResponse(
|
|
230
|
+
'InvalidRequest',
|
|
231
|
+
'collection is required by this implementation',
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const { records, cursor } = await spaceStorage.listSpaceRecords(
|
|
236
|
+
target.space,
|
|
237
|
+
collection,
|
|
238
|
+
url.searchParams.get('cursor'),
|
|
239
|
+
limit,
|
|
240
|
+
);
|
|
241
|
+
return Response.json({
|
|
242
|
+
...(cursor ? { cursor } : {}),
|
|
243
|
+
records: records.map((r) => ({
|
|
244
|
+
uri: `${target.space}/${did}/${r.collection}/${r.rkey}`,
|
|
245
|
+
cid: r.cid,
|
|
246
|
+
value: cborDecode(r.value),
|
|
247
|
+
})),
|
|
248
|
+
});
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
'/xrpc/com.atproto.space.listRepoOps': {
|
|
253
|
+
auth: 'optional',
|
|
254
|
+
handler: async (request, url, auth) => {
|
|
255
|
+
const target = readTarget(url);
|
|
256
|
+
if (target instanceof Response) return target;
|
|
257
|
+
const denied = await authorizeRead(request, target.space, auth);
|
|
258
|
+
if (denied) return denied;
|
|
259
|
+
const did = await resolveRepo(target.repo);
|
|
260
|
+
if (did instanceof Response) return did;
|
|
261
|
+
|
|
262
|
+
const limit = readLimit(url, 'limit', 100, 1000);
|
|
263
|
+
if (limit instanceof Response) return limit;
|
|
264
|
+
const excludeValues = url.searchParams.get('excludeValues') === 'true';
|
|
265
|
+
|
|
266
|
+
const ops = await spaceStorage.listSpaceOps(
|
|
267
|
+
target.space,
|
|
268
|
+
url.searchParams.get('since'),
|
|
269
|
+
limit,
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
// Inline each op's record value so a syncer advances without a
|
|
273
|
+
// getRecord round-trip per write. A value is omitted for deletes, when
|
|
274
|
+
// excludeValues is set, and when a later op superseded it — the stored
|
|
275
|
+
// record is the current one, so it is only this op's value if the CIDs
|
|
276
|
+
// still agree.
|
|
277
|
+
const entries = [];
|
|
278
|
+
for (const op of ops) {
|
|
279
|
+
/** @type {Record<string, unknown>} */
|
|
280
|
+
const entry = {
|
|
281
|
+
rev: op.rev,
|
|
282
|
+
collection: op.collection,
|
|
283
|
+
rkey: op.rkey,
|
|
284
|
+
cid: op.cid,
|
|
285
|
+
prev: op.prev,
|
|
286
|
+
};
|
|
287
|
+
if (!excludeValues && op.cid) {
|
|
288
|
+
const row = await spaceStorage.getSpaceRecord(
|
|
289
|
+
target.space,
|
|
290
|
+
op.collection,
|
|
291
|
+
op.rkey,
|
|
292
|
+
);
|
|
293
|
+
if (row && row.cid === op.cid) {
|
|
294
|
+
entry.value = cborDecode(row.value);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
entries.push(entry);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** @type {Record<string, unknown>} */
|
|
301
|
+
const body = { ops: entries };
|
|
302
|
+
// The commit describes the head, so it is only meaningful once the
|
|
303
|
+
// response has reached it. A full page may have more behind it.
|
|
304
|
+
if (ops.length < limit) {
|
|
305
|
+
const commit = await buildSignedCommit({
|
|
306
|
+
spaceStorage,
|
|
307
|
+
space: target.space,
|
|
308
|
+
author: did,
|
|
309
|
+
signer: await getSigner(),
|
|
310
|
+
});
|
|
311
|
+
if (commit) body.commit = toJsonCommit(commit);
|
|
312
|
+
} else {
|
|
313
|
+
body.cursor = ops[ops.length - 1].rev;
|
|
314
|
+
}
|
|
315
|
+
return Response.json(body);
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
|
|
319
|
+
'/xrpc/com.atproto.space.getLatestCommit': {
|
|
320
|
+
auth: 'optional',
|
|
321
|
+
handler: async (request, url, auth) => {
|
|
322
|
+
const target = readTarget(url);
|
|
323
|
+
if (target instanceof Response) return target;
|
|
324
|
+
const denied = await authorizeRead(request, target.space, auth);
|
|
325
|
+
if (denied) return denied;
|
|
326
|
+
const did = await resolveRepo(target.repo);
|
|
327
|
+
if (did instanceof Response) return did;
|
|
328
|
+
|
|
329
|
+
const commit = await buildSignedCommit({
|
|
330
|
+
spaceStorage,
|
|
331
|
+
space: target.space,
|
|
332
|
+
author: did,
|
|
333
|
+
signer: await getSigner(),
|
|
334
|
+
});
|
|
335
|
+
return Response.json(commit ? { commit: toJsonCommit(commit) } : {});
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
|
|
339
|
+
'/xrpc/com.atproto.space.getRepo': {
|
|
340
|
+
auth: 'optional',
|
|
341
|
+
handler: async (request, url, auth) => {
|
|
342
|
+
const target = readTarget(url);
|
|
343
|
+
if (target instanceof Response) return target;
|
|
344
|
+
const denied = await authorizeRead(request, target.space, auth);
|
|
345
|
+
if (denied) return denied;
|
|
346
|
+
const did = await resolveRepo(target.repo);
|
|
347
|
+
if (did instanceof Response) return did;
|
|
348
|
+
|
|
349
|
+
const commit = await buildSignedCommit({
|
|
350
|
+
spaceStorage,
|
|
351
|
+
space: target.space,
|
|
352
|
+
author: did,
|
|
353
|
+
signer: await getSigner(),
|
|
354
|
+
});
|
|
355
|
+
if (!commit) {
|
|
356
|
+
return errorResponse(
|
|
357
|
+
'RepoNotFound',
|
|
358
|
+
`Could not find repo for space: ${target.space}`,
|
|
359
|
+
404,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const paths = await spaceStorage.listAllSpaceRecords(target.space);
|
|
364
|
+
const records = [];
|
|
365
|
+
for (const p of paths) {
|
|
366
|
+
const row = await spaceStorage.getSpaceRecord(
|
|
367
|
+
target.space,
|
|
368
|
+
p.collection,
|
|
369
|
+
p.rkey,
|
|
370
|
+
);
|
|
371
|
+
if (row) {
|
|
372
|
+
records.push({
|
|
373
|
+
collection: p.collection,
|
|
374
|
+
rkey: p.rkey,
|
|
375
|
+
cid: row.cid,
|
|
376
|
+
value: row.value,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const car = await serializeRepo(commit, records);
|
|
382
|
+
// Copy into a plain ArrayBuffer: a Uint8Array view is not a BodyInit
|
|
383
|
+
// per the DOM lib types, and `.buffer` widens to ArrayBufferLike, which
|
|
384
|
+
// includes SharedArrayBuffer. The copy keeps this honest rather than
|
|
385
|
+
// casting past the check.
|
|
386
|
+
const body = new ArrayBuffer(car.byteLength);
|
|
387
|
+
new Uint8Array(body).set(car);
|
|
388
|
+
return new Response(body, {
|
|
389
|
+
headers: { 'content-type': 'application/vnd.ipld.car' },
|
|
390
|
+
});
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {Object} ctx
|
|
3
|
+
* @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
|
|
4
|
+
* @param {() => Promise<string|null>} ctx.getDid
|
|
5
|
+
* @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
|
|
6
|
+
* @param {(did: string) => Promise<any>} ctx.resolveDid
|
|
7
|
+
* @param {typeof fetch} [ctx.fetch]
|
|
8
|
+
* @returns {import('@pdsjs/core/pds').Routes}
|
|
9
|
+
*/
|
|
10
|
+
export declare function createWriteRoutes(ctx: {
|
|
11
|
+
spaceStorage: import('@pdsjs/core/ports').SpaceStoragePort;
|
|
12
|
+
getDid: () => Promise<string | null>;
|
|
13
|
+
getSigner: () => Promise<{
|
|
14
|
+
sign: (bytes: Uint8Array) => Promise<Uint8Array>;
|
|
15
|
+
}>;
|
|
16
|
+
resolveDid: (did: string) => Promise<any>;
|
|
17
|
+
fetch?: typeof fetch;
|
|
18
|
+
}): import('@pdsjs/core/pds').Routes;
|