@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,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;