@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.
- package/package.json +2 -2
- package/src/authority.d.ts +10 -0
- package/src/authority.js +18 -0
- package/src/car.d.ts +13 -4
- package/src/car.js +17 -6
- package/src/dpop.d.ts +15 -0
- package/src/dpop.js +85 -0
- package/src/handlers/auth.d.ts +56 -1
- package/src/handlers/auth.js +112 -31
- package/src/handlers/manage.js +343 -62
- package/src/handlers/read.d.ts +2 -0
- package/src/handlers/read.js +148 -63
- package/src/handlers/write.d.ts +4 -0
- package/src/handlers/write.js +88 -24
- package/src/memory-storage.d.ts +14 -0
- package/src/memory-storage.js +79 -12
- package/src/notify.d.ts +38 -0
- package/src/notify.js +87 -0
- package/src/routes.d.ts +7 -0
- package/src/routes.js +11 -0
- package/src/token.d.ts +11 -1
- package/src/token.js +18 -3
package/src/handlers/manage.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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 {
|
|
116
|
-
if (typeof
|
|
117
|
-
return errorResponse('InvalidRequest', '
|
|
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 (
|
|
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:
|
|
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
|
-
//
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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?.
|
|
621
|
+
if (!body?.space || !body?.service) {
|
|
361
622
|
return errorResponse(
|
|
362
623
|
'InvalidRequest',
|
|
363
|
-
'space and
|
|
624
|
+
'space and service are required',
|
|
364
625
|
);
|
|
365
626
|
}
|
|
366
627
|
|
|
367
|
-
const
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
'
|
|
373
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
},
|
package/src/handlers/read.d.ts
CHANGED
|
@@ -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>;
|