@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,436 @@
1
+ // @pdsjs/spaces/handlers/auth - the space auth chain.
2
+ //
3
+ // app -> PDS.getDelegationToken (OAuth) proves the user delegated
4
+ // app -> authority.getSpaceCredential exchanges it for a credential
5
+ // app -> repo host.<read endpoints> presents the credential
6
+ //
7
+ // The delegation token says only that the user delegated to *someone*; which
8
+ // app is acting is established separately by a client attestation, which the
9
+ // authority verifies itself.
10
+
11
+ import { ScopePermissions } from '@pdsjs/core/scope';
12
+ import {
13
+ didKeyResolver,
14
+ spaceHostAudience,
15
+ spaceHostEndpoint,
16
+ spaceSigningKey,
17
+ } from '../authority.js';
18
+ import { createServiceAuth } from '../service-auth.js';
19
+ import {
20
+ createSpaceToken,
21
+ parseSpaceToken,
22
+ SpaceTokenError,
23
+ verifySpaceToken,
24
+ } from '../token.js';
25
+ import { parseSpaceUri } from '../uri.js';
26
+
27
+ /**
28
+ * @param {string} error
29
+ * @param {string} message
30
+ * @param {number} [status]
31
+ * @returns {Response}
32
+ */
33
+ function errorResponse(error, message, status = 400) {
34
+ return Response.json({ error, message }, { status });
35
+ }
36
+
37
+ /**
38
+ * @param {unknown} err
39
+ * @returns {Response}
40
+ */
41
+ function tokenErrorResponse(err) {
42
+ if (err instanceof SpaceTokenError) {
43
+ return errorResponse(err.code, err.message, 401);
44
+ }
45
+ throw err;
46
+ }
47
+
48
+ /**
49
+ * @param {Request} request
50
+ * @returns {Promise<any>}
51
+ */
52
+ async function readJson(request) {
53
+ try {
54
+ return JSON.parse(await request.text());
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * @param {Object} ctx
62
+ * @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
63
+ * @param {() => Promise<string|null>} ctx.getDid
64
+ * @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
65
+ * @param {(did: string) => Promise<any>} ctx.resolveDid
66
+ * @param {import('@pdsjs/core/ports').SignatureVerifierPort} ctx.verifier
67
+ * @param {typeof fetch} [ctx.fetch]
68
+ * @returns {import('@pdsjs/core/pds').Routes}
69
+ */
70
+ export function createAuthRoutes(ctx) {
71
+ const { spaceStorage, getDid, getSigner, resolveDid, verifier } = ctx;
72
+ const getSigningKey = didKeyResolver(resolveDid);
73
+
74
+ return {
75
+ // PDS role: mint a token proving this user delegated to the caller. Signed
76
+ // by the user's own #atproto key and addressed to the authority's space
77
+ // host, so it is useless anywhere else and expires in a minute.
78
+ '/xrpc/com.atproto.space.getDelegationToken': {
79
+ auth: 'required',
80
+ handler: async (_request, url, auth) => {
81
+ const space = url.searchParams.get('space');
82
+ if (!space) return errorResponse('InvalidRequest', 'space is required');
83
+ /** @type {{spaceDid: string, spaceType: string, skey: string}} */
84
+ let parsed;
85
+ try {
86
+ parsed = parseSpaceUri(space);
87
+ } catch {
88
+ return errorResponse('InvalidSpaceUri', `Not a space uri: ${space}`);
89
+ }
90
+
91
+ const did = /** @type {{did: string}} */ (auth).did;
92
+
93
+ // `read` grants whole-space access and with it the delegation token;
94
+ // `read_self` deliberately does not, since it only ever covers the
95
+ // holder's own repo and needs no credential.
96
+ const permissions = new ScopePermissions(
97
+ /** @type {{scope?: string}} */ (auth).scope,
98
+ );
99
+ if (
100
+ !permissions.allowsSpace({
101
+ spaceType: parsed.spaceType,
102
+ authority: parsed.spaceDid,
103
+ skey: parsed.skey,
104
+ action: 'read',
105
+ userDid: did,
106
+ })
107
+ ) {
108
+ return errorResponse(
109
+ 'InvalidToken',
110
+ `Scope does not permit reading ${space}`,
111
+ 403,
112
+ );
113
+ }
114
+
115
+ const hosted = await getDid();
116
+ if (did !== hosted) {
117
+ return errorResponse(
118
+ 'Forbidden',
119
+ 'Can only mint a delegation token for the hosted account',
120
+ 403,
121
+ );
122
+ }
123
+
124
+ const token = await createSpaceToken(
125
+ 'delegation',
126
+ {
127
+ iss: did,
128
+ sub: space,
129
+ aud: spaceHostAudience(parsed.spaceDid),
130
+ },
131
+ await getSigner(),
132
+ );
133
+ return Response.json({ token });
134
+ },
135
+ },
136
+
137
+ // Authority role: exchange a delegation token for a space credential.
138
+ // Unauthenticated at the PDS layer — the delegation token IS the credential
139
+ // for this call, and is verified here.
140
+ '/xrpc/com.atproto.space.getSpaceCredential': {
141
+ method: 'POST',
142
+ handler: async (request) => {
143
+ const body = await readJson(request);
144
+ if (!body) return errorResponse('InvalidRequest', 'Invalid JSON body');
145
+ const { space, clientAttestation } = body;
146
+ if (typeof space !== 'string') {
147
+ return errorResponse('InvalidRequest', 'space is required');
148
+ }
149
+ try {
150
+ parseSpaceUri(space);
151
+ } catch {
152
+ return errorResponse('InvalidSpaceUri', `Not a space uri: ${space}`);
153
+ }
154
+
155
+ const header = request.headers.get('authorization') ?? '';
156
+ const match = header.match(/^Bearer\s+(.+)$/i);
157
+ if (!match) {
158
+ return errorResponse(
159
+ 'InvalidDelegationToken',
160
+ 'A delegation token is required',
161
+ 401,
162
+ );
163
+ }
164
+
165
+ const authorityDid = await getDid();
166
+ if (!authorityDid) {
167
+ return errorResponse('SpaceNotFound', 'Server not initialised', 404);
168
+ }
169
+
170
+ /** @type {string} */
171
+ let userDid;
172
+ try {
173
+ const { payload } = await verifySpaceToken('delegation', match[1], {
174
+ getSigningKey,
175
+ verifier,
176
+ aud: spaceHostAudience(authorityDid),
177
+ sub: space,
178
+ });
179
+ userDid = payload.iss;
180
+ } catch (err) {
181
+ return tokenErrorResponse(err);
182
+ }
183
+
184
+ // Structural validation only. Full verification means resolving the
185
+ // client_id to its client-metadata.json, fetching the published JWKS,
186
+ // and checking the signature against the key named by `kid`. Until then
187
+ // the attested client_id is advisory, so an allowList is only as strong
188
+ // as the caller's honesty. The reference implementation carries the same
189
+ // gap.
190
+ /** @type {string|undefined} */
191
+ let clientId;
192
+ if (clientAttestation) {
193
+ try {
194
+ clientId = parseSpaceToken('clientAttestation', clientAttestation)
195
+ .payload.iss;
196
+ } catch (err) {
197
+ return errorResponse(
198
+ 'InvalidClientAttestation',
199
+ err instanceof Error ? err.message : String(err),
200
+ );
201
+ }
202
+ }
203
+
204
+ const spaceRow = await spaceStorage.getSpace(space);
205
+ if (!spaceRow || !spaceRow.isOwner) {
206
+ return errorResponse('SpaceNotFound', 'Space not found', 404);
207
+ }
208
+ if (spaceRow.deletedAt) {
209
+ return errorResponse('SpaceDeleted', 'Space has been deleted');
210
+ }
211
+
212
+ // User perimeter.
213
+ let userAuthorized;
214
+ if (spaceRow.policy === 'public') {
215
+ userAuthorized = true;
216
+ } else if (spaceRow.policy === 'member-list') {
217
+ userAuthorized = await spaceStorage.isMember(space, userDid);
218
+ } else if (spaceRow.policy === 'managing-app') {
219
+ // Delegate the per-user decision to the named service. Any failure —
220
+ // unresolvable DID, unreachable app, malformed answer — denies rather
221
+ // than admits.
222
+ userAuthorized = await checkUserAccess({
223
+ managingApp: spaceRow.managingApp,
224
+ authorityDid,
225
+ space,
226
+ userDid,
227
+ clientId,
228
+ resolveDid,
229
+ getSigner,
230
+ fetch: ctx.fetch,
231
+ });
232
+ } else {
233
+ userAuthorized = false;
234
+ }
235
+ if (!userAuthorized) {
236
+ return errorResponse(
237
+ 'UserNotAuthorized',
238
+ 'User not authorized for this space',
239
+ 403,
240
+ );
241
+ }
242
+
243
+ // App perimeter.
244
+ if (spaceRow.appAccessType === 'allowList') {
245
+ if (!clientId || !spaceRow.appAllowed.includes(clientId)) {
246
+ return errorResponse(
247
+ 'AppNotAuthorized',
248
+ 'Application not authorized for this space',
249
+ 403,
250
+ );
251
+ }
252
+ }
253
+
254
+ const credential = await createSpaceToken(
255
+ 'credential',
256
+ { iss: authorityDid, sub: space },
257
+ await getSigner(),
258
+ );
259
+ return Response.json({ credential });
260
+ },
261
+ },
262
+
263
+ // Authority role: describe a space.
264
+ '/xrpc/com.atproto.space.getSpace': {
265
+ handler: async (_request, url) => {
266
+ const space = url.searchParams.get('space');
267
+ if (!space) return errorResponse('InvalidRequest', 'space is required');
268
+ const row = await spaceStorage.getSpace(space);
269
+ if (!row || !row.isOwner) {
270
+ return errorResponse('SpaceNotFound', 'Space not found', 404);
271
+ }
272
+ return Response.json({
273
+ uri: row.uri,
274
+ config: {
275
+ $type: 'com.atproto.simplespace.defs#spaceConfig',
276
+ policy: row.policy,
277
+ ...(row.managingApp ? { managingApp: row.managingApp } : {}),
278
+ appAccess:
279
+ row.appAccessType === 'allowList'
280
+ ? {
281
+ $type: 'com.atproto.simplespace.defs#allowList',
282
+ allowed: row.appAllowed,
283
+ }
284
+ : { $type: 'com.atproto.simplespace.defs#open' },
285
+ },
286
+ });
287
+ },
288
+ },
289
+
290
+ // Authority role: the writer set, which is the sync boundary. Accounts that
291
+ // have written at least one record — not the broader set allowed to write,
292
+ // which the authority may not even track.
293
+ '/xrpc/com.atproto.space.listRepos': {
294
+ handler: async (_request, url) => {
295
+ const space = url.searchParams.get('space');
296
+ if (!space) return errorResponse('InvalidRequest', 'space is required');
297
+
298
+ const raw = url.searchParams.get('limit');
299
+ const limit = raw === null ? 100 : Number(raw);
300
+ if (!Number.isInteger(limit) || limit < 1 || limit > 1000) {
301
+ return errorResponse(
302
+ 'InvalidRequest',
303
+ 'limit must be an integer between 1 and 1000',
304
+ );
305
+ }
306
+
307
+ const row = await spaceStorage.getSpace(space);
308
+ if (!row || !row.isOwner) {
309
+ return errorResponse('SpaceNotFound', 'Space not found', 404);
310
+ }
311
+
312
+ const { writers, cursor } = await spaceStorage.listSpaceWriters(
313
+ space,
314
+ url.searchParams.get('cursor'),
315
+ limit,
316
+ );
317
+ return Response.json({
318
+ ...(cursor ? { cursor } : {}),
319
+ repos: writers.map((w) => ({
320
+ did: w.did,
321
+ rev: w.rev,
322
+ hash: toJsonBytes(w.hash),
323
+ })),
324
+ });
325
+ },
326
+ },
327
+ };
328
+ }
329
+
330
+ /**
331
+ * @param {Uint8Array} bytes
332
+ * @returns {{$bytes: string}}
333
+ */
334
+ function toJsonBytes(bytes) {
335
+ let binary = '';
336
+ for (const b of bytes) binary += String.fromCharCode(b);
337
+ return { $bytes: btoa(binary).replace(/=+$/, '') };
338
+ }
339
+
340
+ /**
341
+ * Verify a space credential presented to a repo host.
342
+ *
343
+ * @param {Object} opts
344
+ * @param {string} opts.credential - the raw JWT
345
+ * @param {string} opts.space - the space the request targets
346
+ * @param {(did: string) => Promise<any>} opts.resolveDid
347
+ * @param {import('@pdsjs/core/ports').SignatureVerifierPort} opts.verifier
348
+ * @returns {Promise<{iss: string}>}
349
+ */
350
+ export async function verifySpaceCredential({
351
+ credential,
352
+ space,
353
+ resolveDid,
354
+ verifier,
355
+ }) {
356
+ const { spaceDid } = parseSpaceUri(space);
357
+ const { payload } = await verifySpaceToken('credential', credential, {
358
+ verifier,
359
+ // A credential is signed by the authority's space key, so it is verified
360
+ // without contacting the authority.
361
+ getSigningKey: async (iss) => {
362
+ const didDoc = await resolveDid(iss);
363
+ if (!didDoc) {
364
+ throw new SpaceTokenError(`Could not resolve ${iss}`, 'DidNotFound');
365
+ }
366
+ return spaceSigningKey(didDoc, iss);
367
+ },
368
+ sub: space,
369
+ });
370
+ if (payload.iss !== spaceDid) {
371
+ throw new SpaceTokenError(
372
+ 'Credential was not issued by this space authority',
373
+ 'BadJwtIss',
374
+ );
375
+ }
376
+ return { iss: payload.iss };
377
+ }
378
+
379
+ /**
380
+ * Ask a space's managing app whether to authorize a user.
381
+ *
382
+ * Fails closed: anything short of an explicit `authorized: true` is a refusal,
383
+ * so an app being down denies access rather than granting it.
384
+ *
385
+ * @param {Object} opts
386
+ * @param {string|null} opts.managingApp - a `did#serviceId` identifier
387
+ * @param {string} opts.authorityDid
388
+ * @param {string} opts.space
389
+ * @param {string} opts.userDid
390
+ * @param {string|undefined} opts.clientId
391
+ * @param {(did: string) => Promise<any>} opts.resolveDid
392
+ * @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} opts.getSigner
393
+ * @param {typeof fetch} [opts.fetch]
394
+ * @returns {Promise<boolean>}
395
+ */
396
+ export async function checkUserAccess({
397
+ managingApp,
398
+ authorityDid,
399
+ space,
400
+ userDid,
401
+ clientId,
402
+ resolveDid,
403
+ getSigner,
404
+ fetch: doFetch,
405
+ }) {
406
+ if (!managingApp) return false;
407
+ try {
408
+ const [appDid] = managingApp.split('#');
409
+ const didDoc = await resolveDid(appDid);
410
+ if (!didDoc) return false;
411
+ const endpoint = spaceHostEndpoint(didDoc, appDid);
412
+
413
+ const token = await createServiceAuth({
414
+ iss: authorityDid,
415
+ aud: managingApp,
416
+ lxm: 'com.atproto.simplespace.checkUserAccess',
417
+ signer: await getSigner(),
418
+ });
419
+
420
+ const url = new URL(
421
+ `${endpoint}/xrpc/com.atproto.simplespace.checkUserAccess`,
422
+ );
423
+ url.searchParams.set('space', space);
424
+ url.searchParams.set('user', userDid);
425
+ if (clientId) url.searchParams.set('clientId', clientId);
426
+
427
+ const res = await (doFetch ?? fetch)(url, {
428
+ headers: { authorization: `Bearer ${token}` },
429
+ });
430
+ if (!res.ok) return false;
431
+ const body = await res.json();
432
+ return body?.authorized === true;
433
+ } catch {
434
+ return false;
435
+ }
436
+ }
@@ -0,0 +1,20 @@
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
+ * @param {typeof fetch} [ctx.fetch]
9
+ * @returns {import('@pdsjs/core/pds').Routes}
10
+ */
11
+ export declare function createManageRoutes(ctx: {
12
+ spaceStorage: import('@pdsjs/core/ports').SpaceStoragePort;
13
+ getDid: () => Promise<string | null>;
14
+ getSigner: () => Promise<{
15
+ sign: (bytes: Uint8Array) => Promise<Uint8Array>;
16
+ }>;
17
+ resolveDid: (did: string) => Promise<any>;
18
+ verifier: import('@pdsjs/core/ports').SignatureVerifierPort;
19
+ fetch?: typeof fetch;
20
+ }): import('@pdsjs/core/pds').Routes;