@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/src/notify.js ADDED
@@ -0,0 +1,87 @@
1
+ // @pdsjs/spaces/notify - telling a space's syncers that a repo advanced.
2
+ //
3
+ // A space's writes are never broadcast, which is the point of one. So a service
4
+ // that wants to follow a space registers with the authority through
5
+ // registerNotify, and the authority forwards each write notice it learns of. A
6
+ // syncer that receives one holds a session and can read; the notice itself
7
+ // carries no records, only the rev the repo reached.
8
+
9
+ import { createServiceAuth } from './service-auth.js';
10
+
11
+ /**
12
+ * Forward a write notice to every service registered for this space.
13
+ *
14
+ * Best effort, and deliberately so: sync correctness rests on comparing set
15
+ * hashes, so a syncer that misses a notice finds out on the next one or on its
16
+ * own next walk. One unreachable recipient must not fail the others, and none of
17
+ * them may fail the write that produced the notice.
18
+ *
19
+ * The caller awaits this, which puts one round trip per recipient in front of
20
+ * the response. A Worker cancels a promise still running when it answers, and
21
+ * this package holds no execution context to hand the work to, so backgrounding
22
+ * it here would mean dropping notifications on Cloudflare.
23
+ *
24
+ * @param {Object} ctx
25
+ * @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
26
+ * @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
27
+ * @param {typeof fetch} [ctx.fetch]
28
+ * @param {Object} notice
29
+ * @param {string} notice.authorityDid - the space's authority, which signs
30
+ * @param {string} notice.space
31
+ * @param {string} notice.repo - the account whose repo advanced
32
+ * @param {string} notice.rev
33
+ * @param {Uint8Array} notice.hash - the repo's commit hash after the write
34
+ * @returns {Promise<void>}
35
+ */
36
+ export async function forwardToSyncers(
37
+ ctx,
38
+ { authorityDid, space, repo, rev, hash },
39
+ ) {
40
+ let recipients;
41
+ try {
42
+ recipients = await ctx.spaceStorage.listCredentialRecipients(space);
43
+ } catch {
44
+ return;
45
+ }
46
+ if (recipients.length === 0) return;
47
+
48
+ const body = JSON.stringify({ space, repo, rev, hash: toJsonBytes(hash) });
49
+ const doFetch = ctx.fetch ?? fetch;
50
+ const signer = await ctx.getSigner();
51
+
52
+ await Promise.all(
53
+ recipients.map(async (recipient) => {
54
+ try {
55
+ const token = await createServiceAuth({
56
+ iss: authorityDid,
57
+ aud: recipient.serviceDid,
58
+ lxm: 'com.atproto.space.notifyWrite',
59
+ signer,
60
+ });
61
+ await doFetch(
62
+ `${recipient.serviceEndpoint}/xrpc/com.atproto.space.notifyWrite`,
63
+ {
64
+ method: 'POST',
65
+ headers: {
66
+ 'content-type': 'application/json',
67
+ authorization: `Bearer ${token}`,
68
+ },
69
+ body,
70
+ },
71
+ );
72
+ } catch {
73
+ // Best effort.
74
+ }
75
+ }),
76
+ );
77
+ }
78
+
79
+ /**
80
+ * @param {Uint8Array} bytes
81
+ * @returns {{$bytes: string}}
82
+ */
83
+ function toJsonBytes(bytes) {
84
+ let binary = '';
85
+ for (const b of bytes) binary += String.fromCharCode(b);
86
+ return { $bytes: btoa(binary).replace(/=+$/, '') };
87
+ }
package/src/routes.d.ts CHANGED
@@ -1,6 +1,10 @@
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 - blobs referenced
5
+ * from space records live in the account's ordinary blob store
6
+ * @param {(blobCid: string, recordUri: string, recordTime: number|null) => Promise<void>} ctx.linkBlob
7
+ * @param {(recordUri: string) => Promise<void>} ctx.unlinkBlobs
4
8
  * @param {() => Promise<string|null>} ctx.getDid - the hosted account's DID
5
9
  * @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
6
10
  * - the account's signing key, used to sign repo commits and space tokens
@@ -13,6 +17,9 @@
13
17
  */
14
18
  export declare function createSpaceRoutes(ctx: {
15
19
  spaceStorage: import('@pdsjs/core/ports').SpaceStoragePort;
20
+ blobs: import('@pdsjs/core/ports').BlobPort;
21
+ linkBlob: (blobCid: string, recordUri: string, recordTime: number | null) => Promise<void>;
22
+ unlinkBlobs: (recordUri: string) => Promise<void>;
16
23
  getDid: () => Promise<string | null>;
17
24
  getSigner: () => Promise<{
18
25
  sign: (bytes: Uint8Array) => Promise<Uint8Array>;
package/src/routes.js CHANGED
@@ -13,6 +13,10 @@ import { createWriteRoutes } from './handlers/write.js';
13
13
  /**
14
14
  * @param {Object} ctx
15
15
  * @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
16
+ * @param {import('@pdsjs/core/ports').BlobPort} ctx.blobs - blobs referenced
17
+ * from space records live in the account's ordinary blob store
18
+ * @param {(blobCid: string, recordUri: string, recordTime: number|null) => Promise<void>} ctx.linkBlob
19
+ * @param {(recordUri: string) => Promise<void>} ctx.unlinkBlobs
16
20
  * @param {() => Promise<string|null>} ctx.getDid - the hosted account's DID
17
21
  * @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
18
22
  * - the account's signing key, used to sign repo commits and space tokens
@@ -33,6 +37,13 @@ export function createSpaceRoutes(ctx) {
33
37
  if (!ctx.resolveDid || !ctx.verifier) {
34
38
  throw new Error('createSpaceRoutes requires resolveDid and a verifier');
35
39
  }
40
+ // Refused rather than defaulted: without the link callbacks a space record's
41
+ // blobs look unreferenced, and orphan cleanup deletes them.
42
+ if (!ctx.blobs || !ctx.linkBlob || !ctx.unlinkBlobs) {
43
+ throw new Error(
44
+ 'createSpaceRoutes requires blobs, linkBlob and unlinkBlobs',
45
+ );
46
+ }
36
47
  return {
37
48
  ...createWriteRoutes(ctx),
38
49
  ...createReadRoutes(ctx),
package/src/token.d.ts CHANGED
@@ -4,18 +4,21 @@ export declare const SPACE_TOKEN_TYPES: {
4
4
  kid: string;
5
5
  expiresInSec: number;
6
6
  requireAud: boolean;
7
+ requireCnf: boolean;
7
8
  };
8
9
  credential: {
9
10
  typ: string;
10
11
  kid: string;
11
12
  expiresInSec: number;
12
13
  requireAud: boolean;
14
+ requireCnf: boolean;
13
15
  };
14
16
  clientAttestation: {
15
17
  typ: string;
16
18
  kid: undefined;
17
19
  expiresInSec: number;
18
20
  requireAud: boolean;
21
+ requireCnf: boolean;
19
22
  };
20
23
  };
21
24
  export declare class SpaceTokenError extends Error {
@@ -36,6 +39,12 @@ export type SpaceTokenPayload = {
36
39
  iat: number;
37
40
  exp: number;
38
41
  jti: string;
42
+ /**
43
+ * - the key the holder must prove possession of
44
+ */
45
+ cnf?: {
46
+ jkt: string;
47
+ };
39
48
  };
40
49
  export type SpaceTokenHeader = {
41
50
  alg: string;
@@ -44,7 +53,7 @@ export type SpaceTokenHeader = {
44
53
  };
45
54
  /**
46
55
  * @param {keyof typeof SPACE_TOKEN_TYPES} type
47
- * @param {{iss: string, sub: string, aud?: string, expiresInSec?: number, kid?: string, alg?: string}} opts
56
+ * @param {{iss: string, sub: string, aud?: string, dpopJkt?: string, expiresInSec?: number, kid?: string, alg?: string}} opts
48
57
  * @param {{sign: (bytes: Uint8Array) => Promise<Uint8Array>}} signer
49
58
  * @returns {Promise<string>}
50
59
  */
@@ -52,6 +61,7 @@ export declare function createSpaceToken(type: keyof typeof SPACE_TOKEN_TYPES, o
52
61
  iss: string;
53
62
  sub: string;
54
63
  aud?: string;
64
+ dpopJkt?: string;
55
65
  expiresInSec?: number;
56
66
  kid?: string;
57
67
  alg?: string;
package/src/token.js CHANGED
@@ -19,19 +19,26 @@ export const SPACE_TOKEN_TYPES = {
19
19
  kid: '#atproto',
20
20
  expiresInSec: 60,
21
21
  requireAud: true,
22
+ requireCnf: false,
22
23
  },
24
+ // An authority that publishes a dedicated `#atproto_space` key signs with it
25
+ // and names that key in `kid`. This server signs with the account's own
26
+ // `#atproto` key, which is the only key a PDS-hosted authority has.
23
27
  credential: {
24
28
  typ: 'atproto-space-credential+jwt',
25
- kid: '#atproto_space',
26
- // Multi-use across repo hosts until it expires, so it carries no aud.
29
+ kid: '#atproto',
30
+ // Multi-use across repo hosts until it expires, so it carries no aud. It is
31
+ // bound to the holder's key instead — see dpop.js.
27
32
  expiresInSec: 7200,
28
33
  requireAud: false,
34
+ requireCnf: true,
29
35
  },
30
36
  clientAttestation: {
31
37
  typ: 'atproto-client-attestation+jwt',
32
38
  kid: undefined,
33
39
  expiresInSec: 60,
34
40
  requireAud: true,
41
+ requireCnf: false,
35
42
  },
36
43
  };
37
44
 
@@ -57,6 +64,7 @@ export class SpaceTokenError extends Error {
57
64
  * @property {number} iat
58
65
  * @property {number} exp
59
66
  * @property {string} jti
67
+ * @property {{jkt: string}} [cnf] - the key the holder must prove possession of
60
68
  */
61
69
 
62
70
  /**
@@ -92,7 +100,7 @@ function decodeJsonPart(b64, part) {
92
100
 
93
101
  /**
94
102
  * @param {keyof typeof SPACE_TOKEN_TYPES} type
95
- * @param {{iss: string, sub: string, aud?: string, expiresInSec?: number, kid?: string, alg?: string}} opts
103
+ * @param {{iss: string, sub: string, aud?: string, dpopJkt?: string, expiresInSec?: number, kid?: string, alg?: string}} opts
96
104
  * @param {{sign: (bytes: Uint8Array) => Promise<Uint8Array>}} signer
97
105
  * @returns {Promise<string>}
98
106
  */
@@ -101,6 +109,9 @@ export async function createSpaceToken(type, opts, signer) {
101
109
  if (spec.requireAud && !opts.aud) {
102
110
  throw new SpaceTokenError(`a ${type} token requires an "aud"`);
103
111
  }
112
+ if (spec.requireCnf && !opts.dpopJkt) {
113
+ throw new SpaceTokenError(`a ${type} token requires a "dpopJkt"`);
114
+ }
104
115
 
105
116
  const iat = Math.floor(Date.now() / 1000);
106
117
  /** @type {SpaceTokenHeader} */
@@ -113,6 +124,7 @@ export async function createSpaceToken(type, opts, signer) {
113
124
  iss: opts.iss,
114
125
  sub: opts.sub,
115
126
  ...(opts.aud ? { aud: opts.aud } : undefined),
127
+ ...(opts.dpopJkt ? { cnf: { jkt: opts.dpopJkt } } : undefined),
116
128
  iat,
117
129
  exp: iat + (opts.expiresInSec ?? spec.expiresInSec),
118
130
  jti: bytesToHex(crypto.getRandomValues(new Uint8Array(16))),
@@ -163,6 +175,9 @@ export function parseSpaceToken(type, jwt) {
163
175
  if (spec.requireAud && !payload.aud) {
164
176
  throw new SpaceTokenError('missing token "aud"', 'BadJwtAudience');
165
177
  }
178
+ if (spec.requireCnf && !payload.cnf?.jkt) {
179
+ throw new SpaceTokenError('missing token "cnf.jkt"', 'BadJwtCnf');
180
+ }
166
181
  if (type === 'clientAttestation' && payload.iss !== payload.sub) {
167
182
  throw new SpaceTokenError(
168
183
  'client attestation "iss" and "sub" must both be the client_id',