@certrev/cert-block 1.2.0-canary.6 → 1.2.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 (42) hide show
  1. package/CHANGELOG.md +120 -35
  2. package/README.md +116 -21
  3. package/dist/builder/card-reader-css.d.ts +14 -0
  4. package/dist/builder/card-reader-css.d.ts.map +1 -0
  5. package/dist/builder/card-reader-css.js +17 -0
  6. package/dist/builder/card-reader-css.js.map +1 -0
  7. package/dist/builder/cert-review-card.d.ts +8 -0
  8. package/dist/builder/cert-review-card.d.ts.map +1 -1
  9. package/dist/builder/cert-review-card.js +234 -41
  10. package/dist/builder/cert-review-card.js.map +1 -1
  11. package/dist/builder/delivery-runtime.d.ts +15 -1
  12. package/dist/builder/delivery-runtime.d.ts.map +1 -1
  13. package/dist/builder/delivery-runtime.js +31 -3
  14. package/dist/builder/delivery-runtime.js.map +1 -1
  15. package/dist/builder/ssr-seed.d.ts +155 -0
  16. package/dist/builder/ssr-seed.d.ts.map +1 -0
  17. package/dist/builder/ssr-seed.js +278 -0
  18. package/dist/builder/ssr-seed.js.map +1 -0
  19. package/dist/reader/face.d.ts +6 -2
  20. package/dist/reader/face.d.ts.map +1 -1
  21. package/dist/reader/face.js +53 -5
  22. package/dist/reader/face.js.map +1 -1
  23. package/dist/reader/render.d.ts.map +1 -1
  24. package/dist/reader/render.js +10 -10
  25. package/dist/reader/render.js.map +1 -1
  26. package/dist/verify/cache.d.ts +39 -2
  27. package/dist/verify/cache.d.ts.map +1 -1
  28. package/dist/verify/cache.js +61 -12
  29. package/dist/verify/cache.js.map +1 -1
  30. package/dist/verify/get-verified-delivery.d.ts +28 -3
  31. package/dist/verify/get-verified-delivery.d.ts.map +1 -1
  32. package/dist/verify/get-verified-delivery.js +46 -9
  33. package/dist/verify/get-verified-delivery.js.map +1 -1
  34. package/package.json +112 -112
  35. package/src/builder/card-reader-css.ts +19 -0
  36. package/src/builder/cert-review-card.tsx +308 -56
  37. package/src/builder/delivery-runtime.ts +33 -3
  38. package/src/builder/ssr-seed.ts +343 -0
  39. package/src/reader/face.ts +59 -6
  40. package/src/reader/render.ts +12 -12
  41. package/src/verify/cache.ts +87 -11
  42. package/src/verify/get-verified-delivery.ts +77 -11
@@ -11,6 +11,13 @@
11
11
  * ONE fetch, not N.
12
12
  * 3. Negative-caches failures briefly so a hard-down origin doesn't get hammered, while
13
13
  * still recovering quickly (short negative TTL).
14
+ * 4. (1.2.0, opt-in through `staleMs`) STALE-WHILE-REVALIDATE and STALE-IF-ERROR: a positive
15
+ * entry past its TTL but inside the stale window is served at once while ONE background
16
+ * refresh replaces it; a refresh that only failed to reach the origin (`isTransient`)
17
+ * keeps the stale entry instead of overwriting it. So an origin that is slow or briefly
18
+ * down costs the page nothing it already had. A definitive answer (a revocation, say)
19
+ * replaces the entry the moment the refresh lands. With the default `staleMs` of 0 an
20
+ * expired entry is gone, exactly as before 1.2.0.
14
21
  *
15
22
  * It is deliberately tiny + dependency-free (a Map + timestamps) so it runs in any server
16
23
  * runtime (Node, edge, workerd). For multi-instance deployments this is per-instance L1 and
@@ -25,6 +32,18 @@ export interface TtlCacheOptions {
25
32
  readonly negativeTtlMs?: number
26
33
  /** Max entries before LRU-ish eviction of the oldest insert (default 1000). */
27
34
  readonly maxEntries?: number
35
+ /**
36
+ * 1.2.0: how long past its TTL a POSITIVE entry may still be served while a refresh runs (default 0: never). A
37
+ * negative entry is never served stale.
38
+ */
39
+ readonly staleMs?: number
40
+ /**
41
+ * 1.2.0: how long an unsettled load may be joined by later callers (default: no limit). Past it, the next caller
42
+ * starts a fresh load. WHY: a runtime may cancel the I/O of a request that has finished (Cloudflare workerd does),
43
+ * which can leave a background refresh's promise pending forever; without a limit every later caller would join
44
+ * it and none would ever refresh again.
45
+ */
46
+ readonly inflightMaxMs?: number
28
47
  /** Injectable clock for deterministic tests. */
29
48
  readonly now?: () => number
30
49
  }
@@ -35,28 +54,64 @@ interface Entry<V> {
35
54
  readonly negative: boolean
36
55
  }
37
56
 
57
+ interface Inflight<V> {
58
+ readonly promise: Promise<V>
59
+ readonly startedAt: number
60
+ }
61
+
38
62
  export class TtlCache<V> {
39
63
  private readonly store = new Map<string, Entry<V>>()
40
- private readonly inflight = new Map<string, Promise<V>>()
64
+ private readonly inflight = new Map<string, Inflight<V>>()
41
65
  private readonly ttlMs: number
42
66
  private readonly negativeTtlMs: number
43
67
  private readonly maxEntries: number
68
+ private readonly staleMs: number
69
+ private readonly inflightMaxMs: number
44
70
  private readonly now: () => number
45
71
 
46
72
  constructor(opts: TtlCacheOptions = {}) {
47
73
  this.ttlMs = opts.ttlMs ?? 60_000
48
74
  this.negativeTtlMs = opts.negativeTtlMs ?? 5_000
49
75
  this.maxEntries = opts.maxEntries ?? 1000
76
+ this.staleMs = opts.staleMs !== undefined && opts.staleMs > 0 ? opts.staleMs : 0
77
+ this.inflightMaxMs = opts.inflightMaxMs ?? Number.POSITIVE_INFINITY
50
78
  this.now = opts.now ?? Date.now
51
79
  }
52
80
 
53
- /** Read a live (non-expired) entry, or undefined. Prunes the entry if expired. */
54
- peek(key: string): V | undefined {
81
+ /** The entry while it may still be served (fresh, or positive and inside the stale window); prunes it past that. */
82
+ private servable(key: string): Entry<V> | undefined {
55
83
  const e = this.store.get(key)
56
84
  if (!e) return undefined
85
+ const now = this.now()
86
+ if (e.expiresAt > now) return e
87
+ if (!e.negative && now < e.expiresAt + this.staleMs) return e
88
+ this.store.delete(key)
89
+ return undefined
90
+ }
91
+
92
+ /** Read a live (non-expired) entry, or undefined. Never serves a stale one; prunes an entry past its stale window. */
93
+ peek(key: string): V | undefined {
94
+ const e = this.servable(key)
95
+ return e && e.expiresAt > this.now() ? e.value : undefined
96
+ }
97
+
98
+ /**
99
+ * The synchronous stale-while-revalidate read: a fresh value; or a positive entry past its TTL but inside the stale
100
+ * window, returned at once while this starts (or joins) ONE background refresh through `loader`; else undefined,
101
+ * and the caller loads (`getOrLoad`). The refresh caches as `getOrLoad` does, with the same arguments; its failure
102
+ * is swallowed (the stale value was already served, and a thrown loader is never cached).
103
+ */
104
+ peekOrRefresh(
105
+ key: string,
106
+ loader: () => Promise<V>,
107
+ isNegative?: (v: V) => boolean,
108
+ positiveTtlMs?: number,
109
+ isTransient?: (v: V) => boolean,
110
+ ): V | undefined {
111
+ const e = this.servable(key)
112
+ if (!e) return undefined
57
113
  if (e.expiresAt <= this.now()) {
58
- this.store.delete(key)
59
- return undefined
114
+ this.load(key, loader, isNegative, positiveTtlMs, isTransient).catch(() => undefined)
60
115
  }
61
116
  return e.value
62
117
  }
@@ -74,29 +129,50 @@ export class TtlCache<V> {
74
129
  * It deliberately does NOT apply to a negative result: a caller asking for a 10-minute
75
130
  * positive TTL is asking for fewer origin reads, not for a transient failure to be
76
131
  * remembered for ten minutes (see the negative-TTL rationale in the module header).
132
+ *
133
+ * 1.2.0: inside the stale window (`staleMs`) an expired positive entry is returned at once and refreshed in the
134
+ * background (`peekOrRefresh`). `isTransient(value)` marks a result that says only "the origin was not reached"
135
+ * (a timeout, a 5xx): when a servable positive entry exists, such a result is NOT cached over it and the caller
136
+ * gets that entry's value instead (stale-if-error). With no entry to keep, it is cached as `isNegative` says.
77
137
  */
78
138
  async getOrLoad(
79
139
  key: string,
80
140
  loader: () => Promise<V>,
81
141
  isNegative?: (v: V) => boolean,
82
142
  positiveTtlMs?: number,
143
+ isTransient?: (v: V) => boolean,
83
144
  ): Promise<V> {
84
- const cached = this.peek(key)
85
- if (cached !== undefined) return cached
145
+ const served = this.peekOrRefresh(key, loader, isNegative, positiveTtlMs, isTransient)
146
+ if (served !== undefined) return served
147
+ return this.load(key, loader, isNegative, positiveTtlMs, isTransient)
148
+ }
86
149
 
150
+ /** Single-flight load and cache (joins an unsettled load younger than `inflightMaxMs`). */
151
+ private load(
152
+ key: string,
153
+ loader: () => Promise<V>,
154
+ isNegative: ((v: V) => boolean) | undefined,
155
+ positiveTtlMs: number | undefined,
156
+ isTransient: ((v: V) => boolean) | undefined,
157
+ ): Promise<V> {
87
158
  const existing = this.inflight.get(key)
88
- if (existing) return existing
159
+ if (existing && this.now() - existing.startedAt < this.inflightMaxMs) return existing.promise
89
160
 
90
- const promise = (async () => {
161
+ const promise: Promise<V> = (async () => {
91
162
  const value = await loader()
163
+ if (isTransient?.(value)) {
164
+ const kept = this.servable(key)
165
+ if (kept && !kept.negative) return kept.value
166
+ }
92
167
  const negative = isNegative ? isNegative(value) : false
93
168
  this.set(key, value, negative, negative ? undefined : positiveTtlMs)
94
169
  return value
95
170
  })().finally(() => {
96
- this.inflight.delete(key)
171
+ // Only this load's own slot: a newer load that replaced an abandoned one keeps its place.
172
+ if (this.inflight.get(key)?.promise === promise) this.inflight.delete(key)
97
173
  })
98
174
 
99
- this.inflight.set(key, promise)
175
+ this.inflight.set(key, { promise, startedAt: this.now() })
100
176
  return promise
101
177
  }
102
178
 
@@ -29,12 +29,20 @@
29
29
  * `peekVerifiedDelivery` is the synchronous read of the same cache: a React component suspends on
30
30
  * `getVerifiedDelivery` once, then reads the settled value without a second promise (which is what
31
31
  * keeps a streaming SSR render from suspending forever on a fresh promise every retry).
32
+ *
33
+ * 1.2.0: a suppressed read that only failed to REACH CertREV (the fetch threw or hit its deadline, a 5xx or other
34
+ * non-OK status but 404/410, a body that is not JSON or not an object) is marked `unreachable`. It says nothing
35
+ * about the credential, so a cache with a stale window keeps a verified entry over it (stale-if-error), and the
36
+ * card paints its options face instead of nothing. A definitive answer (404/410, a key or signature that does not
37
+ * verify, a revoked, expired or mismatched artifact) is never `unreachable`. `readVerifiedDelivery` is the
38
+ * synchronous read that also serves a stale entry while refreshing it.
32
39
  */
33
40
 
34
41
  import type { CertChromeRenderDef } from '../builder/cert-chrome-data.js'
35
42
  import type {
36
43
  CertDeliveryArtifact,
37
44
  CertPayload,
45
+ CertSignature,
38
46
  CertSuppressReason,
39
47
  CertVerdict,
40
48
  RenderContext,
@@ -44,16 +52,34 @@ import { renderVerdict, verifyArtifact } from '../contract/kernel.js'
44
52
  import { TtlCache } from './cache.js'
45
53
  import { DEFAULT_FETCH_TIMEOUT_MS, fetchWithDeadline } from './fetch-with-deadline.js'
46
54
 
47
- /** What one Delivery read settles to, as cached: a verified payload + its def, or the reason not. */
55
+ /**
56
+ * What one Delivery read settles to, as cached: a verified payload + its def (and, from 1.2.0, the signature it
57
+ * verified with, so a server render can hand the signed envelope to the browser), or the reason not.
58
+ */
48
59
  export type CachedDelivery =
49
- | { readonly kind: 'verified'; readonly payload: CertPayload; readonly renderDef: CertChromeRenderDef | null }
50
- | { readonly kind: 'suppressed'; readonly reason: CertSuppressReason }
60
+ | {
61
+ readonly kind: 'verified'
62
+ readonly payload: CertPayload
63
+ readonly renderDef: CertChromeRenderDef | null
64
+ /** Absent only in a cache filled by hand (a test, a custom cache); a read always keeps it. */
65
+ readonly signature?: CertSignature
66
+ }
67
+ | {
68
+ readonly kind: 'suppressed'
69
+ readonly reason: CertSuppressReason
70
+ /** 1.2.0: true when CertREV was not reached (no answer about the credential); see the module header. */
71
+ readonly unreachable?: true
72
+ }
51
73
 
52
74
  /** The answer for one render: the verdict AT THIS RENDER'S CLOCK, and the def to paint it with. */
53
75
  export interface VerifiedDelivery {
54
76
  readonly verdict: CertVerdict
55
77
  /** The brand's render def, or null (no def resolved, a malformed one, or a suppress verdict). */
56
78
  readonly renderDef: CertChromeRenderDef | null
79
+ /** The verified envelope's signature, on a `render` verdict whose read kept it; else undefined. */
80
+ readonly signature?: CertSignature
81
+ /** 1.2.0: on a suppress verdict, true when CertREV was not reached; else undefined. */
82
+ readonly unreachable?: true
57
83
  }
58
84
 
59
85
  export interface GetVerifiedDeliveryOptions {
@@ -70,7 +96,7 @@ export interface GetVerifiedDeliveryOptions {
70
96
  readonly cache?: TtlCache<CachedDelivery>
71
97
  /** TTL in ms for a VERIFIED read (default: the cache's positive TTL, 60_000 on the shared one). */
72
98
  readonly ttlMs?: number
73
- /** Deadline in ms for the fetch (default `DEFAULT_FETCH_TIMEOUT_MS`, 3s). */
99
+ /** Deadline in ms for the fetch (default `DEFAULT_FETCH_TIMEOUT_MS`, 3s; the Builder card's runtime passes 8s). */
74
100
  readonly timeoutMs?: number
75
101
  /**
76
102
  * 1.2.0-canary.5: read one saved design version instead of the brand's live def. CertREV's own canary
@@ -96,6 +122,13 @@ function suppressed(reason: CertSuppressReason): CachedDelivery {
96
122
  return { kind: 'suppressed', reason }
97
123
  }
98
124
 
125
+ /** A read that did not reach CertREV: the same least-wrong reason as before 1.2.0, marked `unreachable`. */
126
+ function unreachable(): CachedDelivery {
127
+ return { kind: 'suppressed', reason: 'unsupported_contract_version', unreachable: true }
128
+ }
129
+
130
+ const isUnreachable = (v: CachedDelivery): boolean => v.kind === 'suppressed' && v.unreachable === true
131
+
99
132
  /** The Delivery route; a design pin adds `?design=<v>` and, with a brand, `&designBrand=<brand>`. */
100
133
  export function deliveryUrl(baseUrl: string, platform: string, externalId: string, design?: DesignPin): string {
101
134
  const base = baseUrl.replace(/\/+$/, '')
@@ -210,19 +243,19 @@ async function loadDelivery(opts: GetVerifiedDeliveryOptions): Promise<CachedDel
210
243
  )
211
244
  } catch {
212
245
  // Unreachable, DNS/TLS, or the deadline. Same least-wrong member as getVerifiedEnvelope.
213
- return suppressed('unsupported_contract_version')
246
+ return unreachable()
214
247
  }
215
248
  // Nothing served for this placement: never certified, or withdrawn. A LIVE revocation is a 200
216
249
  // tombstone, so a 4xx here genuinely means "no credential for this subject".
217
250
  if (res.status === 404 || res.status === 410) return suppressed('subject_mismatch')
218
- if (!res.ok) return suppressed('unsupported_contract_version')
251
+ if (!res.ok) return unreachable()
219
252
  let body: unknown
220
253
  try {
221
254
  body = await res.json()
222
255
  } catch {
223
- return suppressed('unsupported_contract_version')
256
+ return unreachable()
224
257
  }
225
- if (!isRecord(body)) return suppressed('unsupported_contract_version')
258
+ if (!isRecord(body)) return unreachable()
226
259
  let verdict: CertVerdict
227
260
  try {
228
261
  // `verifyArtifact`, so a tombstone verifies as itself and reports 'revoked'. The kernel reads
@@ -233,14 +266,28 @@ async function loadDelivery(opts: GetVerifiedDeliveryOptions): Promise<CachedDel
233
266
  return suppressed('unknown_key')
234
267
  }
235
268
  if (verdict.decision !== 'render') return suppressed(verdict.reason)
236
- return { kind: 'verified', payload: verdict.payload, renderDef: parseRenderDef(body.renderDef) }
269
+ // A `render` verdict came from an envelope (a tombstone never renders), so `signature` is the one just verified.
270
+ const signature = isRecord(body.signature) ? (body.signature as unknown as CertSignature) : undefined
271
+ return {
272
+ kind: 'verified',
273
+ payload: verdict.payload,
274
+ renderDef: parseRenderDef(body.renderDef),
275
+ ...(signature ? { signature } : {}),
276
+ }
237
277
  }
238
278
 
239
279
  /** Apply the render-time policy to a cached read: lifecycle + subject at THIS render's clock. */
240
280
  export function settleDelivery(cached: CachedDelivery, context: RenderContext): VerifiedDelivery {
241
- if (cached.kind === 'suppressed') return { verdict: { decision: 'suppress', reason: cached.reason }, renderDef: null }
281
+ if (cached.kind === 'suppressed') {
282
+ return {
283
+ verdict: { decision: 'suppress', reason: cached.reason },
284
+ renderDef: null,
285
+ ...(cached.unreachable ? { unreachable: true as const } : {}),
286
+ }
287
+ }
242
288
  const verdict = renderVerdict(cached.payload, context)
243
- return { verdict, renderDef: verdict.decision === 'render' ? cached.renderDef : null }
289
+ if (verdict.decision !== 'render') return { verdict, renderDef: null }
290
+ return { verdict, renderDef: cached.renderDef, ...(cached.signature ? { signature: cached.signature } : {}) }
244
291
  }
245
292
 
246
293
  /**
@@ -255,10 +302,29 @@ export async function getVerifiedDelivery(opts: GetVerifiedDeliveryOptions): Pro
255
302
  () => loadDelivery(opts),
256
303
  (v) => v.kind === 'suppressed',
257
304
  opts.ttlMs,
305
+ isUnreachable,
258
306
  )
259
307
  return settleDelivery(cached, opts.context)
260
308
  }
261
309
 
310
+ /**
311
+ * 1.2.0, the synchronous read a render uses: the settled answer for a fresh cached read; or, inside the cache's
312
+ * stale window, the stale read's answer at once while ONE background refresh (the same fetch `getVerifiedDelivery`
313
+ * makes) replaces it; else `undefined`, and the caller awaits `getVerifiedDelivery`. The verdict is still settled
314
+ * at `context.now`, so a stale payload that has expired or been revoked in its own fields still suppresses.
315
+ */
316
+ export function readVerifiedDelivery(opts: GetVerifiedDeliveryOptions): VerifiedDelivery | undefined {
317
+ const cache = opts.cache ?? sharedDeliveryCache
318
+ const cached = cache.peekOrRefresh(
319
+ deliveryCacheKey(opts),
320
+ () => loadDelivery(opts),
321
+ (v) => v.kind === 'suppressed',
322
+ opts.ttlMs,
323
+ isUnreachable,
324
+ )
325
+ return cached === undefined ? undefined : settleDelivery(cached, opts.context)
326
+ }
327
+
262
328
  /**
263
329
  * The synchronous read: the settled answer when a fresh read is cached, else `undefined` (the
264
330
  * caller then awaits `getVerifiedDelivery`). Never fetches.