@certrev/cert-block 1.3.0 → 2.0.1

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 (88) hide show
  1. package/CHANGELOG.md +235 -0
  2. package/README.md +162 -9
  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 +151 -0
  8. package/dist/builder/card-contract.d.ts.map +1 -0
  9. package/dist/builder/card-contract.js +286 -0
  10. package/dist/builder/card-contract.js.map +1 -0
  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 +49 -0
  16. package/dist/builder/card-report.d.ts.map +1 -0
  17. package/dist/builder/card-report.js +97 -0
  18. package/dist/builder/card-report.js.map +1 -0
  19. package/dist/builder/cert-review-card.d.ts +7 -5
  20. package/dist/builder/cert-review-card.d.ts.map +1 -1
  21. package/dist/builder/cert-review-card.js +202 -170
  22. package/dist/builder/cert-review-card.js.map +1 -1
  23. package/dist/builder/contract-card.d.ts +70 -0
  24. package/dist/builder/contract-card.d.ts.map +1 -0
  25. package/dist/builder/contract-card.js +191 -0
  26. package/dist/builder/contract-card.js.map +1 -0
  27. package/dist/builder/delivered-face.d.ts +9 -0
  28. package/dist/builder/delivered-face.d.ts.map +1 -1
  29. package/dist/builder/delivered-face.js +18 -5
  30. package/dist/builder/delivered-face.js.map +1 -1
  31. package/dist/builder/delivery-runtime.d.ts +27 -3
  32. package/dist/builder/delivery-runtime.d.ts.map +1 -1
  33. package/dist/builder/delivery-runtime.js +40 -4
  34. package/dist/builder/delivery-runtime.js.map +1 -1
  35. package/dist/builder/index.d.ts +7 -4
  36. package/dist/builder/index.d.ts.map +1 -1
  37. package/dist/builder/index.js +8 -4
  38. package/dist/builder/index.js.map +1 -1
  39. package/dist/builder/ssr-seed.d.ts +23 -4
  40. package/dist/builder/ssr-seed.d.ts.map +1 -1
  41. package/dist/builder/ssr-seed.js +29 -6
  42. package/dist/builder/ssr-seed.js.map +1 -1
  43. package/dist/builder/version.d.ts +3 -0
  44. package/dist/builder/version.d.ts.map +1 -0
  45. package/dist/builder/version.js +3 -0
  46. package/dist/builder/version.js.map +1 -0
  47. package/dist/index.d.ts +1 -0
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +2 -0
  50. package/dist/index.js.map +1 -1
  51. package/dist/reader/reader-css.generated.d.ts +1 -1
  52. package/dist/reader/reader-css.generated.d.ts.map +1 -1
  53. package/dist/reader/reader-css.generated.js +1 -1
  54. package/dist/reader/reader-css.generated.js.map +1 -1
  55. package/dist/reader/render.d.ts.map +1 -1
  56. package/dist/reader/render.js +71 -34
  57. package/dist/reader/render.js.map +1 -1
  58. package/dist/reader/tokens.d.ts +2 -0
  59. package/dist/reader/tokens.d.ts.map +1 -1
  60. package/dist/reader/tokens.js +28 -6
  61. package/dist/reader/tokens.js.map +1 -1
  62. package/dist/verify/get-verified-delivery.d.ts +32 -0
  63. package/dist/verify/get-verified-delivery.d.ts.map +1 -1
  64. package/dist/verify/get-verified-delivery.js +30 -5
  65. package/dist/verify/get-verified-delivery.js.map +1 -1
  66. package/dist/verify/key-set.d.ts +67 -0
  67. package/dist/verify/key-set.d.ts.map +1 -0
  68. package/dist/verify/key-set.js +190 -0
  69. package/dist/verify/key-set.js.map +1 -0
  70. package/package.json +115 -112
  71. package/src/builder/card-beacon.ts +201 -0
  72. package/src/builder/card-contract.ts +336 -0
  73. package/src/builder/card-placement.ts +208 -0
  74. package/src/builder/card-report.ts +126 -0
  75. package/src/builder/cert-review-card.tsx +285 -201
  76. package/src/builder/contract-card.tsx +216 -0
  77. package/src/builder/delivered-face.ts +18 -4
  78. package/src/builder/delivery-runtime.ts +46 -4
  79. package/src/builder/index.tsx +16 -4
  80. package/src/builder/ssr-seed.ts +37 -7
  81. package/src/builder/version.ts +2 -0
  82. package/src/index.ts +9 -0
  83. package/src/reader/reader-css.generated.ts +1 -1
  84. package/src/reader/reader.css +297 -2
  85. package/src/reader/render.ts +87 -38
  86. package/src/reader/tokens.ts +29 -6
  87. package/src/verify/get-verified-delivery.ts +54 -5
  88. package/src/verify/key-set.ts +225 -0
@@ -0,0 +1,216 @@
1
+ /**
2
+ * The contract-1 card face (2.0.0): one Builder block printing CertREV's server-rendered card verbatim.
3
+ *
4
+ * The block's wrapper holds, as its `dangerouslySetInnerHTML`, exactly `cardHtml(card, names)`: the block's part hosts
5
+ * in page order and, in the block holding the document's first part, the head fragment, both CertREV dialog hosts and
6
+ * the runtime's tag (`card-contract.ts`).
7
+ * Each host is a `<certrev-card>` with a declarative shadow root (`<template shadowrootmode="open">`), which the
8
+ * browser's HTML parser attaches as it reads the server's page: the card is complete with JavaScript off, and no page
9
+ * rule reaches inside it. React never reconciles the hosts (they are not React children), so the hosted runtime may
10
+ * remove a revoked placement's hosts without React noticing.
11
+ *
12
+ * What the parser does not do, the card does once it has mounted (`useCardRoots`):
13
+ * - ATTACH ROOTS the parser never saw. A card React inserts (an in-app navigation, Builder's editor, React's own
14
+ * re-render after a hydration error) gets its HTML through `innerHTML`, which leaves a declarative template inert.
15
+ * The wrapper is re-parsed with `setHTMLUnsafe`, which attaches declarative roots; a browser without it (or without
16
+ * declarative shadow DOM at all) gets each root attached from its template (`attachShadow` with the template's mode
17
+ * and options, then the template's content). A host that already has its root is left alone, so a hydrated card,
18
+ * whose roots the parser attached, is not touched.
19
+ * - LOAD THE RUNTIME a React-inserted `<script>` never runs: once per page, into `document.head`, with its SRI
20
+ * integrity and `crossorigin="anonymous"`, unless the runtime is already running (its global guard) or already
21
+ * injected. Only the block that prints the runtime's tag loads it (`cardCarriesAssets`). The server's HTML carries
22
+ * the same tag in that block's wrapper, which the parser runs; the runtime's init is idempotent, so it starts once.
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; 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.
36
+ *
37
+ * ONE BUILDER RULE, for the sidebar card only. The card's rail is a right float that the article's copy flows around
38
+ * (the Appearance preview's layout, and every other CMS's). On a Builder page the block sits in Builder's own wrapper,
39
+ * a flex item of the flex column that holds the entry's blocks, and a flex item contains its floats: the block spans
40
+ * the column at the rail's height and no copy sits beside it. `BUILDER_CARD_FLOW_CSS` lets the float out of Builder's
41
+ * wrappers, as 1.2.0-canary.6 did for the reader face: in a column that holds a printed rail, above the card's own
42
+ * 820px breakpoint (below it the rail stacks as a block), the column lays its blocks out as blocks and still contains
43
+ * the float, and the rail's block and the card wrapper become plain blocks. It reaches nothing inside the card (the
44
+ * shadow root is out of its reach) and nothing on a page without a rail, or in a browser without `:has()`.
45
+ */
46
+
47
+ import * as React from 'react'
48
+ import { maybeSendBrowserHealth } from './card-beacon.js'
49
+ import { type CardDocumentV1, type CardPartName, cardCarriesAssets, cardHtml } from './card-contract.js'
50
+ import { afterPageSettles, applyCardPlacement } from './card-placement.js'
51
+ import { SEED_ATTRIBUTE } from './ssr-seed.js'
52
+
53
+ /** The global the hosted runtime sets once it has started (`Symbol.for('certrev.card-runtime')` on `document`). */
54
+ const RUNTIME_GUARD = Symbol.for('certrev.card-runtime')
55
+ /** Marks the runtime tag this package injected into `document.head`. */
56
+ const INJECTED_RUNTIME_ATTRIBUTE = 'data-certrev-card-runtime'
57
+
58
+ /** A layout effect in a browser; nothing on a server (where React would only warn about it). */
59
+ const useBrowserLayoutEffect = typeof document === 'undefined' ? () => {} : React.useLayoutEffect
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
+
79
+ /** Whether this host still holds an unattached declarative root. */
80
+ function pendingTemplate(host: Element): HTMLTemplateElement | null {
81
+ if ((host as HTMLElement).shadowRoot) return null
82
+ for (const child of Array.from(host.children)) {
83
+ if (child instanceof HTMLTemplateElement && declaredMode(child) !== null) return child
84
+ }
85
+ return null
86
+ }
87
+
88
+ /** Attach one host's root from its template, as the parser would have: its mode, its options, its content. */
89
+ function attachFromTemplate(host: HTMLElement, template: HTMLTemplateElement): void {
90
+ const mode = declaredMode(template) === 'closed' ? 'closed' : 'open'
91
+ const init: ShadowRootInit & { clonable?: boolean; serializable?: boolean } = {
92
+ mode,
93
+ delegatesFocus: template.hasAttribute('shadowrootdelegatesfocus'),
94
+ ...(template.hasAttribute('shadowrootclonable') ? { clonable: true } : {}),
95
+ ...(template.hasAttribute('shadowrootserializable') ? { serializable: true } : {}),
96
+ }
97
+ const root = host.attachShadow(init)
98
+ root.appendChild(template.content)
99
+ template.remove()
100
+ }
101
+
102
+ /**
103
+ * Give every host in `wrapper` its declarative root: a no-op where the parser attached them all; else the wrapper is
104
+ * re-parsed from `html` with `setHTMLUnsafe` where the browser has it, or each pending root is attached from its
105
+ * template. Returns how many hosts were pending (for tests and the replica's evidence).
106
+ */
107
+ export function attachCardRoots(wrapper: Element, html: string): number {
108
+ const pending = Array.from(wrapper.querySelectorAll('certrev-card')).filter((host) => pendingTemplate(host) !== null)
109
+ if (pending.length === 0) return 0
110
+ const parse = (wrapper as Element & { setHTMLUnsafe?: (html: string) => void }).setHTMLUnsafe
111
+ if (typeof parse === 'function') {
112
+ parse.call(wrapper, html)
113
+ // A browser whose setHTMLUnsafe predates declarative roots leaves templates behind: attach those by hand.
114
+ for (const host of Array.from(wrapper.querySelectorAll('certrev-card'))) {
115
+ const template = pendingTemplate(host)
116
+ if (template) attachFromTemplate(host as HTMLElement, template)
117
+ }
118
+ return pending.length
119
+ }
120
+ for (const host of pending) {
121
+ const template = pendingTemplate(host)
122
+ if (template) attachFromTemplate(host as HTMLElement, template)
123
+ }
124
+ return pending.length
125
+ }
126
+
127
+ /** Load the document's runtime once per page: unless it already runs, or this package already injected it. */
128
+ export function injectCardRuntime(card: CardDocumentV1): void {
129
+ const runtime = card.assets.runtime
130
+ if (!runtime || typeof document === 'undefined' || !document.head) return
131
+ if ((document as unknown as Record<symbol, unknown>)[RUNTIME_GUARD]) return
132
+ for (const script of Array.from(document.head.querySelectorAll(`script[${INJECTED_RUNTIME_ATTRIBUTE}]`))) {
133
+ if (script.getAttribute('src') === runtime.src) return
134
+ }
135
+ const script = document.createElement('script')
136
+ // Attributes, not IDL properties: the markup is then exactly the server's tag, wherever a property is not reflected.
137
+ script.setAttribute('src', runtime.src)
138
+ script.setAttribute('integrity', runtime.integrity)
139
+ script.setAttribute('crossorigin', 'anonymous')
140
+ script.defer = true
141
+ script.setAttribute(INJECTED_RUNTIME_ATTRIBUTE, '')
142
+ document.head.appendChild(script)
143
+ }
144
+
145
+ /** Builder's wrappers around a printed rail let its float out (the header comment). */
146
+ export const BUILDER_CARD_FLOW_CSS =
147
+ '@media not all and (max-width: 820px){' +
148
+ '.builder-blocks:has(> .builder-block > [data-certrev-face="card"] > certrev-card[data-certrev-part="rail"]){display:flow-root !important}' +
149
+ '.builder-blocks > .builder-block:has(> [data-certrev-face="card"] > certrev-card[data-certrev-part="rail"]){display:block !important}' +
150
+ '.builder-blocks > .builder-block > [data-certrev-face="card"]:has(> certrev-card[data-certrev-part="rail"]){display:block !important}' +
151
+ '}'
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
+
156
+ export function ContractCardFace(props: {
157
+ readonly card: CardDocumentV1
158
+ readonly names: readonly CardPartName[]
159
+ /** The record that names this card (`ssr-seed.ts`): the browser repaints it from here with no read. */
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
163
+ }) {
164
+ const ref = React.useRef<HTMLDivElement>(null)
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])
170
+ const { card } = props
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
+ })
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(() => {
190
+ const wrapper = ref.current
191
+ if (!wrapper) return
192
+ return afterPageSettles(() => applyCardPlacement(wrapper, placing.current.card, placing.current.names))
193
+ }, [html])
194
+ const wrapper = (
195
+ <div
196
+ ref={ref}
197
+ data-certrev-cert-chrome=""
198
+ data-certrev-delivery=""
199
+ data-certrev-face="card"
200
+ data-certrev-contract={String(props.card.contract)}
201
+ data-certrev-def-version={props.card.defVersion}
202
+ {...{ [SEED_ATTRIBUTE]: props.seed }}
203
+ suppressHydrationWarning
204
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: CertREV's card document, printed only after it hashes to the digest the verified envelope signs
205
+ dangerouslySetInnerHTML={inner}
206
+ />
207
+ )
208
+ if (!props.names.includes('rail')) return wrapper
209
+ return (
210
+ <>
211
+ {wrapper}
212
+ {/* biome-ignore lint/security/noDangerouslySetInnerHtml: a package constant; React would escape the `>` and `"` of its selectors */}
213
+ <style data-certrev-builder-flow="" dangerouslySetInnerHTML={BUILDER_CARD_FLOW_HTML} />
214
+ </>
215
+ )
216
+ }
@@ -103,6 +103,22 @@ export function deliveredFaceHtml(face: DeliveredFace): string {
103
103
  return `<style>${face.css}</style>${face.html}`
104
104
  }
105
105
 
106
+ /**
107
+ * Make room in a memo of pending-then-settled work: drop the oldest SETTLED entry once the memo is full, never a pending
108
+ * one. A render suspends on a pending entry and, when React retries it, must find that entry again; evicting it would make
109
+ * every retry start (and suspend on) a new one, so a page with more documents than the memo holds would never finish
110
+ * (break-it B1). With nothing settled to drop, the memo grows past its bound until entries settle.
111
+ */
112
+ export function evictOldestSettled<V extends { settled?: unknown }>(memo: Map<string, V>, max: number): void {
113
+ if (memo.size < max) return
114
+ for (const [key, entry] of memo) {
115
+ if (entry.settled !== undefined) {
116
+ memo.delete(key)
117
+ return
118
+ }
119
+ }
120
+ }
121
+
106
122
  /** One face's hashing: the pending digest, and its answer once settled. */
107
123
  export interface FaceHashMemo {
108
124
  readonly pending: Promise<DeliveredFaceHashes | null>
@@ -128,10 +144,8 @@ export function faceHashesFor(face: DeliveredFace): FaceHashMemo {
128
144
  return hashes
129
145
  }),
130
146
  }
131
- if (faceHashMemo.size >= FACE_HASH_MEMO_MAX) {
132
- const oldest = faceHashMemo.keys().next().value
133
- if (oldest !== undefined) faceHashMemo.delete(oldest)
134
- }
147
+ // 2.0.0: never a pending entry (break-it B1: more faces than the memo holds made every retry suspend again).
148
+ evictOldestSettled(faceHashMemo, FACE_HASH_MEMO_MAX)
135
149
  faceHashMemo.set(key, fresh)
136
150
  return fresh
137
151
  }
@@ -13,22 +13,46 @@
13
13
  * read on a brand's server then misses at most once per placement, and a warm one never waits.
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
+ *
17
+ * 2.0.0: the trust root is a key SET (`verify/key-set.ts`). `resolveKid` is still the baked set (today
18
+ * `cert-issuer-1`), and the card verifies with `resolveKidFor(baseUrl)`: a baked kid resolves as before, any other kid
19
+ * makes one read of the delivery origin's published key set, accepted only when a baked kid signs it. A rotation then
20
+ * needs no reinstall. The card's (2.0) contract-1 document digests are memoized with the delivered faces' hashes.
16
21
  */
17
22
 
18
23
  import type { ResolvePublicKeyByKid } from '../contract/kernel.js'
19
24
  import { TtlCache } from '../verify/cache.js'
20
25
  import type { CachedDelivery } from '../verify/get-verified-delivery.js'
26
+ import { forgetKeySetsForTests, keySetResolver } from '../verify/key-set.js'
27
+ import { setCardBeaconForTests } from './card-beacon.js'
28
+ import { forgetCardDigestsForTests } from './card-contract.js'
29
+ import { forgetCardReportsForTests } from './card-report.js'
21
30
  import { forgetFaceHashesForTests } from './delivered-face.js'
22
31
  import { CERT_ISSUER_KID, CERT_ISSUER_PUBLIC_KEY_PEM } from './issuer-key.js'
23
32
  import { forgetPageSeedsForTests } from './ssr-seed.js'
24
33
 
25
34
  /** The card's server read deadline. A brand's server waits this long once per cold placement. */
26
35
  export const CARD_DELIVERY_TIMEOUT_MS = 8_000
27
- /** How long past its 60s TTL a certificate may still paint while it is refreshed or CertREV is down. */
28
- export const CARD_DELIVERY_STALE_MS = 300_000
36
+ /**
37
+ * 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
38
+ * API's own cache (`max-age=60, stale-while-revalidate=240`), so a revoked card is gone within ADR 0001 §7's five to
39
+ * six minutes (2.0.0's 60 s + 300 s here stacked to about twelve).
40
+ */
41
+ export const CARD_DELIVERY_TTL_MS = 30_000
42
+ /**
43
+ * 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:
44
+ * 300 s). Never longer: past it, a read that cannot reach CertREV prints nothing.
45
+ */
46
+ export const CARD_DELIVERY_STALE_MS = 30_000
29
47
 
30
48
  export interface CardDeliveryRuntime {
49
+ /** The baked kids: the package's own trust root (and the only keys that may sign a published key set). */
31
50
  readonly resolveKid: ResolvePublicKeyByKid
51
+ /**
52
+ * 2.0.0: what the card verifies a read from `baseUrl` with: the baked kids, then that origin's signed published set.
53
+ * Absent: derived from `resolveKid`, `fetchImpl` and `timeoutMs` (`keySetResolver`).
54
+ */
55
+ readonly resolveKidFor?: (baseUrl: string) => ResolvePublicKeyByKid
32
56
  readonly fetchImpl: () => typeof fetch
33
57
  readonly cache: TtlCache<CachedDelivery>
34
58
  readonly now: () => number
@@ -40,7 +64,7 @@ const bakedResolveKid: ResolvePublicKeyByKid = (kid) =>
40
64
 
41
65
  function newCardCache(now?: () => number): TtlCache<CachedDelivery> {
42
66
  return new TtlCache<CachedDelivery>({
43
- ttlMs: 60_000,
67
+ ttlMs: CARD_DELIVERY_TTL_MS,
44
68
  negativeTtlMs: 5_000,
45
69
  staleMs: CARD_DELIVERY_STALE_MS,
46
70
  // A refresh the host runtime abandoned (workerd cancels a finished request's I/O) never
@@ -66,13 +90,30 @@ export function cardDeliveryRuntime(): CardDeliveryRuntime {
66
90
  return runtime
67
91
  }
68
92
 
93
+ /** 2.0.0: the resolver for a read from `baseUrl`: the runtime's own, else the baked kids plus the origin's key set. */
94
+ export function cardResolveKidFor(rt: CardDeliveryRuntime, baseUrl: string): ResolvePublicKeyByKid {
95
+ if (rt.resolveKidFor) return rt.resolveKidFor(baseUrl)
96
+ return keySetResolver({
97
+ baseUrl,
98
+ bakedResolveKid: rt.resolveKid,
99
+ fetchImpl: rt.fetchImpl(),
100
+ ...(rt.timeoutMs !== undefined ? { timeoutMs: rt.timeoutMs } : {}),
101
+ })
102
+ }
103
+
69
104
  /**
70
105
  * Test seam: replace parts of the runtime (a fresh cache unless one is given) and forget the page's envelope seeds
71
- * (a fresh runtime is a fresh page) and (1.3.0) every delivered face's memoized hashes. Returns a restore.
106
+ * (a fresh runtime is a fresh page), (1.3.0) every delivered face's memoized hashes and (2.0.0) every card
107
+ * document's digest and every published key set. 2.0.1: beacons go nowhere (a test that wants them sets its own
108
+ * transport with `setCardBeaconForTests` after this). Returns a restore.
72
109
  */
73
110
  export function setCardDeliveryRuntimeForTests(overrides: Partial<CardDeliveryRuntime>): () => void {
74
111
  forgetPageSeedsForTests()
75
112
  forgetFaceHashesForTests()
113
+ forgetCardDigestsForTests()
114
+ forgetKeySetsForTests()
115
+ forgetCardReportsForTests()
116
+ const restoreBeacon = setCardBeaconForTests()
76
117
  const previous = runtime
77
118
  const now = overrides.now ?? previous.now
78
119
  runtime = {
@@ -82,5 +123,6 @@ export function setCardDeliveryRuntimeForTests(overrides: Partial<CardDeliveryRu
82
123
  }
83
124
  return () => {
84
125
  runtime = previous
126
+ restoreBeacon()
85
127
  }
86
128
  }
@@ -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
@@ -155,6 +155,17 @@ export const certRevAnchorComponent: CertRevBuilderRegistration = {
155
155
  // 1.2.0-canary.5: the design pin, for CertREV's own canary replica (`<Content context={{ certrevDesignPin }}>`).
156
156
  export { type DesignPin, designPinFromUrl, parseDesignPin } from '../verify/get-verified-delivery.js'
157
157
 
158
+ // 2.0.0: the contract-1 card (CertREV's server-rendered card document), its fallback report and the Builder flow rule.
159
+ export {
160
+ acceptedCardBinding,
161
+ type CardBinding,
162
+ type CardDocumentV1,
163
+ type CardPartName,
164
+ cardDocumentDigest,
165
+ cardPartsFor,
166
+ parseCardDocument,
167
+ } from './card-contract.js'
168
+ export { CARD_FALLBACK_EVENT, type CardFallbackDetail, type CardFallbackReason } from './card-report.js'
158
169
  // ── The Builder.io PUSH kit (CertReviewCard) + the single-sourced wire type ──
159
170
  export {
160
171
  BUILDER_CERT_CHROME_KEYS,
@@ -176,6 +187,7 @@ export {
176
187
  PLACEMENT_INPUTS,
177
188
  WIRE_INPUTS,
178
189
  } from './cert-review-card.js'
190
+ export { BUILDER_CARD_FLOW_CSS } from './contract-card.js'
179
191
  export {
180
192
  CERT_FACE_FIELDS,
181
193
  credentialLabel,
@@ -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.
@@ -55,6 +56,17 @@
55
56
  * whose hashes fail paints what 1.2.1 paints for it. A seed with no face is byte for byte the 1.2.1 seed. The face is
56
57
  * on the page twice (painted, and in the seed), which is its page-weight cost.
57
58
  *
59
+ * A CONTRACT-1 CARD RIDES THE SEED (2.0.0), and only when the server printed it: the seed then ends with `card`, the
60
+ * card document exactly as the Delivery API served it, whose RFC 8785 digest the signed `payload.content.faceDigest`
61
+ * binds (`contract: 1`, `doc`). The browser prints that card from the seed only after it has checked it against the
62
+ * seed payload's binding itself (`cert-review-card.tsx`), so a client re-render (React's recovery from a hydration
63
+ * error, a streamed record, a later mount of the same placement) prints the server's card with no read. A seed with no
64
+ * card is the 1.3.1 seed byte for byte. The card is on the page twice (printed, and in the seed), as 1.3.0's face is.
65
+ *
66
+ * A FALLBACK RECORD MAY NAME ITS REASON (2.0.0): where a contract-1 placement prints nothing, its `'none'` record ends
67
+ * with `reason` (`card_missing`, `card_verify_failed`, `card_unsupported_contract`, `card_pin_mismatch`), the same
68
+ * reason the card reports. Every other record is the 1.3.1 record byte for byte, and 1.3.1 ignores the key.
69
+ *
58
70
  * React-free and browser-only in effect: with no `document` (a server) `pageSeedFor` finds nothing, so a server never
59
71
  * reads or keeps a seed and a multi-tenant render process holds nothing per page.
60
72
  */
@@ -90,6 +102,11 @@ export interface DeliverySeed {
90
102
  * seed read from the page it is UNVERIFIED until hashed against `payload.content.faceDigest`.
91
103
  */
92
104
  readonly face?: DeliveredFace
105
+ /**
106
+ * 2.0.0: the contract-1 card document the server printed (the file header), exactly as served; absent whenever it
107
+ * printed anything else. On a seed read from the page it is UNVERIFIED until checked against the payload's binding.
108
+ */
109
+ readonly card?: Readonly<Record<string, unknown>>
93
110
  }
94
111
 
95
112
  /** A seed as read from the page: the parsed seed and the attribute's exact text (re-rendered verbatim on hydration). */
@@ -98,7 +115,7 @@ export interface PageSeed {
98
115
  readonly raw: string
99
116
  }
100
117
 
101
- /** 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). */
102
119
  export type FallbackFace = 'options' | 'none'
103
120
 
104
121
  /** The record a server writes where it had no envelope to paint (the file header). */
@@ -107,6 +124,8 @@ export interface DeliveryFallback {
107
124
  readonly externalId: string
108
125
  readonly design?: DesignPin
109
126
  readonly face: FallbackFace
127
+ /** 2.0.0: why a contract-1 placement printed nothing; absent on every other record. */
128
+ readonly reason?: string
110
129
  }
111
130
 
112
131
  /** A fallback record as read from the page, with the attribute's exact text. */
@@ -203,6 +222,8 @@ export function serializeSeed(seed: DeliverySeed): string {
203
222
  renderDef: seed.renderDef,
204
223
  // 1.3.0: last, and only with a delivered face, so a seed without one is the 1.2.1 bytes.
205
224
  ...(seed.face ? { face: { html: seed.face.html, css: seed.face.css } } : {}),
225
+ // 2.0.0: last, and only with a printed contract-1 card, so a seed without one is the 1.3.1 bytes.
226
+ ...(seed.card ? { card: seed.card } : {}),
206
227
  })
207
228
  }
208
229
 
@@ -213,6 +234,7 @@ export function serializeFallback(fallback: DeliveryFallback): string {
213
234
  externalId: fallback.externalId,
214
235
  ...serializedPin(fallback.design),
215
236
  face: fallback.face,
237
+ ...(fallback.reason === undefined ? {} : { reason: fallback.reason }),
216
238
  })
217
239
  }
218
240
 
@@ -234,7 +256,7 @@ export function parseSeed(raw: string): DeliverySeed | null {
234
256
  return null
235
257
  }
236
258
  if (!isRecord(value) || value.v !== 1) return null
237
- const { baseUrl, externalId, design, payload, signature, renderDef, face } = value
259
+ const { baseUrl, externalId, design, payload, signature, renderDef, face, card } = value
238
260
  if (typeof baseUrl !== 'string' || typeof externalId !== 'string') return null
239
261
  if (!isRecord(payload) || !isRecord(payload.subject) || !isRecord(payload.lifecycle) || !isRecord(payload.content)) {
240
262
  return null
@@ -252,6 +274,8 @@ export function parseSeed(raw: string): DeliverySeed | null {
252
274
  ...(isRecord(signature) ? { signature: signature as unknown as CertSignature } : {}),
253
275
  renderDef: parseRenderDef(renderDef),
254
276
  ...(delivered ? { face: delivered } : {}),
277
+ // 2.0.0: a card that is not a JSON object is no card (the seed paints as its binding says without one).
278
+ ...(isRecord(card) ? { card } : {}),
255
279
  }
256
280
  }
257
281
 
@@ -264,12 +288,18 @@ export function parseFallback(raw: string): DeliveryFallback | null {
264
288
  return null
265
289
  }
266
290
  if (!isRecord(value) || value.v !== 1 || 'payload' in value) return null
267
- const { baseUrl, externalId, design, face } = value
291
+ const { baseUrl, externalId, design, face, reason } = value
268
292
  if (typeof baseUrl !== 'string' || typeof externalId !== 'string') return null
269
293
  if (face !== 'options' && face !== 'none') return null
270
294
  const pin = parsedPin(design)
271
295
  if (pin === null) return null
272
- return { baseUrl, externalId, ...(pin ? { design: pin } : {}), face }
296
+ return {
297
+ baseUrl,
298
+ externalId,
299
+ ...(pin ? { design: pin } : {}),
300
+ face,
301
+ ...(typeof reason === 'string' ? { reason } : {}),
302
+ }
273
303
  }
274
304
 
275
305
  /** One placement's key: the origin, the entry and the pin (the read's own key, so a pin never shares a seed). */
@@ -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.1'
package/src/index.ts CHANGED
@@ -160,6 +160,15 @@ export {
160
160
  invalidateVerdict,
161
161
  sharedVerdictCache,
162
162
  } from './verify/get-verified-envelope.js'
163
+ // 2.0.0: the issuer key SET (baked kids, then the origin's published set signed by a baked kid).
164
+ export {
165
+ type AcceptedKeySet,
166
+ KEY_SET_PATH,
167
+ type KeySetResolverOptions,
168
+ keySetResolver,
169
+ type PublishedKeySet,
170
+ verifyKeySet,
171
+ } from './verify/key-set.js'
163
172
  export {
164
173
  type FetchingKidResolverOptions,
165
174
  fetchingKidResolver,