@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,508 @@
1
+ // @pdsjs/spaces/handlers/manage - com.atproto.simplespace and notifications.
2
+ //
3
+ // simplespace is the baseline space-management implementation every PDS must
4
+ // support: spaces anchored on the user's own DID, governed by an explicit member
5
+ // list or a policy.
6
+ //
7
+ // Notifications are best-effort and are not required for eventual consistency.
8
+ // A dropped one is recovered by a later write's notification or by a syncer's
9
+ // periodic sweep, because sync correctness rests on comparing set hashes rather
10
+ // than on receiving every event.
11
+
12
+ import { createTid } from '@pdsjs/core/repo';
13
+ import { ScopePermissions } from '@pdsjs/core/scope';
14
+ import { createServiceAuth, verifyServiceAuth } from '../service-auth.js';
15
+ import { makeSpaceRow } from '../space-row.js';
16
+ import { SpaceTokenError } from '../token.js';
17
+ import { formatSpaceUri, parseSpaceUri } from '../uri.js';
18
+ import { verifySpaceCredential } from './auth.js';
19
+
20
+ const POLICIES = ['public', 'member-list', 'managing-app'];
21
+
22
+ /**
23
+ * @param {string} error
24
+ * @param {string} message
25
+ * @param {number} [status]
26
+ * @returns {Response}
27
+ */
28
+ function errorResponse(error, message, status = 400) {
29
+ return Response.json({ error, message }, { status });
30
+ }
31
+
32
+ /**
33
+ * @param {Request} request
34
+ * @returns {Promise<any>}
35
+ */
36
+ async function readJson(request) {
37
+ try {
38
+ return JSON.parse(await request.text());
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * @param {Object} ctx
46
+ * @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
47
+ * @param {() => Promise<string|null>} ctx.getDid
48
+ * @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
49
+ * @param {(did: string) => Promise<any>} ctx.resolveDid
50
+ * @param {import('@pdsjs/core/ports').SignatureVerifierPort} ctx.verifier
51
+ * @param {typeof fetch} [ctx.fetch]
52
+ * @returns {import('@pdsjs/core/pds').Routes}
53
+ */
54
+ export function createManageRoutes(ctx) {
55
+ const { spaceStorage, getDid, resolveDid, verifier } = ctx;
56
+
57
+ /**
58
+ * Management verbs are governed by the `manage=` scope param and are not
59
+ * implied by any record action.
60
+ * @param {{did: string, scope?: string}|null} auth
61
+ * @param {string} space
62
+ * @param {string} op
63
+ * @returns {Response|null}
64
+ */
65
+ function checkManageScope(auth, space, op) {
66
+ const { spaceDid, spaceType, skey } = parseSpaceUri(space);
67
+ const permissions = new ScopePermissions(auth?.scope);
68
+ const allowed = permissions.allowsSpaceManage({
69
+ spaceType,
70
+ authority: spaceDid,
71
+ skey,
72
+ op,
73
+ userDid: auth?.did,
74
+ });
75
+ if (!allowed) {
76
+ return errorResponse(
77
+ 'InvalidToken',
78
+ `Scope does not permit ${op} on ${space}`,
79
+ 403,
80
+ );
81
+ }
82
+ return null;
83
+ }
84
+
85
+ /**
86
+ * Load a space this server owns, or the Response explaining why not.
87
+ * @param {string} space
88
+ * @param {{did: string}|null} auth
89
+ * @returns {Promise<import('@pdsjs/core/ports').SpaceRow|Response>}
90
+ */
91
+ async function ownedSpace(space, auth) {
92
+ const row = await spaceStorage.getSpace(space);
93
+ if (!row) return errorResponse('SpaceNotFound', 'Space not found', 404);
94
+ if (!row.isOwner) {
95
+ return errorResponse(
96
+ 'NotSpaceOwner',
97
+ 'This server is not the authority for that space',
98
+ 403,
99
+ );
100
+ }
101
+ const hosted = await getDid();
102
+ if (auth && auth.did !== hosted) {
103
+ return errorResponse('NotSpaceOwner', 'Not the space owner', 403);
104
+ }
105
+ return row;
106
+ }
107
+
108
+ return {
109
+ '/xrpc/com.atproto.simplespace.createSpace': {
110
+ method: 'POST',
111
+ auth: 'required',
112
+ handler: async (request, _url, auth) => {
113
+ const body = await readJson(request);
114
+ if (!body) return errorResponse('InvalidRequest', 'Invalid JSON body');
115
+ const { did, type, skey, config } = body;
116
+ if (typeof did !== 'string' || typeof type !== 'string') {
117
+ return errorResponse('InvalidRequest', 'did and type are required');
118
+ }
119
+ if (!type.includes('.')) {
120
+ return errorResponse('InvalidType', `Not an NSID: ${type}`);
121
+ }
122
+
123
+ const hosted = await getDid();
124
+ const caller = /** @type {{did: string}} */ (auth).did;
125
+ if (did !== hosted || caller !== hosted) {
126
+ return errorResponse(
127
+ 'NotSpaceOwner',
128
+ 'Can only create spaces anchored on the hosted account',
129
+ 403,
130
+ );
131
+ }
132
+ if (typeof skey === 'string' && skey.length > 512) {
133
+ return errorResponse('InvalidRequest', 'skey is too long');
134
+ }
135
+
136
+ const uri = formatSpaceUri({
137
+ spaceDid: did,
138
+ spaceType: type,
139
+ skey: skey || createTid(),
140
+ });
141
+
142
+ const scopeError = checkManageScope(auth, uri, 'create');
143
+ if (scopeError) return scopeError;
144
+
145
+ if (await spaceStorage.getSpace(uri)) {
146
+ return errorResponse('SpaceAlreadyExists', `Exists: ${uri}`);
147
+ }
148
+
149
+ const appAccess = config?.appAccess;
150
+ await spaceStorage.putSpace(
151
+ makeSpaceRow(uri, {
152
+ isOwner: true,
153
+ policy: POLICIES.includes(config?.policy)
154
+ ? config.policy
155
+ : 'member-list',
156
+ managingApp: config?.managingApp ?? null,
157
+ appAccessType:
158
+ appAccess?.$type === 'com.atproto.simplespace.defs#allowList'
159
+ ? 'allowList'
160
+ : 'open',
161
+ appAllowed: appAccess?.allowed ?? [],
162
+ }),
163
+ );
164
+ return Response.json({ uri });
165
+ },
166
+ },
167
+
168
+ '/xrpc/com.atproto.simplespace.updateSpace': {
169
+ method: 'POST',
170
+ auth: 'required',
171
+ handler: async (request, _url, auth) => {
172
+ const body = await readJson(request);
173
+ if (!body?.space) {
174
+ return errorResponse('InvalidRequest', 'space is required');
175
+ }
176
+ const scopeError = checkManageScope(auth, body.space, 'update');
177
+ if (scopeError) return scopeError;
178
+
179
+ const row = await ownedSpace(body.space, auth);
180
+ if (row instanceof Response) return row;
181
+
182
+ // Only fields actually present are changed, so a partial update cannot
183
+ // silently reset the rest of the config.
184
+ const appAccess = body.appAccess;
185
+ await spaceStorage.putSpace({
186
+ ...row,
187
+ policy: POLICIES.includes(body.policy) ? body.policy : row.policy,
188
+ managingApp:
189
+ body.managingApp !== undefined ? body.managingApp : row.managingApp,
190
+ appAccessType: appAccess
191
+ ? appAccess.$type === 'com.atproto.simplespace.defs#allowList'
192
+ ? 'allowList'
193
+ : 'open'
194
+ : row.appAccessType,
195
+ appAllowed: appAccess ? (appAccess.allowed ?? []) : row.appAllowed,
196
+ });
197
+ return Response.json({});
198
+ },
199
+ },
200
+
201
+ '/xrpc/com.atproto.simplespace.deleteSpace': {
202
+ method: 'POST',
203
+ auth: 'required',
204
+ handler: async (request, _url, auth) => {
205
+ const body = await readJson(request);
206
+ if (!body?.space) {
207
+ return errorResponse('InvalidRequest', 'space is required');
208
+ }
209
+ const scopeError = checkManageScope(auth, body.space, 'delete');
210
+ if (scopeError) return scopeError;
211
+
212
+ const row = await ownedSpace(body.space, auth);
213
+ if (row instanceof Response) return row;
214
+
215
+ await spaceStorage.deleteSpace(body.space, new Date().toISOString());
216
+ // Best-effort fan-out; a syncer that misses it finds out on its next
217
+ // credential request, which the authority will refuse.
218
+ await notifyDeleted(ctx, body.space);
219
+ return Response.json({});
220
+ },
221
+ },
222
+
223
+ '/xrpc/com.atproto.simplespace.addMember': {
224
+ method: 'POST',
225
+ auth: 'required',
226
+ handler: async (request, _url, auth) => {
227
+ const body = await readJson(request);
228
+ if (!body?.space || !body?.did) {
229
+ return errorResponse('InvalidRequest', 'space and did are required');
230
+ }
231
+ const scopeError = checkManageScope(auth, body.space, 'update');
232
+ if (scopeError) return scopeError;
233
+ const row = await ownedSpace(body.space, auth);
234
+ if (row instanceof Response) return row;
235
+ await spaceStorage.addMember(body.space, body.did);
236
+ return Response.json({});
237
+ },
238
+ },
239
+
240
+ '/xrpc/com.atproto.simplespace.removeMember': {
241
+ method: 'POST',
242
+ auth: 'required',
243
+ handler: async (request, _url, auth) => {
244
+ const body = await readJson(request);
245
+ if (!body?.space || !body?.did) {
246
+ return errorResponse('InvalidRequest', 'space and did are required');
247
+ }
248
+ const scopeError = checkManageScope(auth, body.space, 'update');
249
+ if (scopeError) return scopeError;
250
+ const row = await ownedSpace(body.space, auth);
251
+ if (row instanceof Response) return row;
252
+ // Removing a member does not revoke credentials already issued; those
253
+ // stay valid until they expire.
254
+ await spaceStorage.removeMember(body.space, body.did);
255
+ return Response.json({});
256
+ },
257
+ },
258
+
259
+ '/xrpc/com.atproto.simplespace.listMembers': {
260
+ auth: 'required',
261
+ handler: async (_request, url, auth) => {
262
+ const space = url.searchParams.get('space');
263
+ if (!space) return errorResponse('InvalidRequest', 'space is required');
264
+
265
+ const raw = url.searchParams.get('limit');
266
+ const limit = raw === null ? 100 : Number(raw);
267
+ if (!Number.isInteger(limit) || limit < 1 || limit > 1000) {
268
+ return errorResponse(
269
+ 'InvalidRequest',
270
+ 'limit must be an integer between 1 and 1000',
271
+ );
272
+ }
273
+
274
+ const row = await ownedSpace(space, auth);
275
+ if (row instanceof Response) return row;
276
+
277
+ const { dids, cursor } = await spaceStorage.listMembers(
278
+ space,
279
+ url.searchParams.get('cursor'),
280
+ limit,
281
+ );
282
+ return Response.json({
283
+ ...(cursor ? { cursor } : {}),
284
+ members: dids.map((did) => ({ did })),
285
+ });
286
+ },
287
+ },
288
+
289
+ // A repo host telling this authority that one of its users wrote. Maintains
290
+ // the writer set, which is what listRepos enumerates as the sync boundary.
291
+ '/xrpc/com.atproto.space.notifyWrite': {
292
+ method: 'POST',
293
+ handler: async (request) => {
294
+ const body = await readJson(request);
295
+ if (!body?.space || !body?.repo || !body?.rev) {
296
+ return errorResponse(
297
+ 'InvalidRequest',
298
+ 'space, repo and rev are required',
299
+ );
300
+ }
301
+
302
+ const hosted = await getDid();
303
+ if (!hosted) {
304
+ return errorResponse('SpaceNotFound', 'Server not initialised', 404);
305
+ }
306
+
307
+ /** @type {{iss: string}} */
308
+ let caller;
309
+ try {
310
+ caller = await verifyServiceAuth({
311
+ jwt: (request.headers.get('authorization') ?? '').replace(
312
+ /^Bearer\s+/i,
313
+ '',
314
+ ),
315
+ aud: hosted,
316
+ lxm: 'com.atproto.space.notifyWrite',
317
+ resolveDid,
318
+ verifier,
319
+ });
320
+ } catch (err) {
321
+ if (err instanceof SpaceTokenError) {
322
+ return errorResponse(err.code, err.message, 401);
323
+ }
324
+ throw err;
325
+ }
326
+
327
+ // A repo host may only report its own account's writes.
328
+ if (caller.iss !== body.repo) {
329
+ return errorResponse(
330
+ 'Forbidden',
331
+ 'Can only report writes for the calling account',
332
+ 403,
333
+ );
334
+ }
335
+
336
+ const row = await spaceStorage.getSpace(body.space);
337
+ if (!row || !row.isOwner) {
338
+ return errorResponse('SpaceNotFound', 'Space not found', 404);
339
+ }
340
+
341
+ const hash = body.hash?.$bytes
342
+ ? Uint8Array.from(atob(body.hash.$bytes), (c) => c.charCodeAt(0))
343
+ : new Uint8Array(0);
344
+ await spaceStorage.putSpaceWriter(
345
+ body.space,
346
+ body.repo,
347
+ body.rev,
348
+ hash,
349
+ );
350
+ return Response.json({});
351
+ },
352
+ },
353
+
354
+ // A syncer subscribing to write notifications. Authenticated with a space
355
+ // credential, so only someone the authority already admitted can subscribe.
356
+ '/xrpc/com.atproto.space.registerNotify': {
357
+ method: 'POST',
358
+ handler: async (request) => {
359
+ const body = await readJson(request);
360
+ if (!body?.space || !body?.endpoint) {
361
+ return errorResponse(
362
+ 'InvalidRequest',
363
+ 'space and endpoint are required',
364
+ );
365
+ }
366
+
367
+ const match = (request.headers.get('authorization') ?? '').match(
368
+ /^Bearer\s+(.+)$/i,
369
+ );
370
+ if (!match) {
371
+ return errorResponse(
372
+ 'AuthenticationRequired',
373
+ 'A space credential is required',
374
+ 401,
375
+ );
376
+ }
377
+ /** @type {{iss: string}} */
378
+ let credential;
379
+ try {
380
+ credential = await verifySpaceCredential({
381
+ credential: match[1],
382
+ space: body.space,
383
+ resolveDid,
384
+ verifier,
385
+ });
386
+ } catch (err) {
387
+ if (err instanceof SpaceTokenError) {
388
+ return errorResponse(err.code, err.message, 401);
389
+ }
390
+ throw err;
391
+ }
392
+
393
+ await spaceStorage.putCredentialRecipient(
394
+ body.space,
395
+ credential.iss,
396
+ body.endpoint,
397
+ new Date().toISOString(),
398
+ );
399
+ return Response.json({});
400
+ },
401
+ },
402
+
403
+ // An authority telling us one of its spaces is gone.
404
+ '/xrpc/com.atproto.space.notifySpaceDeleted': {
405
+ method: 'POST',
406
+ handler: async (request) => {
407
+ const body = await readJson(request);
408
+ if (!body?.space) {
409
+ return errorResponse('InvalidRequest', 'space is required');
410
+ }
411
+
412
+ const hosted = await getDid();
413
+ if (!hosted) {
414
+ return errorResponse('SpaceNotFound', 'Server not initialised', 404);
415
+ }
416
+
417
+ /** @type {{iss: string}} */
418
+ let caller;
419
+ try {
420
+ caller = await verifyServiceAuth({
421
+ jwt: (request.headers.get('authorization') ?? '').replace(
422
+ /^Bearer\s+/i,
423
+ '',
424
+ ),
425
+ aud: hosted,
426
+ lxm: 'com.atproto.space.notifySpaceDeleted',
427
+ resolveDid,
428
+ verifier,
429
+ });
430
+ } catch (err) {
431
+ if (err instanceof SpaceTokenError) {
432
+ return errorResponse(err.code, err.message, 401);
433
+ }
434
+ throw err;
435
+ }
436
+
437
+ // Only the space's own authority may declare it deleted.
438
+ const { spaceDid } = parseSpaceUri(body.space);
439
+ if (caller.iss !== spaceDid) {
440
+ return errorResponse(
441
+ 'Forbidden',
442
+ 'Only the space authority may delete a space',
443
+ 403,
444
+ );
445
+ }
446
+
447
+ const row = await spaceStorage.getSpace(body.space);
448
+ if (row) {
449
+ // Flag the member's repo rather than erasing it: the records are the
450
+ // member's own, and the proposal has repo hosts keep them.
451
+ await spaceStorage.deleteSpace(body.space, new Date().toISOString());
452
+ }
453
+ return Response.json({});
454
+ },
455
+ },
456
+ };
457
+ }
458
+
459
+ /**
460
+ * Tell every registered syncer that a space is gone. Best-effort: failures are
461
+ * swallowed, since a syncer that misses this finds out when its next credential
462
+ * request is refused.
463
+ *
464
+ * @param {Object} ctx
465
+ * @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
466
+ * @param {() => Promise<string|null>} ctx.getDid
467
+ * @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
468
+ * @param {typeof fetch} [ctx.fetch]
469
+ * @param {string} space
470
+ * @returns {Promise<void>}
471
+ */
472
+ async function notifyDeleted(ctx, space) {
473
+ const { spaceStorage } = ctx;
474
+ let recipients;
475
+ try {
476
+ recipients = await spaceStorage.listCredentialRecipients(space);
477
+ } catch {
478
+ return;
479
+ }
480
+ await Promise.all(
481
+ recipients.map(async (r) => {
482
+ try {
483
+ const iss = await ctx.getDid();
484
+ if (!iss) return;
485
+ const token = await createServiceAuth({
486
+ iss,
487
+ aud: r.serviceDid,
488
+ lxm: 'com.atproto.space.notifySpaceDeleted',
489
+ signer: await ctx.getSigner(),
490
+ });
491
+ const doFetch = ctx.fetch ?? fetch;
492
+ await doFetch(
493
+ `${r.serviceEndpoint}/xrpc/com.atproto.space.notifySpaceDeleted`,
494
+ {
495
+ method: 'POST',
496
+ headers: {
497
+ 'content-type': 'application/json',
498
+ authorization: `Bearer ${token}`,
499
+ },
500
+ body: JSON.stringify({ space }),
501
+ },
502
+ );
503
+ } catch {
504
+ // Best effort.
505
+ }
506
+ }),
507
+ );
508
+ }
@@ -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 {import('@pdsjs/core/ports').SignatureVerifierPort} ctx.verifier
8
+ * @returns {import('@pdsjs/core/pds').Routes}
9
+ */
10
+ export declare function createReadRoutes(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
+ verifier: import('@pdsjs/core/ports').SignatureVerifierPort;
18
+ }): import('@pdsjs/core/pds').Routes;