@pdsjs/spaces 2.0.1 → 2.0.2

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.
@@ -11,13 +11,180 @@
11
11
 
12
12
  import { createTid } from '@pdsjs/core/repo';
13
13
  import { ScopePermissions } from '@pdsjs/core/scope';
14
+ import { namedServiceEndpoint } from '../authority.js';
15
+ import { forwardToSyncers } from '../notify.js';
14
16
  import { createServiceAuth, verifyServiceAuth } from '../service-auth.js';
15
17
  import { makeSpaceRow } from '../space-row.js';
16
18
  import { SpaceTokenError } from '../token.js';
17
19
  import { formatSpaceUri, parseSpaceUri } from '../uri.js';
18
- import { verifySpaceCredential } from './auth.js';
20
+ import {
21
+ createReadAuthorizer,
22
+ credentialFromRequest,
23
+ verifyPresentedCredential,
24
+ } from './auth.js';
25
+
26
+ // How long a write-notification registration lasts before a syncer has to
27
+ // renew it.
28
+ const REGISTRATION_TTL_MS = 24 * 60 * 60 * 1000;
29
+
30
+ /** @type {Record<string, string>} */
31
+ const POLICY_TYPES = {
32
+ 'com.atproto.simplespace.defs#publicPolicy': 'public',
33
+ 'com.atproto.simplespace.defs#memberListPolicy': 'member-list',
34
+ 'com.atproto.simplespace.defs#managingAppPolicy': 'managing-app',
35
+ };
36
+ const POLICY_VARIANTS = Object.fromEntries(
37
+ Object.entries(POLICY_TYPES).map(([type, policy]) => [policy, type]),
38
+ );
39
+
40
+ /** @type {Record<string, string>} */
41
+ const APP_ACCESS_TYPES = {
42
+ 'com.atproto.simplespace.defs#open': 'open',
43
+ 'com.atproto.simplespace.defs#allowList': 'allowList',
44
+ };
45
+ const APP_ACCESS_VARIANTS = Object.fromEntries(
46
+ Object.entries(APP_ACCESS_TYPES).map(([type, kind]) => [kind, type]),
47
+ );
48
+
49
+ // The host defaults, which the older `#spaceConfig` documents and a request
50
+ // carrying no config at all gets.
51
+ const DEFAULT_POLICY = 'member-list';
19
52
 
20
- const POLICIES = ['public', 'member-list', 'managing-app'];
53
+ /**
54
+ * Read a user policy in either spelling.
55
+ *
56
+ * The proposal turned this field from a string into a union while
57
+ * implementations were shipping. A union arrives as `{$type: '…#publicPolicy'}`
58
+ * with any managing app inside it; the earlier form is the bare string
59
+ * `'public'` with `managingApp` beside it, under the key `policy` or the older
60
+ * `mintPolicy`. Absent, the host default applies rather than a refusal: the
61
+ * earlier lexicon makes the field optional, and a client written against it
62
+ * sends nothing.
63
+ *
64
+ * @param {unknown} value
65
+ * @param {unknown} managingApp - the sibling field the string form uses
66
+ * @returns {{policy: string, managingApp: string|null}|Response}
67
+ */
68
+ function readPolicy(value, managingApp) {
69
+ if (value === undefined || value === null) {
70
+ return { policy: DEFAULT_POLICY, managingApp: null };
71
+ }
72
+
73
+ const asString = typeof value === 'string' ? value : null;
74
+ const variant = /** @type {{$type?: string, managingApp?: unknown}} */ (
75
+ asString === null ? value : {}
76
+ );
77
+ const named = asString ?? POLICY_TYPES[variant.$type ?? ''];
78
+
79
+ if (!named || !POLICY_VARIANTS[named]) {
80
+ return errorResponse(
81
+ 'UnsupportedPolicy',
82
+ `Not a policy this host implements: ${asString ?? variant.$type ?? '(none)'}`,
83
+ );
84
+ }
85
+ if (named !== 'managing-app') return { policy: named, managingApp: null };
86
+
87
+ // The union carries the managing app inside it; the string form beside it.
88
+ const app = asString === null ? variant.managingApp : managingApp;
89
+ if (typeof app !== 'string' || !app) {
90
+ return errorResponse(
91
+ 'UnsupportedPolicy',
92
+ 'A managing-app policy requires a managingApp',
93
+ );
94
+ }
95
+ return { policy: named, managingApp: app };
96
+ }
97
+
98
+ /**
99
+ * Read an appAccess union into the two columns that hold it. Absent, access is
100
+ * open, which is the default the earlier lexicon documents.
101
+ *
102
+ * @param {unknown} value
103
+ * @returns {{appAccessType: string, appAllowed: string[]}|Response}
104
+ */
105
+ function readAppAccess(value) {
106
+ if (value === undefined || value === null) {
107
+ return { appAccessType: 'open', appAllowed: [] };
108
+ }
109
+ const variant = /** @type {{$type?: string, allowed?: unknown}} */ (value);
110
+ const kind = APP_ACCESS_TYPES[variant.$type ?? ''];
111
+ if (!kind) {
112
+ return errorResponse(
113
+ 'UnsupportedAppAccess',
114
+ `Not an app access policy this host implements: ${variant.$type ?? '(none)'}`,
115
+ );
116
+ }
117
+ if (kind === 'open') return { appAccessType: 'open', appAllowed: [] };
118
+ if (
119
+ !Array.isArray(variant.allowed) ||
120
+ variant.allowed.some((c) => typeof c !== 'string')
121
+ ) {
122
+ return errorResponse(
123
+ 'UnsupportedAppAccess',
124
+ 'allowList requires a list of client IDs',
125
+ );
126
+ }
127
+ return { appAccessType: 'allowList', appAllowed: variant.allowed };
128
+ }
129
+
130
+ /**
131
+ * Where a request carries its config. The union form puts the fields at the top
132
+ * level; the earlier form nests them under `config`.
133
+ *
134
+ * @param {any} body
135
+ * @returns {any}
136
+ */
137
+ function configSource(body) {
138
+ if (body?.policy !== undefined || body?.appAccess !== undefined) return body;
139
+ return body?.config ?? {};
140
+ }
141
+
142
+ /**
143
+ * Read both policies out of a request in either shape.
144
+ * @param {any} body
145
+ * @returns {{policy: string, managingApp: string|null, appAccessType: string, appAllowed: string[]}|Response}
146
+ */
147
+ function readConfig(body) {
148
+ const source = configSource(body);
149
+ const policy = readPolicy(
150
+ source.policy ?? source.mintPolicy,
151
+ source.managingApp,
152
+ );
153
+ if (policy instanceof Response) return policy;
154
+ const appAccess = readAppAccess(source.appAccess);
155
+ if (appAccess instanceof Response) return appAccess;
156
+ return { ...policy, ...appAccess };
157
+ }
158
+
159
+ /**
160
+ * Both spellings of the config on the way out: the union fields at the top
161
+ * level, and the `#spaceConfig` object beside them. A client reads whichever it
162
+ * knows, and neither has to guess which server it is talking to.
163
+ *
164
+ * @param {import('@pdsjs/core/ports').SpaceRow} row
165
+ * @returns {Record<string, unknown>}
166
+ */
167
+ function writeConfig(row) {
168
+ const appAccess = {
169
+ $type: APP_ACCESS_VARIANTS[row.appAccessType],
170
+ ...(row.appAccessType === 'allowList' ? { allowed: row.appAllowed } : {}),
171
+ };
172
+ return {
173
+ policy: {
174
+ $type: POLICY_VARIANTS[row.policy],
175
+ ...(row.policy === 'managing-app'
176
+ ? { managingApp: row.managingApp }
177
+ : {}),
178
+ },
179
+ appAccess,
180
+ config: {
181
+ $type: 'com.atproto.simplespace.defs#spaceConfig',
182
+ policy: row.policy,
183
+ ...(row.managingApp ? { managingApp: row.managingApp } : {}),
184
+ appAccess,
185
+ },
186
+ };
187
+ }
21
188
 
22
189
  /**
23
190
  * @param {string} error
@@ -53,6 +220,7 @@ async function readJson(request) {
53
220
  */
54
221
  export function createManageRoutes(ctx) {
55
222
  const { spaceStorage, getDid, resolveDid, verifier } = ctx;
223
+ const authorizeRead = createReadAuthorizer({ getDid, resolveDid, verifier });
56
224
 
57
225
  /**
58
226
  * Management verbs are governed by the `manage=` scope param and are not
@@ -82,6 +250,53 @@ export function createManageRoutes(ctx) {
82
250
  return null;
83
251
  }
84
252
 
253
+ /**
254
+ * Check the space credential a subscriber presents, or the Response
255
+ * explaining why not.
256
+ * @param {Request} request
257
+ * @param {string} space
258
+ * @returns {Promise<Response|null>}
259
+ */
260
+ async function requireSpaceCredential(request, space) {
261
+ const credential = credentialFromRequest(request);
262
+ if (!credential) {
263
+ return errorResponse(
264
+ 'AuthenticationRequired',
265
+ 'A space credential is required',
266
+ 401,
267
+ );
268
+ }
269
+ try {
270
+ await verifyPresentedCredential({
271
+ request,
272
+ credential,
273
+ space,
274
+ resolveDid,
275
+ verifier,
276
+ });
277
+ return null;
278
+ } catch (err) {
279
+ if (err instanceof SpaceTokenError) {
280
+ return errorResponse(err.code, err.message, 401);
281
+ }
282
+ throw err;
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Registrations are the authority's to hold, so this server must govern the
288
+ * space.
289
+ * @param {string} space
290
+ * @returns {Promise<import('@pdsjs/core/ports').SpaceRow|Response>}
291
+ */
292
+ async function hostedSpace(space) {
293
+ const row = await spaceStorage.getSpace(space);
294
+ if (!row?.isOwner || row.deletedAt) {
295
+ return errorResponse('SpaceNotFound', 'Space not found', 404);
296
+ }
297
+ return row;
298
+ }
299
+
85
300
  /**
86
301
  * Load a space this server owns, or the Response explaining why not.
87
302
  * @param {string} space
@@ -105,6 +320,22 @@ export function createManageRoutes(ctx) {
105
320
  return row;
106
321
  }
107
322
 
323
+ /** @type {import('@pdsjs/core/pds').Route} */
324
+ const getSpaceRoute = {
325
+ auth: 'optional',
326
+ handler: async (request, url, auth) => {
327
+ const space = url.searchParams.get('space');
328
+ if (!space) return errorResponse('InvalidRequest', 'space is required');
329
+ const denied = await authorizeRead(request, space, auth);
330
+ if (denied) return denied;
331
+ const row = await spaceStorage.getSpace(space);
332
+ if (!row?.isOwner) {
333
+ return errorResponse('SpaceNotFound', 'Space not found', 404);
334
+ }
335
+ return Response.json({ uri: row.uri, ...writeConfig(row) });
336
+ },
337
+ };
338
+
108
339
  return {
109
340
  '/xrpc/com.atproto.simplespace.createSpace': {
110
341
  method: 'POST',
@@ -112,17 +343,21 @@ export function createManageRoutes(ctx) {
112
343
  handler: async (request, _url, auth) => {
113
344
  const body = await readJson(request);
114
345
  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');
346
+ const { type, skey } = body;
347
+ if (typeof type !== 'string') {
348
+ return errorResponse('InvalidRequest', 'type is required');
118
349
  }
119
350
  if (!type.includes('.')) {
120
351
  return errorResponse('InvalidType', `Not an NSID: ${type}`);
121
352
  }
122
353
 
354
+ // The space is anchored on the authenticated user, who becomes its
355
+ // owner. This server hosts one account, so any other caller is a miss.
356
+ // `did` is optional and older clients still send it; it has to name the
357
+ // caller, since nobody creates a space under another account.
123
358
  const hosted = await getDid();
124
359
  const caller = /** @type {{did: string}} */ (auth).did;
125
- if (did !== hosted || caller !== hosted) {
360
+ if (caller !== hosted || (body.did && body.did !== caller)) {
126
361
  return errorResponse(
127
362
  'NotSpaceOwner',
128
363
  'Can only create spaces anchored on the hosted account',
@@ -134,7 +369,7 @@ export function createManageRoutes(ctx) {
134
369
  }
135
370
 
136
371
  const uri = formatSpaceUri({
137
- spaceDid: did,
372
+ spaceDid: caller,
138
373
  spaceType: type,
139
374
  skey: skey || createTid(),
140
375
  });
@@ -142,25 +377,21 @@ export function createManageRoutes(ctx) {
142
377
  const scopeError = checkManageScope(auth, uri, 'create');
143
378
  if (scopeError) return scopeError;
144
379
 
380
+ const config = readConfig(body);
381
+ if (config instanceof Response) return config;
382
+
145
383
  if (await spaceStorage.getSpace(uri)) {
146
384
  return errorResponse('SpaceAlreadyExists', `Exists: ${uri}`);
147
385
  }
148
386
 
149
- const appAccess = config?.appAccess;
150
387
  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
- }),
388
+ makeSpaceRow(uri, { isOwner: true, ...config }),
163
389
  );
390
+ // The owner joins their own space. Membership is what a member-list
391
+ // space checks before it issues a credential, and that is the default
392
+ // policy, so without this the account that created a space cannot read
393
+ // it. addMember is a set insert, so re-creating a space is harmless.
394
+ await spaceStorage.addMember(uri, caller);
164
395
  return Response.json({ uri });
165
396
  },
166
397
  },
@@ -179,21 +410,29 @@ export function createManageRoutes(ctx) {
179
410
  const row = await ownedSpace(body.space, auth);
180
411
  if (row instanceof Response) return row;
181
412
 
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
- });
413
+ // Both fields are optional, so a partial update cannot silently reset
414
+ // the rest of the config. Absence has to be read from the request
415
+ // rather than from readConfig, which fills a missing field with the
416
+ // host default.
417
+ const source = configSource(body);
418
+ let policy = { policy: row.policy, managingApp: row.managingApp };
419
+ const sent = source.policy ?? source.mintPolicy;
420
+ if (sent !== undefined) {
421
+ const read = readPolicy(sent, source.managingApp);
422
+ if (read instanceof Response) return read;
423
+ policy = read;
424
+ }
425
+ let appAccess = {
426
+ appAccessType: row.appAccessType,
427
+ appAllowed: row.appAllowed,
428
+ };
429
+ if (source.appAccess !== undefined) {
430
+ const read = readAppAccess(source.appAccess);
431
+ if (read instanceof Response) return read;
432
+ appAccess = read;
433
+ }
434
+
435
+ await spaceStorage.putSpace({ ...row, ...policy, ...appAccess });
197
436
  return Response.json({});
198
437
  },
199
438
  },
@@ -220,6 +459,18 @@ export function createManageRoutes(ctx) {
220
459
  },
221
460
  },
222
461
 
462
+ // Authority role: describe a space. The config names the member policy and
463
+ // the clients allowed to reach it, so a caller outside the space perimeter
464
+ // does not get to read it.
465
+ '/xrpc/com.atproto.simplespace.getSpace': getSpaceRoute,
466
+
467
+ // The proposal moved this method from com.atproto.space to
468
+ // com.atproto.simplespace while implementations were already shipping, and
469
+ // both names are in use: the atproto reference PR serves the second,
470
+ // ngerakines.me/atproto-crates the first. Both answer here until the draft
471
+ // settles, since a client that guesses wrong reads the feature as absent.
472
+ '/xrpc/com.atproto.space.getSpace': getSpaceRoute,
473
+
223
474
  '/xrpc/com.atproto.simplespace.addMember': {
224
475
  method: 'POST',
225
476
  auth: 'required',
@@ -347,6 +598,16 @@ export function createManageRoutes(ctx) {
347
598
  body.rev,
348
599
  hash,
349
600
  );
601
+ // The authority is the only party that knows who is following this
602
+ // space, so forwarding is its job. A writer's own host cannot do it: the
603
+ // registrations are held here.
604
+ await forwardToSyncers(ctx, {
605
+ authorityDid: hosted,
606
+ space: body.space,
607
+ repo: body.repo,
608
+ rev: body.rev,
609
+ hash,
610
+ });
350
611
  return Response.json({});
351
612
  },
352
613
  },
@@ -357,45 +618,65 @@ export function createManageRoutes(ctx) {
357
618
  method: 'POST',
358
619
  handler: async (request) => {
359
620
  const body = await readJson(request);
360
- if (!body?.space || !body?.endpoint) {
621
+ if (!body?.space || !body?.service) {
361
622
  return errorResponse(
362
623
  'InvalidRequest',
363
- 'space and endpoint are required',
624
+ 'space and service are required',
364
625
  );
365
626
  }
366
627
 
367
- const match = (request.headers.get('authorization') ?? '').match(
368
- /^Bearer\s+(.+)$/i,
369
- );
370
- if (!match) {
628
+ const denied = await requireSpaceCredential(request, body.space);
629
+ if (denied) return denied;
630
+
631
+ const owned = await hostedSpace(body.space);
632
+ if (owned instanceof Response) return owned;
633
+
634
+ // The delivery URL comes from the subscriber's own DID document, so a
635
+ // registration can only ever point at an endpoint that service
636
+ // published for itself.
637
+ const didDoc = await resolveDid(body.service.split('#')[0]);
638
+ const endpoint = didDoc && namedServiceEndpoint(didDoc, body.service);
639
+ if (!endpoint) {
371
640
  return errorResponse(
372
- 'AuthenticationRequired',
373
- 'A space credential is required',
374
- 401,
641
+ 'ServiceNotResolvable',
642
+ `Could not resolve a service endpoint for ${body.service}`,
375
643
  );
376
644
  }
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
645
 
646
+ const expiresAt = new Date(Date.now() + REGISTRATION_TTL_MS)
647
+ .toISOString()
648
+ .replace(/\.\d{3}Z$/, '.000Z');
393
649
  await spaceStorage.putCredentialRecipient(
394
650
  body.space,
395
- credential.iss,
396
- body.endpoint,
397
- new Date().toISOString(),
651
+ body.service,
652
+ endpoint,
653
+ expiresAt,
398
654
  );
655
+ return Response.json({ expiresAt });
656
+ },
657
+ },
658
+
659
+ // Withdrawing a registration. The service identifier is not resolved here:
660
+ // a subscriber whose DID document has changed must still be able to leave.
661
+ '/xrpc/com.atproto.space.unregisterNotify': {
662
+ method: 'POST',
663
+ handler: async (request) => {
664
+ const body = await readJson(request);
665
+ if (!body?.space || !body?.service) {
666
+ return errorResponse(
667
+ 'InvalidRequest',
668
+ 'space and service are required',
669
+ );
670
+ }
671
+
672
+ const denied = await requireSpaceCredential(request, body.space);
673
+ if (denied) return denied;
674
+
675
+ const owned = await hostedSpace(body.space);
676
+ if (owned instanceof Response) return owned;
677
+
678
+ // Idempotent: succeeds whether or not a registration existed.
679
+ await spaceStorage.deleteCredentialRecipient(body.space, body.service);
399
680
  return Response.json({});
400
681
  },
401
682
  },
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * @param {Object} ctx
3
3
  * @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
4
+ * @param {import('@pdsjs/core/ports').BlobPort} ctx.blobs
4
5
  * @param {() => Promise<string|null>} ctx.getDid
5
6
  * @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
6
7
  * @param {(did: string) => Promise<any>} ctx.resolveDid
@@ -9,6 +10,7 @@
9
10
  */
10
11
  export declare function createReadRoutes(ctx: {
11
12
  spaceStorage: import('@pdsjs/core/ports').SpaceStoragePort;
13
+ blobs: import('@pdsjs/core/ports').BlobPort;
12
14
  getDid: () => Promise<string | null>;
13
15
  getSigner: () => Promise<{
14
16
  sign: (bytes: Uint8Array) => Promise<Uint8Array>;