@pdsjs/spaces 2.0.0 → 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
@@ -7,7 +7,11 @@
7
7
  // They share a wire shape and differ only in who signs, who they address, and
8
8
  // how long they live — so they are data, not three implementations.
9
9
 
10
- import { base64UrlDecode, base64UrlEncode, bytesToHex } from '@pdsjs/core/crypto';
10
+ import {
11
+ base64UrlDecode,
12
+ base64UrlEncode,
13
+ bytesToHex,
14
+ } from '@pdsjs/core/crypto';
11
15
 
12
16
  export const SPACE_TOKEN_TYPES = {
13
17
  delegation: {
@@ -15,19 +19,26 @@ export const SPACE_TOKEN_TYPES = {
15
19
  kid: '#atproto',
16
20
  expiresInSec: 60,
17
21
  requireAud: true,
22
+ requireCnf: false,
18
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.
19
27
  credential: {
20
28
  typ: 'atproto-space-credential+jwt',
21
- kid: '#atproto_space',
22
- // 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.
23
32
  expiresInSec: 7200,
24
33
  requireAud: false,
34
+ requireCnf: true,
25
35
  },
26
36
  clientAttestation: {
27
37
  typ: 'atproto-client-attestation+jwt',
28
38
  kid: undefined,
29
39
  expiresInSec: 60,
30
40
  requireAud: true,
41
+ requireCnf: false,
31
42
  },
32
43
  };
33
44
 
@@ -53,6 +64,7 @@ export class SpaceTokenError extends Error {
53
64
  * @property {number} iat
54
65
  * @property {number} exp
55
66
  * @property {string} jti
67
+ * @property {{jkt: string}} [cnf] - the key the holder must prove possession of
56
68
  */
57
69
 
58
70
  /**
@@ -88,7 +100,7 @@ function decodeJsonPart(b64, part) {
88
100
 
89
101
  /**
90
102
  * @param {keyof typeof SPACE_TOKEN_TYPES} type
91
- * @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
92
104
  * @param {{sign: (bytes: Uint8Array) => Promise<Uint8Array>}} signer
93
105
  * @returns {Promise<string>}
94
106
  */
@@ -97,6 +109,9 @@ export async function createSpaceToken(type, opts, signer) {
97
109
  if (spec.requireAud && !opts.aud) {
98
110
  throw new SpaceTokenError(`a ${type} token requires an "aud"`);
99
111
  }
112
+ if (spec.requireCnf && !opts.dpopJkt) {
113
+ throw new SpaceTokenError(`a ${type} token requires a "dpopJkt"`);
114
+ }
100
115
 
101
116
  const iat = Math.floor(Date.now() / 1000);
102
117
  /** @type {SpaceTokenHeader} */
@@ -109,6 +124,7 @@ export async function createSpaceToken(type, opts, signer) {
109
124
  iss: opts.iss,
110
125
  sub: opts.sub,
111
126
  ...(opts.aud ? { aud: opts.aud } : undefined),
127
+ ...(opts.dpopJkt ? { cnf: { jkt: opts.dpopJkt } } : undefined),
112
128
  iat,
113
129
  exp: iat + (opts.expiresInSec ?? spec.expiresInSec),
114
130
  jti: bytesToHex(crypto.getRandomValues(new Uint8Array(16))),
@@ -159,6 +175,9 @@ export function parseSpaceToken(type, jwt) {
159
175
  if (spec.requireAud && !payload.aud) {
160
176
  throw new SpaceTokenError('missing token "aud"', 'BadJwtAudience');
161
177
  }
178
+ if (spec.requireCnf && !payload.cnf?.jkt) {
179
+ throw new SpaceTokenError('missing token "cnf.jkt"', 'BadJwtCnf');
180
+ }
162
181
  if (type === 'clientAttestation' && payload.iss !== payload.sub) {
163
182
  throw new SpaceTokenError(
164
183
  'client attestation "iss" and "sub" must both be the client_id',
package/src/verifier.d.ts CHANGED
@@ -1,15 +1 @@
1
- /**
2
- * @param {string} didKey - did:key:z… (the did: prefix is optional)
3
- * @returns {{curve: 'p256'|'secp256k1', publicKey: Uint8Array}}
4
- */
5
- export declare function parseDidKey(didKey: string): {
6
- curve: 'p256' | 'secp256k1';
7
- publicKey: Uint8Array;
8
- };
9
- /**
10
- * @param {{secp256k1?: (publicKey: Uint8Array, data: Uint8Array, sig: Uint8Array) => Promise<boolean>}} [opts]
11
- * @returns {import('@pdsjs/core/ports').SignatureVerifierPort}
12
- */
13
- export declare function createVerifier(opts?: {
14
- secp256k1?: (publicKey: Uint8Array, data: Uint8Array, sig: Uint8Array) => Promise<boolean>;
15
- }): import('@pdsjs/core/ports').SignatureVerifierPort;
1
+ export { createVerifier, parseDidKey } from '@pdsjs/core/verify';
package/src/verifier.js CHANGED
@@ -1,74 +1,8 @@
1
1
  // @pdsjs/spaces/verifier - signature verification port.
2
2
  //
3
- // P-256 ships built in on crypto.subtle. secp256k1 is injected: hand-rolling
4
- // ECDSA verification is a different risk class from hand-rolling a hash, since
5
- // a subtle bug means accepting a forged credential rather than a failing test.
6
- // Without an injected verifier, spaces interoperate only with P-256 accounts.
3
+ // The implementation now lives in @pdsjs/core/verify so that core features
4
+ // (service-auth verification on account migration) and spaces (space
5
+ // credentials) share one verifier. This module re-exports it to keep the
6
+ // @pdsjs/spaces/verifier entry point stable for existing importers.
7
7
 
8
- import { base58btcDecode, P256_MULTICODEC } from '@pdsjs/core/plc';
9
-
10
- const SECP256K1_MULTICODEC = [0xe7, 0x01];
11
-
12
- /**
13
- * @param {string} didKey - did:key:z… (the did: prefix is optional)
14
- * @returns {{curve: 'p256'|'secp256k1', publicKey: Uint8Array}}
15
- */
16
- export function parseDidKey(didKey) {
17
- const value = didKey.startsWith('did:key:') ? didKey.slice(8) : didKey;
18
- if (!value.startsWith('z')) {
19
- throw new Error(`expected a did:key multibase value, got: ${didKey}`);
20
- }
21
- const bytes = base58btcDecode(value.slice(1));
22
- if (bytes[0] === P256_MULTICODEC[0] && bytes[1] === P256_MULTICODEC[1]) {
23
- return { curve: 'p256', publicKey: bytes.slice(2) };
24
- }
25
- if (
26
- bytes[0] === SECP256K1_MULTICODEC[0] &&
27
- bytes[1] === SECP256K1_MULTICODEC[1]
28
- ) {
29
- return { curve: 'secp256k1', publicKey: bytes.slice(2) };
30
- }
31
- throw new Error(`unsupported did:key multicodec: ${didKey}`);
32
- }
33
-
34
- /**
35
- * @param {{secp256k1?: (publicKey: Uint8Array, data: Uint8Array, sig: Uint8Array) => Promise<boolean>}} [opts]
36
- * @returns {import('@pdsjs/core/ports').SignatureVerifierPort}
37
- */
38
- export function createVerifier(opts = {}) {
39
- return {
40
- async verify(didKey, data, sig) {
41
- const { curve, publicKey } = parseDidKey(didKey);
42
- if (curve === 'p256') return verifyP256(publicKey, data, sig);
43
- if (!opts.secp256k1) {
44
- throw new Error(
45
- 'secp256k1 verification requires an injected verifier; ' +
46
- 'this deployment supports P-256 keys only',
47
- );
48
- }
49
- return opts.secp256k1(publicKey, data, sig);
50
- },
51
- };
52
- }
53
-
54
- /**
55
- * @param {Uint8Array} publicKey - 33-byte compressed point
56
- * @param {Uint8Array} data
57
- * @param {Uint8Array} sig - 64-byte raw r||s
58
- * @returns {Promise<boolean>}
59
- */
60
- async function verifyP256(publicKey, data, sig) {
61
- const key = await crypto.subtle.importKey(
62
- 'raw',
63
- /** @type {BufferSource} */ (publicKey),
64
- { name: 'ECDSA', namedCurve: 'P-256' },
65
- false,
66
- ['verify'],
67
- );
68
- return crypto.subtle.verify(
69
- { name: 'ECDSA', hash: 'SHA-256' },
70
- key,
71
- /** @type {BufferSource} */ (sig),
72
- /** @type {BufferSource} */ (data),
73
- );
74
- }
8
+ export { createVerifier, parseDidKey } from '@pdsjs/core/verify';