@certrev/cert-block 1.2.1 → 2.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.
Files changed (73) hide show
  1. package/CHANGELOG.md +230 -0
  2. package/README.md +135 -8
  3. package/dist/builder/card-contract.d.ts +143 -0
  4. package/dist/builder/card-contract.d.ts.map +1 -0
  5. package/dist/builder/card-contract.js +262 -0
  6. package/dist/builder/card-contract.js.map +1 -0
  7. package/dist/builder/card-report.d.ts +37 -0
  8. package/dist/builder/card-report.d.ts.map +1 -0
  9. package/dist/builder/card-report.js +43 -0
  10. package/dist/builder/card-report.js.map +1 -0
  11. package/dist/builder/cert-review-card.d.ts +7 -5
  12. package/dist/builder/cert-review-card.d.ts.map +1 -1
  13. package/dist/builder/cert-review-card.js +227 -18
  14. package/dist/builder/cert-review-card.js.map +1 -1
  15. package/dist/builder/contract-card.d.ts +53 -0
  16. package/dist/builder/contract-card.d.ts.map +1 -0
  17. package/dist/builder/contract-card.js +141 -0
  18. package/dist/builder/contract-card.js.map +1 -0
  19. package/dist/builder/delivered-face.d.ts +83 -0
  20. package/dist/builder/delivered-face.d.ts.map +1 -0
  21. package/dist/builder/delivered-face.js +135 -0
  22. package/dist/builder/delivered-face.js.map +1 -0
  23. package/dist/builder/delivery-runtime.d.ts +15 -1
  24. package/dist/builder/delivery-runtime.d.ts.map +1 -1
  25. package/dist/builder/delivery-runtime.js +26 -1
  26. package/dist/builder/delivery-runtime.js.map +1 -1
  27. package/dist/builder/index.d.ts +7 -1
  28. package/dist/builder/index.d.ts.map +1 -1
  29. package/dist/builder/index.js +8 -1
  30. package/dist/builder/index.js.map +1 -1
  31. package/dist/builder/ssr-seed.d.ts +31 -1
  32. package/dist/builder/ssr-seed.d.ts.map +1 -1
  33. package/dist/builder/ssr-seed.js +38 -4
  34. package/dist/builder/ssr-seed.js.map +1 -1
  35. package/dist/index.d.ts +3 -2
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +3 -1
  38. package/dist/index.js.map +1 -1
  39. package/dist/reader/reader-css.generated.d.ts +1 -1
  40. package/dist/reader/reader-css.generated.d.ts.map +1 -1
  41. package/dist/reader/reader-css.generated.js +1 -1
  42. package/dist/reader/reader-css.generated.js.map +1 -1
  43. package/dist/reader/render.d.ts.map +1 -1
  44. package/dist/reader/render.js +71 -34
  45. package/dist/reader/render.js.map +1 -1
  46. package/dist/reader/tokens.d.ts +2 -0
  47. package/dist/reader/tokens.d.ts.map +1 -1
  48. package/dist/reader/tokens.js +28 -6
  49. package/dist/reader/tokens.js.map +1 -1
  50. package/dist/verify/get-verified-delivery.d.ts +56 -0
  51. package/dist/verify/get-verified-delivery.d.ts.map +1 -1
  52. package/dist/verify/get-verified-delivery.js +49 -4
  53. package/dist/verify/get-verified-delivery.js.map +1 -1
  54. package/dist/verify/key-set.d.ts +67 -0
  55. package/dist/verify/key-set.d.ts.map +1 -0
  56. package/dist/verify/key-set.js +190 -0
  57. package/dist/verify/key-set.js.map +1 -0
  58. package/package.json +115 -112
  59. package/src/builder/card-contract.ts +314 -0
  60. package/src/builder/card-report.ts +61 -0
  61. package/src/builder/cert-review-card.tsx +332 -26
  62. package/src/builder/contract-card.tsx +159 -0
  63. package/src/builder/delivered-face.ts +156 -0
  64. package/src/builder/delivery-runtime.ts +32 -1
  65. package/src/builder/index.tsx +16 -1
  66. package/src/builder/ssr-seed.ts +56 -4
  67. package/src/index.ts +12 -0
  68. package/src/reader/reader-css.generated.ts +1 -1
  69. package/src/reader/reader.css +297 -2
  70. package/src/reader/render.ts +87 -38
  71. package/src/reader/tokens.ts +29 -6
  72. package/src/verify/get-verified-delivery.ts +85 -4
  73. package/src/verify/key-set.ts +225 -0
@@ -36,9 +36,24 @@
36
36
  * card paints its options face instead of nothing. A definitive answer (404/410, a key or signature that does not
37
37
  * verify, a revoked, expired or mismatched artifact) is never `unreachable`. `readVerifiedDelivery` is the
38
38
  * synchronous read that also serves a stale entry while refreshing it.
39
+ *
40
+ * 1.3.0: the response may also carry a server-rendered face as a second UNSIGNED sibling, `face: { html, css }`. It is
41
+ * read the way `renderDef` is (shape-checked, malformed is dropped, never read unless the artifact verified) and cached
42
+ * beside it, and `settleDelivery` hands it out only on a `render` verdict. NOTHING HERE VOUCHES FOR IT: the signature
43
+ * does not cover it. What binds it is the signed `payload.content.faceDigest` (cert-contract 0.6.0), and a caller
44
+ * paints it only after hashing it against that digest, as the Builder card does (`src/builder/delivered-face.ts`).
45
+ *
46
+ * 2.0.0: the response may also carry the contract-1 CARD DOCUMENT as a third UNSIGNED sibling, `card` (CertREV's portal
47
+ * serves it only for a brand on contract 1). It is kept exactly as served when it is a JSON object (any other value,
48
+ * `null` included, is no card), cached beside the payload, and handed out only on a `render` verdict. Like `face`,
49
+ * NOTHING HERE VOUCHES FOR IT: the signed `payload.content.faceDigest` binds it (`contract: 1`, `doc`), and the Builder
50
+ * card prints it only after its shape check and digest check pass (`src/builder/card-contract.ts`). A read with no
51
+ * card is the 1.3.1 object exactly. The read is made with `credentials: 'omit'`: it never sends a cookie.
39
52
  */
40
53
 
54
+ import { cardDocumentDigest } from '../builder/card-contract.js'
41
55
  import type { CertChromeRenderDef } from '../builder/cert-chrome-data.js'
56
+ import { type DeliveredFaceHashes, hashDeliveredFace } from '../builder/delivered-face.js'
42
57
  import type {
43
58
  CertDeliveryArtifact,
44
59
  CertPayload,
@@ -52,6 +67,16 @@ import { renderVerdict, verifyArtifact } from '../contract/kernel.js'
52
67
  import { TtlCache } from './cache.js'
53
68
  import { DEFAULT_FETCH_TIMEOUT_MS, fetchWithDeadline } from './fetch-with-deadline.js'
54
69
 
70
+ /**
71
+ * 1.3.0: the server-rendered face the Delivery API may serve beside the artifact. UNSIGNED and UNVERIFIED as carried
72
+ * here: paint it only when sha256 of each half's UTF-8 bytes equals the signed `payload.content.faceDigest`, and the
73
+ * digest's `defVersion` is the def served with it.
74
+ */
75
+ export interface DeliveredFace {
76
+ readonly html: string
77
+ readonly css: string
78
+ }
79
+
55
80
  /**
56
81
  * What one Delivery read settles to, as cached: a verified payload + its def (and, from 1.2.0, the signature it
57
82
  * verified with, so a server render can hand the signed envelope to the browser), or the reason not.
@@ -63,6 +88,18 @@ export type CachedDelivery =
63
88
  readonly renderDef: CertChromeRenderDef | null
64
89
  /** Absent only in a cache filled by hand (a test, a custom cache); a read always keeps it. */
65
90
  readonly signature?: CertSignature
91
+ /** 1.3.0: the unsigned face sibling, when the response carried a well-formed one; else absent. */
92
+ readonly face?: DeliveredFace
93
+ /** 2.0.0: the unsigned card-document sibling as served, when it was a JSON object; else absent. */
94
+ readonly card?: Readonly<Record<string, unknown>>
95
+ /**
96
+ * 2.0.0: the digest of `card` (lowercase hex sha256 of its RFC 8785 bytes; null when it cannot be computed),
97
+ * computed once by the read that cached it; present whenever `card` is. UNVERIFIED like the card: the caller
98
+ * compares it with the signed binding.
99
+ */
100
+ readonly cardDigest?: string | null
101
+ /** 2.0.0: the two hashes of `face`, computed once by the read; present whenever `face` is (null: no WebCrypto). */
102
+ readonly faceHashes?: DeliveredFaceHashes | null
66
103
  }
67
104
  | {
68
105
  readonly kind: 'suppressed'
@@ -80,6 +117,21 @@ export interface VerifiedDelivery {
80
117
  readonly signature?: CertSignature
81
118
  /** 1.2.0: on a suppress verdict, true when CertREV was not reached; else undefined. */
82
119
  readonly unreachable?: true
120
+ /**
121
+ * 1.3.0: on a `render` verdict, the server-rendered face served beside the artifact, when the response carried a
122
+ * well-formed one; else undefined (never on a suppress verdict). UNVERIFIED: see `DeliveredFace`.
123
+ */
124
+ readonly face?: DeliveredFace
125
+ /**
126
+ * 2.0.0: on a `render` verdict, the card document served beside the artifact (a JSON object, exactly as served);
127
+ * else undefined. UNVERIFIED: print it only after `parseCardDocument` and the digest check of
128
+ * `src/builder/card-contract.ts`, against the verified payload's `faceDigest`.
129
+ */
130
+ readonly card?: Readonly<Record<string, unknown>>
131
+ /** 2.0.0: `card`'s digest as the read computed it, beside `card`; see `CachedDelivery`. */
132
+ readonly cardDigest?: string | null
133
+ /** 2.0.0: `face`'s hashes as the read computed them, beside `face`; see `CachedDelivery`. */
134
+ readonly faceHashes?: DeliveredFaceHashes | null
83
135
  }
84
136
 
85
137
  export interface GetVerifiedDeliveryOptions {
@@ -230,6 +282,18 @@ export function parseRenderDef(value: unknown): CertChromeRenderDef | null {
230
282
  }
231
283
  }
232
284
 
285
+ /**
286
+ * 1.3.0: the face sibling, shape-checked: an object whose `html` and `css` are both strings, copied to exactly those
287
+ * two keys. Anything else is null, and the card paints exactly what it paints with no face. The markup is NOT judged
288
+ * here; the signed digest judges it at paint time.
289
+ */
290
+ export function parseDeliveredFace(value: unknown): DeliveredFace | null {
291
+ if (!isRecord(value)) return null
292
+ const { html, css } = value
293
+ if (!isString(html) || !isString(css)) return null
294
+ return { html, css }
295
+ }
296
+
233
297
  /** Fetch, parse and cryptographically verify one Delivery response. Never throws. */
234
298
  async function loadDelivery(opts: GetVerifiedDeliveryOptions): Promise<CachedDelivery> {
235
299
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch
@@ -238,7 +302,7 @@ async function loadDelivery(opts: GetVerifiedDeliveryOptions): Promise<CachedDel
238
302
  res = await fetchWithDeadline(
239
303
  fetchImpl,
240
304
  deliveryUrl(opts.baseUrl, opts.platform, opts.externalId, opts.design),
241
- { headers: { accept: 'application/json' } },
305
+ { headers: { accept: 'application/json' }, credentials: 'omit' },
242
306
  opts.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS,
243
307
  )
244
308
  } catch {
@@ -259,8 +323,8 @@ async function loadDelivery(opts: GetVerifiedDeliveryOptions): Promise<CachedDel
259
323
  let verdict: CertVerdict
260
324
  try {
261
325
  // `verifyArtifact`, so a tombstone verifies as itself and reports 'revoked'. The kernel reads
262
- // `payload` + `signature` (or the tombstone's own fields); the `renderDef` sibling is not
263
- // part of what is verified, and is never read unless the artifact verified.
326
+ // `payload` + `signature` (or the tombstone's own fields); the `renderDef` and (1.3.0) `face`
327
+ // siblings are not part of what is verified, and are never read unless the artifact verified.
264
328
  verdict = await verifyArtifact(body as unknown as CertDeliveryArtifact, opts.resolveKid, { ...opts.context })
265
329
  } catch {
266
330
  return suppressed('unknown_key')
@@ -268,11 +332,20 @@ async function loadDelivery(opts: GetVerifiedDeliveryOptions): Promise<CachedDel
268
332
  if (verdict.decision !== 'render') return suppressed(verdict.reason)
269
333
  // A `render` verdict came from an envelope (a tombstone never renders), so `signature` is the one just verified.
270
334
  const signature = isRecord(body.signature) ? (body.signature as unknown as CertSignature) : undefined
335
+ const face = parseDeliveredFace(body.face)
336
+ const card = isRecord(body.card) ? body.card : undefined
337
+ // 2.0.0: the digests a render compares with the signed binding, computed here, once per read, so a render that waited
338
+ // for this read never waits a second time. Under React 19, `use` matches promises by their order in a render: a
339
+ // second suspension (on a digest) after the first (on this read) was handed this read's result, and the card failed.
340
+ const cardDigest = card ? await cardDocumentDigest(card) : undefined
341
+ const faceHashes = face ? await hashDeliveredFace(face) : undefined
271
342
  return {
272
343
  kind: 'verified',
273
344
  payload: verdict.payload,
274
345
  renderDef: parseRenderDef(body.renderDef),
275
346
  ...(signature ? { signature } : {}),
347
+ ...(face ? { face, faceHashes } : {}),
348
+ ...(card ? { card, cardDigest } : {}),
276
349
  }
277
350
  }
278
351
 
@@ -287,7 +360,15 @@ export function settleDelivery(cached: CachedDelivery, context: RenderContext):
287
360
  }
288
361
  const verdict = renderVerdict(cached.payload, context)
289
362
  if (verdict.decision !== 'render') return { verdict, renderDef: null }
290
- return { verdict, renderDef: cached.renderDef, ...(cached.signature ? { signature: cached.signature } : {}) }
363
+ return {
364
+ verdict,
365
+ renderDef: cached.renderDef,
366
+ ...(cached.signature ? { signature: cached.signature } : {}),
367
+ ...(cached.face ? { face: cached.face } : {}),
368
+ ...(cached.faceHashes !== undefined ? { faceHashes: cached.faceHashes } : {}),
369
+ ...(cached.card ? { card: cached.card } : {}),
370
+ ...(cached.cardDigest !== undefined ? { cardDigest: cached.cardDigest } : {}),
371
+ }
291
372
  }
292
373
 
293
374
  /**
@@ -0,0 +1,225 @@
1
+ /**
2
+ * ─────────────────────────────────────────────────────────────────────────────
3
+ * The issuer key SET a card trusts (2.0.0): baked keys first, then CertREV's signed published set
4
+ * ─────────────────────────────────────────────────────────────────────────────
5
+ *
6
+ * Through 1.3.x the Builder card trusted exactly one baked key (`cert-issuer-1`), so a key rotation would have needed
7
+ * every brand to install a new package. 2.0 trusts a SET without adding a trust root:
8
+ *
9
+ * 1. A kid the package bakes resolves to the baked key, always (a published set never overrides a baked key).
10
+ * 2. Any other kid makes ONE read of the delivery origin's published key set, `GET <origin>/api/cert/v1/keys`
11
+ * (`credentials: 'omit'`, the card's deadline): a browser reads it at most once per page and origin, a server at
12
+ * most once per origin per hour (single-flight, a failure remembered for ten minutes).
13
+ * 3. The set is accepted only when it is signed by a BAKED kid, over the RFC 8785 bytes of exactly this object:
14
+ *
15
+ * { kind: 'certrev.keyset', v: 1, issuedAt, keys: [{ kid, crv: 'Ed25519', x }] } (x: base64url, 32 bytes)
16
+ *
17
+ * answered as `{ keyset, signature: { alg: 'ed25519', kid, sig, signedAt? } }`. Refused: another `kind` or `v`
18
+ * (an envelope, a tombstone or a release record can never verify as a key set: their signed bytes are other
19
+ * objects), a set whose `issuedAt` is older than the one already accepted from that origin (no rollback), a set
20
+ * whose keys do not hold its signer's kid with the same key (it may not drop or replace the key that vouches for
21
+ * it), a malformed key, a signature that does not verify.
22
+ * 4. Anything else (no route, a network error, a refused set) leaves the kid unknown: the kernel suppresses the
23
+ * envelope with `unknown_key`, so the card renders nothing and reports, exactly as for an unknown kid in 1.3.x.
24
+ *
25
+ * The route is the portal's (ADR 0001 section 3 addendum). Today every envelope is signed by the baked
26
+ * `cert-issuer-1`, so step 2 never runs and nothing is fetched.
27
+ */
28
+
29
+ import {
30
+ base64urlDecode,
31
+ canonicalBytes,
32
+ type Ed25519PublicKeyInput,
33
+ importEd25519PublicKey,
34
+ type ResolvePublicKeyByKid,
35
+ } from '../contract/kernel.js'
36
+ import { TtlCache } from './cache.js'
37
+ import { DEFAULT_FETCH_TIMEOUT_MS, fetchWithDeadline } from './fetch-with-deadline.js'
38
+
39
+ /** Where an origin publishes its key set. */
40
+ export const KEY_SET_PATH = '/api/cert/v1/keys'
41
+
42
+ /** The signed object, exactly as the portal canonicalizes and signs it. */
43
+ export interface PublishedKeySet {
44
+ readonly kind: 'certrev.keyset'
45
+ readonly v: 1
46
+ readonly issuedAt: string
47
+ readonly keys: ReadonlyArray<{ readonly kid: string; readonly crv: 'Ed25519'; readonly x: string }>
48
+ }
49
+
50
+ /** An accepted set: the keys it publishes, and when it was issued (for the no-rollback rule). */
51
+ export interface AcceptedKeySet {
52
+ readonly issuedAt: number
53
+ readonly keys: ReadonlyMap<string, Ed25519PublicKeyInput>
54
+ }
55
+
56
+ const ED25519 = { name: 'Ed25519' } as const
57
+
58
+ const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
59
+ const isString = (v: unknown): v is string => typeof v === 'string'
60
+
61
+ function rawKey(x: unknown): Uint8Array | null {
62
+ if (!isString(x) || !/^[A-Za-z0-9_-]+$/.test(x)) return null
63
+ try {
64
+ const bytes = base64urlDecode(x)
65
+ return bytes.length === 32 ? bytes : null
66
+ } catch {
67
+ return null
68
+ }
69
+ }
70
+
71
+ /** Ed25519 verify under a declared key or an imported one; any failure (a bad key, no WebCrypto Ed25519) is false. */
72
+ async function verifies(key: Ed25519PublicKeyInput | CryptoKey, sig: Uint8Array, bytes: Uint8Array): Promise<boolean> {
73
+ try {
74
+ const imported = 'format' in (key as object) ? await importEd25519PublicKey(key as Ed25519PublicKeyInput) : key
75
+ // Fresh ArrayBuffer-backed copies: WebCrypto's BufferSource excludes a SharedArrayBuffer view.
76
+ return await crypto.subtle.verify(ED25519, imported as CryptoKey, new Uint8Array(sig), new Uint8Array(bytes))
77
+ } catch {
78
+ return false
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Judge one key-set response body against the baked keys (and the set already accepted from the same origin, for
84
+ * the no-rollback rule). Returns the accepted set, or null. Never throws.
85
+ */
86
+ export async function verifyKeySet(
87
+ body: unknown,
88
+ bakedResolveKid: ResolvePublicKeyByKid,
89
+ previous?: AcceptedKeySet,
90
+ ): Promise<AcceptedKeySet | null> {
91
+ if (!isRecord(body) || !isRecord(body.keyset) || !isRecord(body.signature)) return null
92
+ const { keyset, signature } = body
93
+ if (keyset.kind !== 'certrev.keyset' || keyset.v !== 1) return null
94
+ if (!isString(keyset.issuedAt)) return null
95
+ const issuedAt = Date.parse(keyset.issuedAt)
96
+ if (!Number.isFinite(issuedAt)) return null
97
+ if (previous && issuedAt < previous.issuedAt) return null
98
+ if (!Array.isArray(keyset.keys) || keyset.keys.length === 0) return null
99
+ if (signature.alg !== 'ed25519' || !isString(signature.kid) || !isString(signature.sig)) return null
100
+
101
+ // The signer must be a kid this package bakes.
102
+ let baked: Ed25519PublicKeyInput | CryptoKey | null | undefined
103
+ try {
104
+ baked = await bakedResolveKid(signature.kid)
105
+ } catch {
106
+ return null
107
+ }
108
+ if (!baked) return null
109
+
110
+ const keys = new Map<string, Ed25519PublicKeyInput>()
111
+ for (const entry of keyset.keys) {
112
+ if (!isRecord(entry) || !isString(entry.kid) || entry.kid === '' || entry.crv !== 'Ed25519') return null
113
+ const bytes = rawKey(entry.x)
114
+ if (!bytes || keys.has(entry.kid)) return null
115
+ keys.set(entry.kid, { format: 'raw', bytes })
116
+ }
117
+ // The set must keep the key that signed it.
118
+ const signerEntry = keys.get(signature.kid)
119
+ if (!signerEntry) return null
120
+
121
+ let sig: Uint8Array
122
+ let bytes: Uint8Array
123
+ try {
124
+ sig = base64urlDecode(signature.sig)
125
+ bytes = canonicalBytes(keyset)
126
+ } catch {
127
+ return null
128
+ }
129
+ if (!(await verifies(baked, sig, bytes))) return null
130
+ // ... and publish it as the same key: the signature must verify under the published entry too.
131
+ if (!(await verifies(signerEntry, sig, bytes))) return null
132
+ return { issuedAt, keys }
133
+ }
134
+
135
+ export interface KeySetResolverOptions {
136
+ /** The delivery origin the card reads (the marker's `baseUrl`). */
137
+ readonly baseUrl: string
138
+ /** The baked kids (the package's own trust root). */
139
+ readonly bakedResolveKid: ResolvePublicKeyByKid
140
+ readonly fetchImpl: typeof fetch
141
+ readonly timeoutMs?: number
142
+ }
143
+
144
+ /** What one origin's published set settled to: accepted, or nothing (unknown kid). */
145
+ type Loaded = AcceptedKeySet | null
146
+
147
+ /** Server: one read per origin per hour, a failure remembered for ten minutes (single-flight across renders). */
148
+ const serverSets = new TtlCache<Loaded>({ ttlMs: 3_600_000, negativeTtlMs: 600_000, maxEntries: 50 })
149
+ /** Browser: one read per origin for the page's lifetime, whatever it settled to. */
150
+ const pageSets = new Map<string, Promise<Loaded>>()
151
+ /** The newest set accepted per origin (both runtimes), for the no-rollback rule. */
152
+ const acceptedSets = new Map<string, AcceptedKeySet>()
153
+
154
+ function originOf(baseUrl: string): string | null {
155
+ try {
156
+ return new URL(baseUrl).origin
157
+ } catch {
158
+ return null
159
+ }
160
+ }
161
+
162
+ async function loadKeySet(origin: string, opts: KeySetResolverOptions): Promise<Loaded> {
163
+ let res: Response
164
+ try {
165
+ res = await fetchWithDeadline(
166
+ opts.fetchImpl,
167
+ `${origin}${KEY_SET_PATH}`,
168
+ { headers: { accept: 'application/json' }, credentials: 'omit' },
169
+ opts.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS,
170
+ )
171
+ } catch {
172
+ return acceptedSets.get(origin) ?? null
173
+ }
174
+ if (!res.ok) return acceptedSets.get(origin) ?? null
175
+ let body: unknown
176
+ try {
177
+ body = await res.json()
178
+ } catch {
179
+ return acceptedSets.get(origin) ?? null
180
+ }
181
+ const previous = acceptedSets.get(origin)
182
+ const accepted = await verifyKeySet(body, opts.bakedResolveKid, previous)
183
+ // A refused set (older, unsigned, malformed) never replaces one this origin already proved.
184
+ if (!accepted) return previous ?? null
185
+ acceptedSets.set(origin, accepted)
186
+ return accepted
187
+ }
188
+
189
+ function publishedSet(origin: string, opts: KeySetResolverOptions): Promise<Loaded> {
190
+ if (typeof document !== 'undefined') {
191
+ let pending = pageSets.get(origin)
192
+ if (!pending) {
193
+ pending = loadKeySet(origin, opts)
194
+ pageSets.set(origin, pending)
195
+ }
196
+ return pending
197
+ }
198
+ return serverSets.getOrLoad(
199
+ origin,
200
+ () => loadKeySet(origin, opts),
201
+ (v) => v === null,
202
+ )
203
+ }
204
+
205
+ /**
206
+ * The resolver a card verifies with: the baked kids, then (for any other kid) the origin's published, signed set.
207
+ * Never throws; an unresolved kid is null (`unknown_key`).
208
+ */
209
+ export function keySetResolver(opts: KeySetResolverOptions): ResolvePublicKeyByKid {
210
+ return async (kid) => {
211
+ const baked = await opts.bakedResolveKid(kid)
212
+ if (baked) return baked
213
+ const origin = originOf(opts.baseUrl)
214
+ if (!origin) return null
215
+ const set = await publishedSet(origin, opts)
216
+ return set?.keys.get(kid) ?? null
217
+ }
218
+ }
219
+
220
+ /** Test seam: forget every published set (a new page and process). */
221
+ export function forgetKeySetsForTests(): void {
222
+ serverSets.clear()
223
+ pageSets.clear()
224
+ acceptedSets.clear()
225
+ }