@certrev/cert-block 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.
Files changed (61) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/README.md +50 -10
  3. package/dist/builder/card-beacon.d.ts +73 -0
  4. package/dist/builder/card-beacon.d.ts.map +1 -0
  5. package/dist/builder/card-beacon.js +178 -0
  6. package/dist/builder/card-beacon.js.map +1 -0
  7. package/dist/builder/card-contract.d.ts +10 -2
  8. package/dist/builder/card-contract.d.ts.map +1 -1
  9. package/dist/builder/card-contract.js +27 -3
  10. package/dist/builder/card-contract.js.map +1 -1
  11. package/dist/builder/card-placement.d.ts +45 -0
  12. package/dist/builder/card-placement.d.ts.map +1 -0
  13. package/dist/builder/card-placement.js +193 -0
  14. package/dist/builder/card-placement.js.map +1 -0
  15. package/dist/builder/card-report.d.ts +26 -14
  16. package/dist/builder/card-report.d.ts.map +1 -1
  17. package/dist/builder/card-report.js +68 -14
  18. package/dist/builder/card-report.js.map +1 -1
  19. package/dist/builder/cert-review-card.d.ts +6 -4
  20. package/dist/builder/cert-review-card.d.ts.map +1 -1
  21. package/dist/builder/cert-review-card.js +172 -199
  22. package/dist/builder/cert-review-card.js.map +1 -1
  23. package/dist/builder/contract-card.d.ts +18 -1
  24. package/dist/builder/contract-card.d.ts.map +1 -1
  25. package/dist/builder/contract-card.js +60 -10
  26. package/dist/builder/contract-card.js.map +1 -1
  27. package/dist/builder/delivery-runtime.d.ts +38 -4
  28. package/dist/builder/delivery-runtime.d.ts.map +1 -1
  29. package/dist/builder/delivery-runtime.js +43 -5
  30. package/dist/builder/delivery-runtime.js.map +1 -1
  31. package/dist/builder/index.d.ts +4 -4
  32. package/dist/builder/index.js +4 -4
  33. package/dist/builder/ssr-seed.d.ts +5 -4
  34. package/dist/builder/ssr-seed.d.ts.map +1 -1
  35. package/dist/builder/ssr-seed.js +4 -3
  36. package/dist/builder/ssr-seed.js.map +1 -1
  37. package/dist/builder/version.d.ts +3 -0
  38. package/dist/builder/version.d.ts.map +1 -0
  39. package/dist/builder/version.js +3 -0
  40. package/dist/builder/version.js.map +1 -0
  41. package/dist/verify/cache.d.ts +24 -0
  42. package/dist/verify/cache.d.ts.map +1 -1
  43. package/dist/verify/cache.js +78 -6
  44. package/dist/verify/cache.js.map +1 -1
  45. package/dist/verify/get-verified-delivery.d.ts +18 -0
  46. package/dist/verify/get-verified-delivery.d.ts.map +1 -1
  47. package/dist/verify/get-verified-delivery.js +30 -3
  48. package/dist/verify/get-verified-delivery.js.map +1 -1
  49. package/package.json +1 -1
  50. package/src/builder/card-beacon.ts +201 -0
  51. package/src/builder/card-contract.ts +25 -3
  52. package/src/builder/card-placement.ts +208 -0
  53. package/src/builder/card-report.ts +79 -14
  54. package/src/builder/cert-review-card.tsx +208 -238
  55. package/src/builder/contract-card.tsx +65 -8
  56. package/src/builder/delivery-runtime.ts +45 -5
  57. package/src/builder/index.tsx +4 -4
  58. package/src/builder/ssr-seed.ts +5 -4
  59. package/src/builder/version.ts +2 -0
  60. package/src/verify/cache.ts +78 -6
  61. package/src/verify/get-verified-delivery.ts +40 -3
@@ -21,7 +21,18 @@
21
21
  * injected. Only the block that prints the runtime's tag loads it (`cardCarriesAssets`). The server's HTML carries
22
22
  * the same tag in that block's wrapper, which the parser runs; the runtime's init is idempotent, so it starts once.
23
23
  * Both run in a layout effect, before the browser paints the inserted card, so a client-mounted card never shows an
24
- * empty frame.
24
+ * empty frame; the roots are checked after every commit of the block, whatever React wrote. React writes the card only
25
+ * when its HTML changes: the `dangerouslySetInnerHTML` object is kept per string, since React 19 rewrites `innerHTML`
26
+ * for every new object (2.0.0 on React 19 rebuilt the card on each re-render, and the hosted runtime re-attached it).
27
+ *
28
+ * NO CONSOLE NOISE (2.0.1). In a browser that parses declarative roots, the HTML React itself writes names each
29
+ * template's mode `data-certrev-shadowrootmode` instead of `shadowrootmode` (`clientCardHtml`): Chrome warns ("Found
30
+ * declarative shadowrootmode attribute on a template, but declarative Shadow DOM is not being parsed") for every such
31
+ * template `innerHTML` meets, and the layout effect re-parses the card's exact bytes anyway. The wrapper carries
32
+ * `suppressHydrationWarning` (React never prints it): the server's wrapper holds attached roots where React expects
33
+ * templates, which React's development build reported as a hydration mismatch on every page. React does not patch a
34
+ * hydrated element's HTML, so the server's card stays as the parser built it. A server, and a DOM without declarative
35
+ * roots (jsdom, an old browser), print the card's bytes unchanged.
25
36
  *
26
37
  * ONE BUILDER RULE, for the sidebar card only. The card's rail is a right float that the article's copy flows around
27
38
  * (the Appearance preview's layout, and every other CMS's). On a Builder page the block sits in Builder's own wrapper,
@@ -34,7 +45,9 @@
34
45
  */
35
46
 
36
47
  import * as React from 'react'
48
+ import { maybeSendBrowserHealth } from './card-beacon.js'
37
49
  import { type CardDocumentV1, type CardPartName, cardCarriesAssets, cardHtml } from './card-contract.js'
50
+ import { afterPageSettles, applyCardPlacement } from './card-placement.js'
38
51
  import { SEED_ATTRIBUTE } from './ssr-seed.js'
39
52
 
40
53
  /** The global the hosted runtime sets once it has started (`Symbol.for('certrev.card-runtime')` on `document`). */
@@ -45,18 +58,36 @@ const INJECTED_RUNTIME_ATTRIBUTE = 'data-certrev-card-runtime'
45
58
  /** A layout effect in a browser; nothing on a server (where React would only warn about it). */
46
59
  const useBrowserLayoutEffect = typeof document === 'undefined' ? () => {} : React.useLayoutEffect
47
60
 
61
+ /** What a client render prints in place of a template's `shadowrootmode` (the header comment). */
62
+ export const INERT_MODE_ATTRIBUTE = 'data-certrev-shadowrootmode'
63
+
64
+ /** Whether this DOM parses declarative shadow roots (a browser does; a server and jsdom do not). */
65
+ function parsesDeclarativeRoots(): boolean {
66
+ return typeof HTMLTemplateElement === 'function' && 'shadowRootMode' in HTMLTemplateElement.prototype
67
+ }
68
+
69
+ /** The card's HTML as React writes it in such a browser: each template's mode under `INERT_MODE_ATTRIBUTE`. */
70
+ export function clientCardHtml(html: string): string {
71
+ return html.replace(/<template\b([^>]*?)\sshadowrootmode=/gi, `<template$1 ${INERT_MODE_ATTRIBUTE}=`)
72
+ }
73
+
74
+ /** The template's declared mode, under either name. */
75
+ function declaredMode(template: Element): string | null {
76
+ return template.getAttribute('shadowrootmode') ?? template.getAttribute(INERT_MODE_ATTRIBUTE)
77
+ }
78
+
48
79
  /** Whether this host still holds an unattached declarative root. */
49
80
  function pendingTemplate(host: Element): HTMLTemplateElement | null {
50
81
  if ((host as HTMLElement).shadowRoot) return null
51
82
  for (const child of Array.from(host.children)) {
52
- if (child instanceof HTMLTemplateElement && child.hasAttribute('shadowrootmode')) return child
83
+ if (child instanceof HTMLTemplateElement && declaredMode(child) !== null) return child
53
84
  }
54
85
  return null
55
86
  }
56
87
 
57
88
  /** Attach one host's root from its template, as the parser would have: its mode, its options, its content. */
58
89
  function attachFromTemplate(host: HTMLElement, template: HTMLTemplateElement): void {
59
- const mode = template.getAttribute('shadowrootmode') === 'closed' ? 'closed' : 'open'
90
+ const mode = declaredMode(template) === 'closed' ? 'closed' : 'open'
60
91
  const init: ShadowRootInit & { clonable?: boolean; serializable?: boolean } = {
61
92
  mode,
62
93
  delegatesFocus: template.hasAttribute('shadowrootdelegatesfocus'),
@@ -119,22 +150,47 @@ export const BUILDER_CARD_FLOW_CSS =
119
150
  '.builder-blocks > .builder-block > [data-certrev-face="card"]:has(> certrev-card[data-certrev-part="rail"]){display:block !important}' +
120
151
  '}'
121
152
 
153
+ /** One object for the flow rule's element, so React 19 never rewrites it on a re-render. */
154
+ const BUILDER_CARD_FLOW_HTML = { __html: BUILDER_CARD_FLOW_CSS }
155
+
122
156
  export function ContractCardFace(props: {
123
157
  readonly card: CardDocumentV1
124
158
  readonly names: readonly CardPartName[]
125
159
  /** The record that names this card (`ssr-seed.ts`): the browser repaints it from here with no read. */
126
160
  readonly seed: string
161
+ /** 2.0.1: the delivery origin the block read, where its sampled health beacon goes (`card-beacon.ts`). */
162
+ readonly origin?: string
127
163
  }) {
128
164
  const ref = React.useRef<HTMLDivElement>(null)
129
165
  const html = cardHtml(props.card, props.names)
166
+ const printed = typeof document !== 'undefined' && parsesDeclarativeRoots() ? clientCardHtml(html) : html
167
+ // One object per printed string: React 19 rewrites `innerHTML` whenever this prop is a new object (React 18 compared
168
+ // the strings), which would replace the attached card on every re-render.
169
+ const inner = React.useMemo(() => ({ __html: printed }), [printed])
130
170
  const { card } = props
131
171
  const carriesAssets = cardCarriesAssets(card, props.names)
172
+ // After every commit of this block (a no-op while every root is attached): whatever React wrote, the card has its roots
173
+ // before the browser paints.
174
+ useBrowserLayoutEffect(() => {
175
+ if (ref.current) attachCardRoots(ref.current, html)
176
+ })
132
177
  useBrowserLayoutEffect(() => {
178
+ if (ref.current && carriesAssets) injectCardRuntime(card)
179
+ }, [card, carriesAssets])
180
+ const { origin } = props
181
+ React.useEffect(() => {
182
+ maybeSendBrowserHealth(origin, card.subject.externalId)
183
+ }, [origin, card])
184
+ // 2.0.1: the document's placement hints, once the page has settled (`card-placement.ts`). Keyed on the printed HTML,
185
+ // which the document and the parts decide, so a re-render that prints the same card moves nothing again.
186
+ const placing = React.useRef({ card, names: props.names })
187
+ placing.current = { card, names: props.names }
188
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `html` is the trigger on purpose; the document and parts are read from the ref
189
+ React.useEffect(() => {
133
190
  const wrapper = ref.current
134
191
  if (!wrapper) return
135
- attachCardRoots(wrapper, html)
136
- if (carriesAssets) injectCardRuntime(card)
137
- }, [html, card, carriesAssets])
192
+ return afterPageSettles(() => applyCardPlacement(wrapper, placing.current.card, placing.current.names))
193
+ }, [html])
138
194
  const wrapper = (
139
195
  <div
140
196
  ref={ref}
@@ -144,8 +200,9 @@ export function ContractCardFace(props: {
144
200
  data-certrev-contract={String(props.card.contract)}
145
201
  data-certrev-def-version={props.card.defVersion}
146
202
  {...{ [SEED_ATTRIBUTE]: props.seed }}
203
+ suppressHydrationWarning
147
204
  // biome-ignore lint/security/noDangerouslySetInnerHtml: CertREV's card document, printed only after it hashes to the digest the verified envelope signs
148
- dangerouslySetInnerHTML={{ __html: html }}
205
+ dangerouslySetInnerHTML={inner}
149
206
  />
150
207
  )
151
208
  if (!props.names.includes('rail')) return wrapper
@@ -153,7 +210,7 @@ export function ContractCardFace(props: {
153
210
  <>
154
211
  {wrapper}
155
212
  {/* biome-ignore lint/security/noDangerouslySetInnerHtml: a package constant; React would escape the `>` and `"` of its selectors */}
156
- <style data-certrev-builder-flow="" dangerouslySetInnerHTML={{ __html: BUILDER_CARD_FLOW_CSS }} />
213
+ <style data-certrev-builder-flow="" dangerouslySetInnerHTML={BUILDER_CARD_FLOW_HTML} />
157
214
  </>
158
215
  )
159
216
  }
@@ -14,6 +14,10 @@
14
14
  * A revocation still lands on the first render after its refresh does. The tests' runtime gets
15
15
  * the same cache shape, so they exercise what production runs.
16
16
  *
17
+ * 2.0.2: a server render waits at most `CARD_SERVER_WAIT_MS` (300 ms) for a cold read, never joins another request's read
18
+ * in flight, and a failure is remembered `CARD_DELIVERY_FAILURE_MS` (60 s) per placement, so a hanging CertREV costs a
19
+ * brand's page at most one short wait per placement per minute (2.0.1: 8 s, every 5 s).
20
+ *
17
21
  * 2.0.0: the trust root is a key SET (`verify/key-set.ts`). `resolveKid` is still the baked set (today
18
22
  * `cert-issuer-1`), and the card verifies with `resolveKidFor(baseUrl)`: a baked kid resolves as before, any other kid
19
23
  * makes one read of the delivery origin's published key set, accepted only when a baked kid signs it. A rotation then
@@ -24,16 +28,44 @@ import type { ResolvePublicKeyByKid } from '../contract/kernel.js'
24
28
  import { TtlCache } from '../verify/cache.js'
25
29
  import type { CachedDelivery } from '../verify/get-verified-delivery.js'
26
30
  import { forgetKeySetsForTests, keySetResolver } from '../verify/key-set.js'
31
+ import { setCardBeaconForTests } from './card-beacon.js'
27
32
  import { forgetCardDigestsForTests } from './card-contract.js'
28
33
  import { forgetCardReportsForTests } from './card-report.js'
29
34
  import { forgetFaceHashesForTests } from './delivered-face.js'
30
35
  import { CERT_ISSUER_KID, CERT_ISSUER_PUBLIC_KEY_PEM } from './issuer-key.js'
31
36
  import { forgetPageSeedsForTests } from './ssr-seed.js'
32
37
 
33
- /** The card's server read deadline. A brand's server waits this long once per cold placement. */
38
+ /**
39
+ * The card's fetch deadline: how long a read of the Delivery API may run (2.0.2: in the background, past the render; a
40
+ * browser read under `clientRead`). Not how long a server render waits: that is `CARD_SERVER_WAIT_MS`.
41
+ */
34
42
  export const CARD_DELIVERY_TIMEOUT_MS = 8_000
35
- /** How long past its 60s TTL a certificate may still paint while it is refreshed or CertREV is down. */
36
- export const CARD_DELIVERY_STALE_MS = 300_000
43
+ /**
44
+ * 2.0.2: the longest a SERVER render waits for a placement's read (2.0.1 waited the whole `CARD_DELIVERY_TIMEOUT_MS`,
45
+ * 8 s). Past it the render prints nothing (`delivery_unreachable`), the placement's failure is remembered
46
+ * (`CARD_DELIVERY_FAILURE_MS`), and the read runs on: when it lands it is cached for the next render. 300 ms covers the
47
+ * Delivery API's p90 function time (221 ms over 30 days of production, n = 21,368 CDN misses; 93% <= 300 ms) and a
48
+ * cached edge answer (~50 ms) plus a round trip; see the CHANGELOG.
49
+ */
50
+ export const CARD_SERVER_WAIT_MS = 300
51
+ /**
52
+ * 2.0.2: how long a placement's failure (a timeout, a network error, a 5xx, a render that stopped waiting) is
53
+ * remembered per process. Inside it, no read of the placement starts: a render with no cached card prints nothing at
54
+ * once, and a stale card paints without a refresh. So an ongoing outage costs at most one short wait per placement per
55
+ * minute. (2.0.1: a failure was remembered 5 s.)
56
+ */
57
+ export const CARD_DELIVERY_FAILURE_MS = 60_000
58
+ /**
59
+ * 2.0.1: how long a verified answer is fresh. With the stale window below, a card adds at most one minute to the Delivery
60
+ * API's own cache (`max-age=60, stale-while-revalidate=240`), so a revoked card is gone within ADR 0001 §7's five to
61
+ * six minutes (2.0.0's 60 s + 300 s here stacked to about twelve).
62
+ */
63
+ export const CARD_DELIVERY_TTL_MS = 30_000
64
+ /**
65
+ * How long past its TTL a certificate may still paint while it is refreshed or CertREV is down (2.0.1: 30 s; 2.0.0:
66
+ * 300 s). Never longer: past it, a read that cannot reach CertREV prints nothing.
67
+ */
68
+ export const CARD_DELIVERY_STALE_MS = 30_000
37
69
 
38
70
  export interface CardDeliveryRuntime {
39
71
  /** The baked kids: the package's own trust root (and the only keys that may sign a published key set). */
@@ -47,6 +79,8 @@ export interface CardDeliveryRuntime {
47
79
  readonly cache: TtlCache<CachedDelivery>
48
80
  readonly now: () => number
49
81
  readonly timeoutMs?: number
82
+ /** 2.0.2: the longest a server render waits for a read (`CARD_SERVER_WAIT_MS`). */
83
+ readonly serverWaitMs?: number
50
84
  }
51
85
 
52
86
  const bakedResolveKid: ResolvePublicKeyByKid = (kid) =>
@@ -54,9 +88,11 @@ const bakedResolveKid: ResolvePublicKeyByKid = (kid) =>
54
88
 
55
89
  function newCardCache(now?: () => number): TtlCache<CachedDelivery> {
56
90
  return new TtlCache<CachedDelivery>({
57
- ttlMs: 60_000,
91
+ ttlMs: CARD_DELIVERY_TTL_MS,
58
92
  negativeTtlMs: 5_000,
59
93
  staleMs: CARD_DELIVERY_STALE_MS,
94
+ // 2.0.2: on a server only. A browser read (`clientRead`) holds no page, and keeps 2.0.1's 5 s (break-it B1).
95
+ ...(typeof document === 'undefined' ? { failureTtlMs: CARD_DELIVERY_FAILURE_MS } : {}),
60
96
  // A refresh the host runtime abandoned (workerd cancels a finished request's I/O) never
61
97
  // settles; after the deadline plus a margin, the next render starts a new one.
62
98
  inflightMaxMs: CARD_DELIVERY_TIMEOUT_MS + 2_000,
@@ -71,6 +107,7 @@ function defaultRuntime(): CardDeliveryRuntime {
71
107
  cache: newCardCache(),
72
108
  now: Date.now,
73
109
  timeoutMs: CARD_DELIVERY_TIMEOUT_MS,
110
+ serverWaitMs: CARD_SERVER_WAIT_MS,
74
111
  }
75
112
  }
76
113
 
@@ -94,7 +131,8 @@ export function cardResolveKidFor(rt: CardDeliveryRuntime, baseUrl: string): Res
94
131
  /**
95
132
  * Test seam: replace parts of the runtime (a fresh cache unless one is given) and forget the page's envelope seeds
96
133
  * (a fresh runtime is a fresh page), (1.3.0) every delivered face's memoized hashes and (2.0.0) every card
97
- * document's digest and every published key set. Returns a restore.
134
+ * document's digest and every published key set. 2.0.1: beacons go nowhere (a test that wants them sets its own
135
+ * transport with `setCardBeaconForTests` after this). Returns a restore.
98
136
  */
99
137
  export function setCardDeliveryRuntimeForTests(overrides: Partial<CardDeliveryRuntime>): () => void {
100
138
  forgetPageSeedsForTests()
@@ -102,6 +140,7 @@ export function setCardDeliveryRuntimeForTests(overrides: Partial<CardDeliveryRu
102
140
  forgetCardDigestsForTests()
103
141
  forgetKeySetsForTests()
104
142
  forgetCardReportsForTests()
143
+ const restoreBeacon = setCardBeaconForTests()
105
144
  const previous = runtime
106
145
  const now = overrides.now ?? previous.now
107
146
  runtime = {
@@ -111,5 +150,6 @@ export function setCardDeliveryRuntimeForTests(overrides: Partial<CardDeliveryRu
111
150
  }
112
151
  return () => {
113
152
  runtime = previous
153
+ restoreBeacon()
114
154
  }
115
155
  }
@@ -31,10 +31,10 @@
31
31
  * (`face: { html, css }`, unsigned) and the verified envelope signs that face's sha256 digests for the
32
32
  * def version served (`content.faceDigest`), an unpinned whole-card block paints that face instead;
33
33
  * markup that does not hash to the signed digest is never painted. A revoked, expired, unverifiable or unreachable cert, a marker that
34
- * is not exactly that shape, or a missing entry id renders nothing. A block WITHOUT the marker
35
- * (every entry exported before 1.1.0) renders from its options exactly as 1.0.3 did, so the
36
- * paragraph above still describes it: an editor who deletes the marker gets the options face back,
37
- * which is the 1.0.3 trust boundary, not a new one.
34
+ * is not exactly that shape, or a missing entry id renders nothing. 2.0.1: a block WITHOUT the marker
35
+ * (every entry exported before 1.1.0) renders nothing too, and reports `no_marker`: through 2.0.0 it
36
+ * painted its options as 1.0.3 did, unsigned export-time claims an editor could change. So deleting the
37
+ * marker no longer brings any face back.
38
38
  *
39
39
  * Three things the card DOES enforce, and they are the limit of it: no chrome at all without a
40
40
  * `reviewerName` and a `verifyUrl`; a credential renders only welded to its dated verification
@@ -41,9 +41,10 @@
41
41
  * page carried for another version.
42
42
  *
43
43
  * WHEN THE SERVER HAD NO ENVELOPE TO PAINT (1.2.0) the same attribute carries a fallback record instead of a seed:
44
- * `{ v: 1, baseUrl, externalId, design?, face }`, where `face` is `'options'` (CertREV could not be reached, so the
45
- * server painted the block's own options face) or `'none'` (the server painted nothing: revoked, expired, unverifiable,
46
- * for another entry). A browser that renders the card again (hydration, or React's client re-render after a hydration
44
+ * `{ v: 1, baseUrl, externalId, design?, face, reason? }`, where `face` is `'none'` (the server painted nothing:
45
+ * revoked, expired, unverifiable, for another entry, or, 2.0.1, CertREV not reached, with `reason` naming why) or, on a
46
+ * page a 2.0.0 server rendered, `'options'` (CertREV could not be reached and that server painted the block's options
47
+ * face; 2.0.1 prints nothing for it). A browser that renders the card again (hydration, or React's client re-render after a hydration
47
48
  * error elsewhere on the page) paints what the record says, so it neither reads the Delivery API nor shows a card the
48
49
  * server withheld. A record holds no payload, so `parseSeed` never takes one for a seed. A `'none'` record sits on a
49
50
  * `hidden` element with nothing in it.
@@ -114,7 +115,7 @@ export interface PageSeed {
114
115
  readonly raw: string
115
116
  }
116
117
 
117
- /** What the server painted with no verified envelope: the block's options face, or nothing. */
118
+ /** What the server painted with no verified envelope: nothing (2.0.1 never writes `'options'`; a 2.0.0 page may hold one). */
118
119
  export type FallbackFace = 'options' | 'none'
119
120
 
120
121
  /** The record a server writes where it had no envelope to paint (the file header). */
@@ -0,0 +1,2 @@
1
+ /** This package's version, as its beacon reports it (held to `package.json` by `version.test.ts`). */
2
+ export const CERT_BLOCK_VERSION = '2.0.2'
@@ -18,6 +18,12 @@
18
18
  * down costs the page nothing it already had. A definitive answer (a revocation, say)
19
19
  * replaces the entry the moment the refresh lands. With the default `staleMs` of 0 an
20
20
  * expired entry is gone, exactly as before 1.2.0.
21
+ * 5. (2.0.2, opt-in through `failureTtlMs`) FAILURE MEMORY: a transient result (`isTransient`: the origin was not
22
+ * reached) is remembered in a slot of its own for `failureTtlMs`, beside a positive entry and never over it. While
23
+ * it is remembered nothing loads for the key: a read with no servable entry gets the failure at once, and a stale
24
+ * entry is served without starting a refresh. A definitive answer clears it. So an ongoing outage costs a caller at
25
+ * most one load per key per failure window. `markFailed` lets a caller that stopped waiting (a render deadline)
26
+ * remember one before the load itself settles. Without `failureTtlMs` the cache behaves exactly as 2.0.1's.
21
27
  *
22
28
  * It is deliberately tiny + dependency-free (a Map + timestamps) so it runs in any server
23
29
  * runtime (Node, edge, workerd). For multi-instance deployments this is per-instance L1 and
@@ -44,6 +50,11 @@ export interface TtlCacheOptions {
44
50
  * it and none would ever refresh again.
45
51
  */
46
52
  readonly inflightMaxMs?: number
53
+ /**
54
+ * 2.0.2: how long a transient result (`isTransient`) is remembered, in a slot of its own (default: none, and a transient
55
+ * result is cached as a negative entry as before). See the module header, point 5.
56
+ */
57
+ readonly failureTtlMs?: number
47
58
  /** Injectable clock for deterministic tests. */
48
59
  readonly now?: () => number
49
60
  }
@@ -57,16 +68,19 @@ interface Entry<V> {
57
68
  interface Inflight<V> {
58
69
  readonly promise: Promise<V>
59
70
  readonly startedAt: number
71
+ readonly token: object
60
72
  }
61
73
 
62
74
  export class TtlCache<V> {
63
75
  private readonly store = new Map<string, Entry<V>>()
64
76
  private readonly inflight = new Map<string, Inflight<V>>()
77
+ private readonly failures = new Map<string, { readonly value: V; readonly until: number }>()
65
78
  private readonly ttlMs: number
66
79
  private readonly negativeTtlMs: number
67
80
  private readonly maxEntries: number
68
81
  private readonly staleMs: number
69
82
  private readonly inflightMaxMs: number
83
+ private readonly failureTtlMs: number
70
84
  private readonly now: () => number
71
85
 
72
86
  constructor(opts: TtlCacheOptions = {}) {
@@ -75,6 +89,7 @@ export class TtlCache<V> {
75
89
  this.maxEntries = opts.maxEntries ?? 1000
76
90
  this.staleMs = opts.staleMs !== undefined && opts.staleMs > 0 ? opts.staleMs : 0
77
91
  this.inflightMaxMs = opts.inflightMaxMs ?? Number.POSITIVE_INFINITY
92
+ this.failureTtlMs = opts.failureTtlMs !== undefined && opts.failureTtlMs > 0 ? opts.failureTtlMs : 0
78
93
  this.now = opts.now ?? Date.now
79
94
  }
80
95
 
@@ -89,10 +104,20 @@ export class TtlCache<V> {
89
104
  return undefined
90
105
  }
91
106
 
107
+ /** 2.0.2: the remembered failure while it lasts (only with `failureTtlMs`); prunes it past that. */
108
+ private failure(key: string): { readonly value: V } | undefined {
109
+ const f = this.failures.get(key)
110
+ if (!f) return undefined
111
+ if (f.until > this.now()) return f
112
+ this.failures.delete(key)
113
+ return undefined
114
+ }
115
+
92
116
  /** Read a live (non-expired) entry, or undefined. Never serves a stale one; prunes an entry past its stale window. */
93
117
  peek(key: string): V | undefined {
94
118
  const e = this.servable(key)
95
- return e && e.expiresAt > this.now() ? e.value : undefined
119
+ if (e && e.expiresAt > this.now()) return e.value
120
+ return this.failure(key)?.value
96
121
  }
97
122
 
98
123
  /**
@@ -109,8 +134,10 @@ export class TtlCache<V> {
109
134
  isTransient?: (v: V) => boolean,
110
135
  ): V | undefined {
111
136
  const e = this.servable(key)
112
- if (!e) return undefined
113
- if (e.expiresAt <= this.now()) {
137
+ const failed = this.failure(key)
138
+ if (!e) return failed?.value
139
+ // 2.0.2: no refresh while a failure is remembered; the stale value is still served.
140
+ if (e.expiresAt <= this.now() && !failed) {
114
141
  this.load(key, loader, isNegative, positiveTtlMs, isTransient).catch(() => undefined)
115
142
  }
116
143
  return e.value
@@ -158,9 +185,20 @@ export class TtlCache<V> {
158
185
  const existing = this.inflight.get(key)
159
186
  if (existing && this.now() - existing.startedAt < this.inflightMaxMs) return existing.promise
160
187
 
188
+ const token = {}
161
189
  const promise: Promise<V> = (async () => {
162
190
  const value = await loader()
163
- if (isTransient?.(value)) {
191
+ // 2.0.2: a load that was replaced (abandoned past `inflightMaxMs`) never writes: its answer may be older than
192
+ // the replacement's, and a late verified read must never overwrite a newer revocation.
193
+ if (this.inflight.get(key)?.token !== token) return value
194
+ if (this.failureTtlMs > 0) {
195
+ if (isTransient?.(value)) {
196
+ this.markFailed(key, value)
197
+ const kept = this.servable(key)
198
+ return kept && !kept.negative ? kept.value : value
199
+ }
200
+ this.failures.delete(key)
201
+ } else if (isTransient?.(value)) {
164
202
  const kept = this.servable(key)
165
203
  if (kept && !kept.negative) return kept.value
166
204
  }
@@ -169,10 +207,10 @@ export class TtlCache<V> {
169
207
  return value
170
208
  })().finally(() => {
171
209
  // 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)
210
+ if (this.inflight.get(key)?.token === token) this.inflight.delete(key)
173
211
  })
174
212
 
175
- this.inflight.set(key, { promise, startedAt: this.now() })
213
+ this.inflight.set(key, { promise, startedAt: this.now(), token })
176
214
  return promise
177
215
  }
178
216
 
@@ -189,9 +227,39 @@ export class TtlCache<V> {
189
227
  this.store.set(key, { value, expiresAt: this.now() + ttl, negative })
190
228
  }
191
229
 
230
+ /** 2.0.2: whether a load for `key` is running (and still joinable, younger than `inflightMaxMs`). */
231
+ loading(key: string): boolean {
232
+ const existing = this.inflight.get(key)
233
+ return existing !== undefined && this.now() - existing.startedAt < this.inflightMaxMs
234
+ }
235
+
236
+ /**
237
+ * 2.0.2: remember a transient failure for `key` for `failureTtlMs` from now (a caller that stopped waiting for a load, or
238
+ * a load that settled on one), beside the key's entry: it never touches a positive entry. Without `failureTtlMs` it is
239
+ * a negative entry for the negative TTL, as a transient result was before 2.0.2, and like any `set` it replaces the
240
+ * key's entry.
241
+ */
242
+ markFailed(key: string, value: V): void {
243
+ if (this.failureTtlMs <= 0) {
244
+ this.set(key, value, true)
245
+ return
246
+ }
247
+ // Bounded like the store (break-it B3): past `maxEntries`, drop the expired failures, then the oldest.
248
+ if (this.failures.size >= this.maxEntries && !this.failures.has(key)) {
249
+ const now = this.now()
250
+ for (const [k, f] of this.failures) if (f.until <= now) this.failures.delete(k)
251
+ if (this.failures.size >= this.maxEntries) {
252
+ const oldest = this.failures.keys().next().value
253
+ if (oldest !== undefined) this.failures.delete(oldest)
254
+ }
255
+ }
256
+ this.failures.set(key, { value, until: this.now() + this.failureTtlMs })
257
+ }
258
+
192
259
  /** Drop a key (e.g. on a known revocation push). */
193
260
  delete(key: string): void {
194
261
  this.store.delete(key)
262
+ this.failures.delete(key)
195
263
  }
196
264
 
197
265
  /**
@@ -205,11 +273,15 @@ export class TtlCache<V> {
205
273
  for (const key of this.store.keys()) {
206
274
  if (key.startsWith(prefix)) this.store.delete(key)
207
275
  }
276
+ for (const key of this.failures.keys()) {
277
+ if (key.startsWith(prefix)) this.failures.delete(key)
278
+ }
208
279
  }
209
280
 
210
281
  clear(): void {
211
282
  this.store.clear()
212
283
  this.inflight.clear()
284
+ this.failures.clear()
213
285
  }
214
286
 
215
287
  get size(): number {
@@ -106,6 +106,8 @@ export type CachedDelivery =
106
106
  readonly reason: CertSuppressReason
107
107
  /** 1.2.0: true when CertREV was not reached (no answer about the credential); see the module header. */
108
108
  readonly unreachable?: true
109
+ /** 2.0.1: the HTTP status, when CertREV answered with a non-OK status other than 404/410. */
110
+ readonly status?: number
109
111
  }
110
112
 
111
113
  /** The answer for one render: the verdict AT THIS RENDER'S CLOCK, and the def to paint it with. */
@@ -117,6 +119,8 @@ export interface VerifiedDelivery {
117
119
  readonly signature?: CertSignature
118
120
  /** 1.2.0: on a suppress verdict, true when CertREV was not reached; else undefined. */
119
121
  readonly unreachable?: true
122
+ /** 2.0.1: with `unreachable`, the HTTP status when CertREV answered with one (a 5xx, a 429, ...); else undefined. */
123
+ readonly status?: number
120
124
  /**
121
125
  * 1.3.0: on a `render` verdict, the server-rendered face served beside the artifact, when the response carried a
122
126
  * well-formed one; else undefined (never on a suppress verdict). UNVERIFIED: see `DeliveredFace`.
@@ -175,8 +179,13 @@ function suppressed(reason: CertSuppressReason): CachedDelivery {
175
179
  }
176
180
 
177
181
  /** A read that did not reach CertREV: the same least-wrong reason as before 1.2.0, marked `unreachable`. */
178
- function unreachable(): CachedDelivery {
179
- return { kind: 'suppressed', reason: 'unsupported_contract_version', unreachable: true }
182
+ function unreachable(status?: number): CachedDelivery {
183
+ return {
184
+ kind: 'suppressed',
185
+ reason: 'unsupported_contract_version',
186
+ unreachable: true,
187
+ ...(status === undefined ? {} : { status }),
188
+ }
180
189
  }
181
190
 
182
191
  const isUnreachable = (v: CachedDelivery): boolean => v.kind === 'suppressed' && v.unreachable === true
@@ -312,7 +321,7 @@ async function loadDelivery(opts: GetVerifiedDeliveryOptions): Promise<CachedDel
312
321
  // Nothing served for this placement: never certified, or withdrawn. A LIVE revocation is a 200
313
322
  // tombstone, so a 4xx here genuinely means "no credential for this subject".
314
323
  if (res.status === 404 || res.status === 410) return suppressed('subject_mismatch')
315
- if (!res.ok) return unreachable()
324
+ if (!res.ok) return unreachable(res.status)
316
325
  let body: unknown
317
326
  try {
318
327
  body = await res.json()
@@ -356,6 +365,7 @@ export function settleDelivery(cached: CachedDelivery, context: RenderContext):
356
365
  verdict: { decision: 'suppress', reason: cached.reason },
357
366
  renderDef: null,
358
367
  ...(cached.unreachable ? { unreachable: true as const } : {}),
368
+ ...(cached.unreachable && cached.status !== undefined ? { status: cached.status } : {}),
359
369
  }
360
370
  }
361
371
  const verdict = renderVerdict(cached.payload, context)
@@ -417,6 +427,33 @@ export function peekVerifiedDelivery(
417
427
  return cached === undefined ? undefined : settleDelivery(cached, opts.context)
418
428
  }
419
429
 
430
+ /**
431
+ * 2.0.2: the settled answer for "CertREV was not reached in time": what a server render prints when it stops waiting
432
+ * (its deadline passed, or another request's read of the placement is in flight). Nothing, reported as
433
+ * `delivery_unreachable`, exactly as for a read that failed.
434
+ */
435
+ export function unreachableDelivery(): VerifiedDelivery {
436
+ return settleDelivery(unreachable(), { platform: 'builder', externalId: '', now: new Date(0) })
437
+ }
438
+
439
+ /**
440
+ * 2.0.2: remember that a read of one placement did not reach CertREV in time (a render stopped waiting for it), so the
441
+ * cache answers the placement at once for its failure window. A no-op on a cache with no failure window beyond 2.0.1's
442
+ * negative entry. Never touches a verified entry.
443
+ */
444
+ export function rememberDeliveryUnreachable(
445
+ opts: Pick<GetVerifiedDeliveryOptions, 'baseUrl' | 'platform' | 'externalId' | 'context' | 'cache' | 'design'>,
446
+ ): void {
447
+ ;(opts.cache ?? sharedDeliveryCache).markFailed(deliveryCacheKey(opts), unreachable())
448
+ }
449
+
450
+ /** 2.0.2: whether a read of one placement is already running in `cache` (a render's, or a background refresh). */
451
+ export function deliveryLoading(
452
+ opts: Pick<GetVerifiedDeliveryOptions, 'baseUrl' | 'platform' | 'externalId' | 'context' | 'cache' | 'design'>,
453
+ ): boolean {
454
+ return (opts.cache ?? sharedDeliveryCache).loading(deliveryCacheKey(opts))
455
+ }
456
+
420
457
  /** Drop the cached read for one placement (e.g. from a revocation webhook); with `design`, that pinned read. */
421
458
  export function invalidateDelivery(
422
459
  opts: Pick<GetVerifiedDeliveryOptions, 'baseUrl' | 'platform' | 'externalId' | 'context' | 'design'>,