@pdsjs/spaces 2.0.1 → 2.0.3

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.
@@ -1,9 +1,54 @@
1
- // @pdsjs/spaces/memory-storage - in-memory SpaceStoragePort.
1
+ // @pdsjs/spaces/memory-storage - in-memory SpaceStoragePort, and the blob side
2
+ // of the route context.
2
3
  //
3
4
  // Backs unit tests and gives handler tests a store with no database. Keyed
4
5
  // exactly like the SQL adapters so the shared conformance suite exercises the
5
6
  // same behaviour everywhere.
6
7
 
8
+ /**
9
+ * The blob store and blob link table createSpaceRoutes wants, in memory. A real
10
+ * deployment passes the account's own store and link table; here they are two
11
+ * Maps that answer the same way.
12
+ *
13
+ * @returns {{blobs: import('@pdsjs/core/ports').BlobPort, linkBlob: (blobCid: string, recordUri: string, recordTime: number|null) => Promise<void>, unlinkBlobs: (recordUri: string) => Promise<void>, links: Map<string, Set<string>>, put: (cid: string, data: Uint8Array, mimeType?: string) => void}}
14
+ */
15
+ export function createMemoryBlobContext() {
16
+ /** @type {Map<string, {data: Uint8Array, mimeType: string}>} */
17
+ const stored = new Map();
18
+ /** @type {Map<string, Set<string>>} record uri -> blob cids */
19
+ const links = new Map();
20
+
21
+ return {
22
+ blobs: {
23
+ async get(_did, cid) {
24
+ const blob = stored.get(cid);
25
+ return blob ? { ...blob, data: new Uint8Array(blob.data) } : null;
26
+ },
27
+ async put(_did, cid, data, mimeType) {
28
+ stored.set(cid, { data: new Uint8Array(data), mimeType });
29
+ },
30
+ async delete(_did, cid) {
31
+ stored.delete(cid);
32
+ },
33
+ },
34
+ async linkBlob(blobCid, recordUri) {
35
+ let forRecord = links.get(recordUri);
36
+ if (!forRecord) {
37
+ forRecord = new Set();
38
+ links.set(recordUri, forRecord);
39
+ }
40
+ forRecord.add(blobCid);
41
+ },
42
+ async unlinkBlobs(recordUri) {
43
+ links.delete(recordUri);
44
+ },
45
+ links,
46
+ put(cid, data, mimeType = 'application/octet-stream') {
47
+ stored.set(cid, { data, mimeType });
48
+ },
49
+ };
50
+ }
51
+
7
52
  /**
8
53
  * Keyset pagination over a sorted key list.
9
54
  * @template T
@@ -117,17 +162,31 @@ export function createMemorySpaceStorage() {
117
162
  return records.get(space)?.get(`${collection}/${rkey}`)?.cid ?? null;
118
163
  },
119
164
 
120
- async listSpaceRecords(space, collection, cursor, limit) {
165
+ async listSpaceRecords(
166
+ space,
167
+ { collection, cursor, limit, reverse, excludeValues },
168
+ ) {
121
169
  const inSpace = records.get(space) ?? new Map();
122
- const keys = [...inSpace.values()]
123
- .filter((r) => r.collection === collection)
124
- .map((r) => r.rkey)
170
+ let keys = [...inSpace.values()]
171
+ .filter((r) => !collection || r.collection === collection)
172
+ .map((r) => `${r.collection}/${r.rkey}`)
125
173
  .sort();
126
- const { items, cursor: next } = page(keys, cursor, limit, (rkey) => {
127
- const row = inSpace.get(`${collection}/${rkey}`);
128
- return { ...row, value: new Uint8Array(row.value) };
174
+ // Descending unless the caller asks otherwise, matching the reference.
175
+ if (!reverse) keys.reverse();
176
+ if (cursor) {
177
+ keys = keys.filter((k) => (reverse ? k > cursor : k < cursor));
178
+ }
179
+ const items = keys.slice(0, limit).map((key) => {
180
+ const row = inSpace.get(key);
181
+ const { value, ...rest } = row;
182
+ return excludeValues
183
+ ? { ...rest }
184
+ : { ...rest, value: new Uint8Array(value) };
129
185
  });
130
- return { records: items, cursor: next };
186
+ return {
187
+ records: items,
188
+ cursor: items.length === limit ? keys[limit - 1] : null,
189
+ };
131
190
  },
132
191
 
133
192
  async listAllSpaceRecords(space) {
@@ -226,19 +285,27 @@ export function createMemorySpaceStorage() {
226
285
  space,
227
286
  serviceDid,
228
287
  serviceEndpoint,
229
- lastIssuedAt,
288
+ expiresAt,
230
289
  ) {
231
290
  let inSpace = recipients.get(space);
232
291
  if (!inSpace) {
233
292
  inSpace = new Map();
234
293
  recipients.set(space, inSpace);
235
294
  }
236
- inSpace.set(serviceDid, { serviceDid, serviceEndpoint, lastIssuedAt });
295
+ inSpace.set(serviceDid, { serviceDid, serviceEndpoint, expiresAt });
296
+ },
297
+
298
+ async deleteCredentialRecipient(space, serviceDid) {
299
+ recipients.get(space)?.delete(serviceDid);
237
300
  },
238
301
 
239
302
  async listCredentialRecipients(space) {
240
303
  const inSpace = recipients.get(space) ?? new Map();
241
- return [...inSpace.keys()].sort().map((k) => ({ ...inSpace.get(k) }));
304
+ const now = new Date().toISOString();
305
+ return [...inSpace.keys()]
306
+ .sort()
307
+ .map((k) => ({ ...inSpace.get(k) }))
308
+ .filter((r) => r.expiresAt > now);
242
309
  },
243
310
  };
244
311
  }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Forward a write notice to every service registered for this space.
3
+ *
4
+ * Best effort, and deliberately so: sync correctness rests on comparing set
5
+ * hashes, so a syncer that misses a notice finds out on the next one or on its
6
+ * own next walk. One unreachable recipient must not fail the others, and none of
7
+ * them may fail the write that produced the notice.
8
+ *
9
+ * The caller awaits this, which puts one round trip per recipient in front of
10
+ * the response. A Worker cancels a promise still running when it answers, and
11
+ * this package holds no execution context to hand the work to, so backgrounding
12
+ * it here would mean dropping notifications on Cloudflare.
13
+ *
14
+ * @param {Object} ctx
15
+ * @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
16
+ * @param {() => Promise<import('./token.js').SpaceSigner>} ctx.getSigner
17
+ * @param {typeof fetch} [ctx.fetch]
18
+ * @param {Object} notice
19
+ * @param {string} notice.authorityDid - the space's authority, which signs
20
+ * @param {string} notice.space
21
+ * @param {string} notice.repo - the account whose repo advanced
22
+ * @param {string} notice.rev
23
+ * @param {Uint8Array} notice.hash - the repo's commit hash after the write
24
+ * @returns {Promise<void>}
25
+ */
26
+ export declare function forwardToSyncers(ctx: {
27
+ spaceStorage: import('@pdsjs/core/ports').SpaceStoragePort;
28
+ getSigner: () => Promise<import('./token.js').SpaceSigner>;
29
+ fetch?: typeof fetch;
30
+ }, { authorityDid, space, repo, rev, hash }: {
31
+ authorityDid: string;
32
+ space: string;
33
+ repo: string;
34
+ rev: string;
35
+ hash: Uint8Array;
36
+ }): Promise<void>;
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<import('./token.js').SpaceSigner>} 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,23 +1,31 @@
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
- * @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
9
+ * @param {() => Promise<import('./token.js').SpaceSigner>} ctx.getSigner
6
10
  * - the account's signing key, used to sign repo commits and space tokens
7
- * @param {(did: string) => Promise<any>} ctx.resolveDid - DID document lookup,
11
+ * @param {import('@pdsjs/core/ports').DidResolverPort} ctx.resolveDid - DID document lookup,
8
12
  * injected so this package stays dependency-free
9
13
  * @param {import('@pdsjs/core/ports').SignatureVerifierPort} ctx.verifier
14
+ * @param {import('@pdsjs/core/ports').RecordWriteGuardPort} [ctx.recordGuard]
15
+ * - a veto over writes in its named collections, e.g. git branch protection
10
16
  * @param {typeof fetch} [ctx.fetch] - injected for the outbound notification
11
17
  * calls, so tests can observe them without a network
12
18
  * @returns {import('@pdsjs/core/pds').Routes}
13
19
  */
14
20
  export declare function createSpaceRoutes(ctx: {
15
21
  spaceStorage: import('@pdsjs/core/ports').SpaceStoragePort;
22
+ blobs: import('@pdsjs/core/ports').BlobPort;
23
+ linkBlob: (blobCid: string, recordUri: string, recordTime: number | null) => Promise<void>;
24
+ unlinkBlobs: (recordUri: string) => Promise<void>;
16
25
  getDid: () => Promise<string | null>;
17
- getSigner: () => Promise<{
18
- sign: (bytes: Uint8Array) => Promise<Uint8Array>;
19
- }>;
20
- resolveDid: (did: string) => Promise<any>;
26
+ getSigner: () => Promise<import('./token.js').SpaceSigner>;
27
+ resolveDid: import('@pdsjs/core/ports').DidResolverPort;
21
28
  verifier: import('@pdsjs/core/ports').SignatureVerifierPort;
29
+ recordGuard?: import('@pdsjs/core/ports').RecordWriteGuardPort;
22
30
  fetch?: typeof fetch;
23
31
  }): import('@pdsjs/core/pds').Routes;
package/src/routes.js CHANGED
@@ -13,12 +13,18 @@ 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
- * @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
21
+ * @param {() => Promise<import('./token.js').SpaceSigner>} ctx.getSigner
18
22
  * - the account's signing key, used to sign repo commits and space tokens
19
- * @param {(did: string) => Promise<any>} ctx.resolveDid - DID document lookup,
23
+ * @param {import('@pdsjs/core/ports').DidResolverPort} ctx.resolveDid - DID document lookup,
20
24
  * injected so this package stays dependency-free
21
25
  * @param {import('@pdsjs/core/ports').SignatureVerifierPort} ctx.verifier
26
+ * @param {import('@pdsjs/core/ports').RecordWriteGuardPort} [ctx.recordGuard]
27
+ * - a veto over writes in its named collections, e.g. git branch protection
22
28
  * @param {typeof fetch} [ctx.fetch] - injected for the outbound notification
23
29
  * calls, so tests can observe them without a network
24
30
  * @returns {import('@pdsjs/core/pds').Routes}
@@ -33,6 +39,13 @@ export function createSpaceRoutes(ctx) {
33
39
  if (!ctx.resolveDid || !ctx.verifier) {
34
40
  throw new Error('createSpaceRoutes requires resolveDid and a verifier');
35
41
  }
42
+ // Refused rather than defaulted: without the link callbacks a space record's
43
+ // blobs look unreferenced, and orphan cleanup deletes them.
44
+ if (!ctx.blobs || !ctx.linkBlob || !ctx.unlinkBlobs) {
45
+ throw new Error(
46
+ 'createSpaceRoutes requires blobs, linkBlob and unlinkBlobs',
47
+ );
48
+ }
36
49
  return {
37
50
  ...createWriteRoutes(ctx),
38
51
  ...createReadRoutes(ctx),
@@ -5,7 +5,7 @@
5
5
  * @param {string} opts.jwt
6
6
  * @param {string} opts.aud - our own DID; the token must be addressed to us
7
7
  * @param {string} opts.lxm - the method being called
8
- * @param {(did: string) => Promise<any>} opts.resolveDid
8
+ * @param {import('@pdsjs/core/ports').DidResolverPort} opts.resolveDid
9
9
  * @param {import('@pdsjs/core/ports').SignatureVerifierPort} opts.verifier
10
10
  * @returns {Promise<{iss: string}>}
11
11
  */
@@ -13,7 +13,7 @@ export declare function verifyServiceAuth({ jwt, aud, lxm, resolveDid, verifier,
13
13
  jwt: string;
14
14
  aud: string;
15
15
  lxm: string;
16
- resolveDid: (did: string) => Promise<any>;
16
+ resolveDid: import('@pdsjs/core/ports').DidResolverPort;
17
17
  verifier: import('@pdsjs/core/ports').SignatureVerifierPort;
18
18
  }): Promise<{
19
19
  iss: string;
@@ -29,14 +29,12 @@ export declare function verifyServiceAuth({ jwt, aud, lxm, resolveDid, verifier,
29
29
  * @param {string} opts.iss - our DID
30
30
  * @param {string} opts.aud - the service we are calling
31
31
  * @param {string} opts.lxm - the method, so the token cannot be replayed elsewhere
32
- * @param {{sign: (bytes: Uint8Array) => Promise<Uint8Array>}} opts.signer
32
+ * @param {import('./token.js').SpaceSigner} opts.signer
33
33
  * @returns {Promise<string>}
34
34
  */
35
35
  export declare function createServiceAuth({ iss, aud, lxm, signer }: {
36
36
  iss: string;
37
37
  aud: string;
38
38
  lxm: string;
39
- signer: {
40
- sign: (bytes: Uint8Array) => Promise<Uint8Array>;
41
- };
39
+ signer: import('./token.js').SpaceSigner;
42
40
  }): Promise<string>;
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { base64UrlEncode, bytesToHex } from '@pdsjs/core/crypto';
9
9
  import { atprotoSigningKey } from './authority.js';
10
- import { SpaceTokenError } from './token.js';
10
+ import { assertKeyAlg, SpaceTokenError, signerAlg } from './token.js';
11
11
 
12
12
  const CLOCK_SKEW_SEC = 5;
13
13
 
@@ -43,7 +43,7 @@ function decodeSig(b64) {
43
43
  * @param {string} opts.jwt
44
44
  * @param {string} opts.aud - our own DID; the token must be addressed to us
45
45
  * @param {string} opts.lxm - the method being called
46
- * @param {(did: string) => Promise<any>} opts.resolveDid
46
+ * @param {import('@pdsjs/core/ports').DidResolverPort} opts.resolveDid
47
47
  * @param {import('@pdsjs/core/ports').SignatureVerifierPort} opts.verifier
48
48
  * @returns {Promise<{iss: string}>}
49
49
  */
@@ -60,14 +60,19 @@ export async function verifyServiceAuth({
60
60
  }
61
61
  const [headerB64, payloadB64, sigB64] = parts;
62
62
 
63
+ /** @type {any} */
64
+ let header;
63
65
  /** @type {any} */
64
66
  let payload;
65
67
  try {
66
- decodePart(headerB64);
68
+ header = decodePart(headerB64);
67
69
  payload = decodePart(payloadB64);
68
70
  } catch {
69
71
  throw new SpaceTokenError('could not parse service token', 'BadJwt');
70
72
  }
73
+ if (typeof header?.alg !== 'string' || !header.alg) {
74
+ throw new SpaceTokenError('missing service token "alg"', 'BadJwt');
75
+ }
71
76
 
72
77
  const now = Math.floor(Date.now() / 1000);
73
78
  if (typeof payload.exp !== 'number' || now - CLOCK_SKEW_SEC >= payload.exp) {
@@ -91,30 +96,47 @@ export async function verifyServiceAuth({
91
96
  );
92
97
  }
93
98
 
94
- const didDoc = await resolveDid(payload.iss);
95
- if (!didDoc) {
96
- throw new SpaceTokenError(
97
- `could not resolve ${payload.iss}`,
98
- 'DidNotFound',
99
- );
100
- }
101
- const didKey = atprotoSigningKey(didDoc, payload.iss);
102
99
  const signingInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
100
+ const sig = decodeSig(sigB64);
103
101
 
104
- let valid;
105
- try {
106
- valid = await verifier.verify(didKey, signingInput, decodeSig(sigB64));
107
- } catch (err) {
108
- throw new SpaceTokenError(
109
- `could not verify service token: ${err instanceof Error ? err.message : String(err)}`,
110
- 'BadJwtSignature',
111
- );
112
- }
113
- if (!valid) {
114
- throw new SpaceTokenError(
115
- 'invalid service token signature',
116
- 'BadJwtSignature',
117
- );
102
+ /**
103
+ * @param {boolean} forceRefresh
104
+ * @returns {Promise<{didKey: string, valid: boolean}>}
105
+ */
106
+ const matchesSignature = async (forceRefresh) => {
107
+ const didDoc = await resolveDid(payload.iss, { forceRefresh });
108
+ if (!didDoc) {
109
+ throw new SpaceTokenError(
110
+ `could not resolve ${payload.iss}`,
111
+ 'DidNotFound',
112
+ );
113
+ }
114
+ const didKey = atprotoSigningKey(didDoc, payload.iss);
115
+ assertKeyAlg(didKey, header.alg);
116
+ try {
117
+ return {
118
+ didKey,
119
+ valid: await verifier.verify(didKey, signingInput, sig),
120
+ };
121
+ } catch (err) {
122
+ throw new SpaceTokenError(
123
+ `could not verify service token: ${err instanceof Error ? err.message : String(err)}`,
124
+ 'BadJwtSignature',
125
+ );
126
+ }
127
+ };
128
+
129
+ const first = await matchesSignature(false);
130
+ if (!first.valid) {
131
+ // A resolver that holds its answers can name a key the caller has rotated
132
+ // away from, and the token is signed by the current one.
133
+ const fresh = await matchesSignature(true);
134
+ if (fresh.didKey === first.didKey || !fresh.valid) {
135
+ throw new SpaceTokenError(
136
+ 'invalid service token signature',
137
+ 'BadJwtSignature',
138
+ );
139
+ }
118
140
  }
119
141
 
120
142
  return { iss: payload.iss };
@@ -131,12 +153,12 @@ export async function verifyServiceAuth({
131
153
  * @param {string} opts.iss - our DID
132
154
  * @param {string} opts.aud - the service we are calling
133
155
  * @param {string} opts.lxm - the method, so the token cannot be replayed elsewhere
134
- * @param {{sign: (bytes: Uint8Array) => Promise<Uint8Array>}} opts.signer
156
+ * @param {import('./token.js').SpaceSigner} opts.signer
135
157
  * @returns {Promise<string>}
136
158
  */
137
159
  export async function createServiceAuth({ iss, aud, lxm, signer }) {
138
160
  const now = Math.floor(Date.now() / 1000);
139
- const header = { typ: 'JWT', alg: 'ES256' };
161
+ const header = { typ: 'JWT', alg: signerAlg(signer) };
140
162
  const payload = {
141
163
  iss,
142
164
  aud,
package/src/token.d.ts CHANGED
@@ -4,20 +4,38 @@ 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
  };
24
+ export type SpaceSigner = {
25
+ sign: (bytes: Uint8Array) => Promise<Uint8Array>;
26
+ /**
27
+ * - p256 when absent
28
+ */
29
+ curve?: 'p256' | 'secp256k1';
30
+ };
31
+ /**
32
+ * The JWT `alg` for a signer's curve. A verifier resolves the issuer's key,
33
+ * reads its type, and refuses a header that names another algorithm before it
34
+ * checks the signature. So a secp256k1 key must stamp ES256K.
35
+ * @param {SpaceSigner} signer
36
+ * @returns {string}
37
+ */
38
+ export declare function signerAlg(signer: SpaceSigner): string;
21
39
  export declare class SpaceTokenError extends Error {
22
40
  code: string;
23
41
  /**
@@ -26,6 +44,18 @@ export declare class SpaceTokenError extends Error {
26
44
  */
27
45
  constructor(message: string, code?: string);
28
46
  }
47
+ /**
48
+ * Refuse a token whose header names an algorithm the issuer's key cannot
49
+ * produce. The signature would verify anyway, since the curve comes from the
50
+ * resolved key rather than the header. The reference implementation rejects
51
+ * the mismatch, so a token this server accepts has to be one the rest of the
52
+ * ecosystem accepts too.
53
+ *
54
+ * @param {string} didKey
55
+ * @param {string} alg - the token header's `alg`
56
+ * @throws {SpaceTokenError}
57
+ */
58
+ export declare function assertKeyAlg(didKey: string, alg: string): void;
29
59
  export type SpaceTokenPayload = {
30
60
  iss: string;
31
61
  /**
@@ -36,6 +66,12 @@ export type SpaceTokenPayload = {
36
66
  iat: number;
37
67
  exp: number;
38
68
  jti: string;
69
+ /**
70
+ * - the key the holder must prove possession of
71
+ */
72
+ cnf?: {
73
+ jkt: string;
74
+ };
39
75
  };
40
76
  export type SpaceTokenHeader = {
41
77
  alg: string;
@@ -44,20 +80,19 @@ export type SpaceTokenHeader = {
44
80
  };
45
81
  /**
46
82
  * @param {keyof typeof SPACE_TOKEN_TYPES} type
47
- * @param {{iss: string, sub: string, aud?: string, expiresInSec?: number, kid?: string, alg?: string}} opts
48
- * @param {{sign: (bytes: Uint8Array) => Promise<Uint8Array>}} signer
83
+ * @param {{iss: string, sub: string, aud?: string, dpopJkt?: string, expiresInSec?: number, kid?: string, alg?: string}} opts
84
+ * @param {SpaceSigner} signer
49
85
  * @returns {Promise<string>}
50
86
  */
51
87
  export declare function createSpaceToken(type: keyof typeof SPACE_TOKEN_TYPES, opts: {
52
88
  iss: string;
53
89
  sub: string;
54
90
  aud?: string;
91
+ dpopJkt?: string;
55
92
  expiresInSec?: number;
56
93
  kid?: string;
57
94
  alg?: string;
58
- }, signer: {
59
- sign: (bytes: Uint8Array) => Promise<Uint8Array>;
60
- }): Promise<string>;
95
+ }, signer: SpaceSigner): Promise<string>;
61
96
  /**
62
97
  * Structural validation only, no signature check. This is as far as we go for
63
98
  * client attestations, whose key comes from the client's JWKS rather than a DID
@@ -77,15 +112,16 @@ export declare function parseSpaceToken(type: keyof typeof SPACE_TOKEN_TYPES, jw
77
112
  * @param {keyof typeof SPACE_TOKEN_TYPES} type
78
113
  * @param {string} jwt
79
114
  * @param {Object} opts
80
- * @param {(iss: string, kid?: string) => Promise<string>} opts.getSigningKey
81
- * - resolves the issuer to a did:key. Given `kid` so it can honour the key id.
115
+ * @param {(iss: string, kid?: string, forceRefresh?: boolean) => Promise<string>} opts.getSigningKey
116
+ * - resolves the issuer to a did:key. Given `kid` so it can honour the key
117
+ * id, and `forceRefresh` to read past a held answer after a signature fails.
82
118
  * @param {import('@pdsjs/core/ports').SignatureVerifierPort} opts.verifier
83
119
  * @param {string} [opts.aud] - required audience, when the caller knows it
84
120
  * @param {string} [opts.sub] - required subject, when the caller knows it
85
121
  * @returns {Promise<{header: SpaceTokenHeader, payload: SpaceTokenPayload}>}
86
122
  */
87
123
  export declare function verifySpaceToken(type: keyof typeof SPACE_TOKEN_TYPES, jwt: string, opts: {
88
- getSigningKey: (iss: string, kid?: string) => Promise<string>;
124
+ getSigningKey: (iss: string, kid?: string, forceRefresh?: boolean) => Promise<string>;
89
125
  verifier: import('@pdsjs/core/ports').SignatureVerifierPort;
90
126
  aud?: string;
91
127
  sub?: string;