@certrev/cert-block 0.5.5 → 1.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 (190) hide show
  1. package/CHANGELOG.md +189 -25
  2. package/LICENSE +21 -0
  3. package/README.md +415 -54
  4. package/dist/builder/cert-chrome-data.d.ts +26 -8
  5. package/dist/builder/cert-chrome-data.d.ts.map +1 -1
  6. package/dist/builder/cert-chrome-data.js +5 -5
  7. package/dist/builder/cert-chrome-data.js.map +1 -1
  8. package/dist/builder/cert-review-card.d.ts +35 -5
  9. package/dist/builder/cert-review-card.d.ts.map +1 -1
  10. package/dist/builder/cert-review-card.js +117 -34
  11. package/dist/builder/cert-review-card.js.map +1 -1
  12. package/dist/builder/index.d.ts +33 -16
  13. package/dist/builder/index.d.ts.map +1 -1
  14. package/dist/builder/index.js +33 -16
  15. package/dist/builder/index.js.map +1 -1
  16. package/dist/components/CertBadge.d.ts +2 -2
  17. package/dist/components/CertBadge.d.ts.map +1 -1
  18. package/dist/components/CertBadge.js +11 -5
  19. package/dist/components/CertBadge.js.map +1 -1
  20. package/dist/components/CertJsonLd.d.ts.map +1 -1
  21. package/dist/components/CertJsonLd.js.map +1 -1
  22. package/dist/components/CertRevBacklink.d.ts.map +1 -1
  23. package/dist/components/CertRevBacklink.js +3 -2
  24. package/dist/components/CertRevBacklink.js.map +1 -1
  25. package/dist/components/CertReview.d.ts +1 -1
  26. package/dist/components/ExpertBio.d.ts.map +1 -1
  27. package/dist/components/ExpertBio.js +12 -4
  28. package/dist/components/ExpertBio.js.map +1 -1
  29. package/dist/components/escape.d.ts +28 -1
  30. package/dist/components/escape.d.ts.map +1 -1
  31. package/dist/components/escape.js +37 -8
  32. package/dist/components/escape.js.map +1 -1
  33. package/dist/components/format.d.ts +51 -2
  34. package/dist/components/format.d.ts.map +1 -1
  35. package/dist/components/format.js +103 -6
  36. package/dist/components/format.js.map +1 -1
  37. package/dist/components/preview-block.d.ts +1 -1
  38. package/dist/components/preview-block.js +3 -3
  39. package/dist/components/preview-block.js.map +1 -1
  40. package/dist/components/rel.d.ts +24 -0
  41. package/dist/components/rel.d.ts.map +1 -0
  42. package/dist/components/rel.js +24 -0
  43. package/dist/components/rel.js.map +1 -0
  44. package/dist/components/render-cert-block.d.ts +50 -22
  45. package/dist/components/render-cert-block.d.ts.map +1 -1
  46. package/dist/components/render-cert-block.js +146 -80
  47. package/dist/components/render-cert-block.js.map +1 -1
  48. package/dist/components/render-def.d.ts +13 -14
  49. package/dist/components/render-def.d.ts.map +1 -1
  50. package/dist/components/render-def.js +14 -15
  51. package/dist/components/render-def.js.map +1 -1
  52. package/dist/index.d.ts +1 -0
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +4 -2
  55. package/dist/index.js.map +1 -1
  56. package/dist/jsonld/project.d.ts +46 -13
  57. package/dist/jsonld/project.d.ts.map +1 -1
  58. package/dist/jsonld/project.js +139 -56
  59. package/dist/jsonld/project.js.map +1 -1
  60. package/dist/modal/avatar-resize.d.ts +10 -10
  61. package/dist/modal/avatar-resize.js +14 -14
  62. package/dist/modal/avatar-resize.js.map +1 -1
  63. package/dist/modal/cert-modal-view.d.ts +27 -7
  64. package/dist/modal/cert-modal-view.d.ts.map +1 -1
  65. package/dist/modal/cert-modal-view.js +49 -25
  66. package/dist/modal/cert-modal-view.js.map +1 -1
  67. package/dist/modal/certrev-cert-modal.d.ts +5 -5
  68. package/dist/modal/certrev-cert-modal.js +9 -9
  69. package/dist/modal/certrev-cert-modal.js.map +1 -1
  70. package/dist/modal/certrev-cert-modal.styles.d.ts +6 -6
  71. package/dist/modal/certrev-cert-modal.styles.d.ts.map +1 -1
  72. package/dist/modal/certrev-cert-modal.styles.js +8 -8
  73. package/dist/modal/certrev-cert-modal.styles.js.map +1 -1
  74. package/dist/modal/certrev-cert.entry.d.ts +8 -6
  75. package/dist/modal/certrev-cert.entry.d.ts.map +1 -1
  76. package/dist/modal/certrev-cert.entry.js +9 -7
  77. package/dist/modal/certrev-cert.entry.js.map +1 -1
  78. package/dist/modal/certrev-cert.js +6 -6
  79. package/dist/modal/display-strings.d.ts +25 -5
  80. package/dist/modal/display-strings.d.ts.map +1 -1
  81. package/dist/modal/display-strings.js +27 -5
  82. package/dist/modal/display-strings.js.map +1 -1
  83. package/dist/modal/escape.d.ts +26 -3
  84. package/dist/modal/escape.d.ts.map +1 -1
  85. package/dist/modal/escape.js +32 -3
  86. package/dist/modal/escape.js.map +1 -1
  87. package/dist/modal/font-face.d.ts.map +1 -1
  88. package/dist/modal/font-face.js +8 -11
  89. package/dist/modal/font-face.js.map +1 -1
  90. package/dist/modal/ftc-disclosure.d.ts +2 -2
  91. package/dist/modal/ftc-disclosure.js +2 -2
  92. package/dist/modal/ftc-guard.d.ts +44 -9
  93. package/dist/modal/ftc-guard.d.ts.map +1 -1
  94. package/dist/modal/ftc-guard.js +50 -17
  95. package/dist/modal/ftc-guard.js.map +1 -1
  96. package/dist/modal/index.d.ts +1 -1
  97. package/dist/modal/index.js +1 -1
  98. package/dist/modal/interactions.d.ts +2 -2
  99. package/dist/modal/interactions.js +2 -2
  100. package/dist/modal/logo-paths.js +2 -2
  101. package/dist/modal/logo-paths.js.map +1 -1
  102. package/dist/modal/placement.d.ts +4 -3
  103. package/dist/modal/placement.d.ts.map +1 -1
  104. package/dist/modal/placement.js +7 -6
  105. package/dist/modal/placement.js.map +1 -1
  106. package/dist/modal/revalidate.d.ts +2 -3
  107. package/dist/modal/revalidate.d.ts.map +1 -1
  108. package/dist/modal/revalidate.js +2 -3
  109. package/dist/modal/revalidate.js.map +1 -1
  110. package/dist/modal/verify-client.d.ts +2 -2
  111. package/dist/modal/verify-client.js +2 -2
  112. package/dist/verify/cache.d.ts +21 -6
  113. package/dist/verify/cache.d.ts.map +1 -1
  114. package/dist/verify/cache.js +29 -8
  115. package/dist/verify/cache.js.map +1 -1
  116. package/dist/verify/fetch-with-deadline.d.ts +35 -0
  117. package/dist/verify/fetch-with-deadline.d.ts.map +1 -0
  118. package/dist/verify/fetch-with-deadline.js +55 -0
  119. package/dist/verify/fetch-with-deadline.js.map +1 -0
  120. package/dist/verify/get-verified-envelope.d.ts +23 -9
  121. package/dist/verify/get-verified-envelope.d.ts.map +1 -1
  122. package/dist/verify/get-verified-envelope.js +89 -40
  123. package/dist/verify/get-verified-envelope.js.map +1 -1
  124. package/dist/verify/resolve-kid.d.ts +3 -0
  125. package/dist/verify/resolve-kid.d.ts.map +1 -1
  126. package/dist/verify/resolve-kid.js +17 -8
  127. package/dist/verify/resolve-kid.js.map +1 -1
  128. package/dist/webcomponent/certrev-badge.d.ts.map +1 -1
  129. package/dist/webcomponent/certrev-badge.js +24 -2
  130. package/dist/webcomponent/certrev-badge.js.map +1 -1
  131. package/dist/webcomponent/render-badge-html.d.ts +1 -1
  132. package/dist/webcomponent/render-badge-html.d.ts.map +1 -1
  133. package/dist/webcomponent/render-badge-html.js +36 -17
  134. package/dist/webcomponent/render-badge-html.js.map +1 -1
  135. package/package.json +5 -2
  136. package/src/builder/cert-chrome-data.ts +26 -8
  137. package/src/builder/cert-review-card.tsx +149 -38
  138. package/src/builder/index.tsx +34 -17
  139. package/src/components/CertBadge.tsx +14 -9
  140. package/src/components/CertJsonLd.tsx +1 -1
  141. package/src/components/CertRevBacklink.tsx +10 -3
  142. package/src/components/CertReview.tsx +1 -1
  143. package/src/components/ExpertBio.tsx +15 -8
  144. package/src/components/escape.ts +37 -7
  145. package/src/components/format.ts +102 -6
  146. package/src/components/preview-block.ts +3 -3
  147. package/src/components/rel.ts +23 -0
  148. package/src/components/render-cert-block.ts +192 -109
  149. package/src/components/render-def.ts +14 -15
  150. package/src/index.ts +4 -2
  151. package/src/jsonld/project.ts +155 -59
  152. package/src/modal/avatar-resize.ts +14 -14
  153. package/src/modal/cert-modal-view.ts +52 -27
  154. package/src/modal/certrev-cert-modal.styles.ts +8 -8
  155. package/src/modal/certrev-cert-modal.ts +9 -9
  156. package/src/modal/certrev-cert.entry.ts +9 -7
  157. package/src/modal/display-strings.ts +26 -5
  158. package/src/modal/escape.ts +32 -4
  159. package/src/modal/font-face.ts +8 -11
  160. package/src/modal/ftc-disclosure.ts +2 -2
  161. package/src/modal/ftc-guard.ts +73 -18
  162. package/src/modal/index.ts +1 -1
  163. package/src/modal/interactions.ts +2 -2
  164. package/src/modal/logo-paths.ts +2 -2
  165. package/src/modal/placement.ts +7 -6
  166. package/src/modal/revalidate.ts +2 -3
  167. package/src/modal/verify-client.ts +2 -2
  168. package/src/verify/cache.ts +34 -8
  169. package/src/verify/fetch-with-deadline.ts +60 -0
  170. package/src/verify/get-verified-envelope.ts +131 -48
  171. package/src/verify/resolve-kid.ts +22 -6
  172. package/src/webcomponent/certrev-badge.ts +24 -2
  173. package/src/webcomponent/render-badge-html.ts +66 -19
  174. package/src/__tests__/__snapshots__/render-cert-block.test.ts.snap +0 -17
  175. package/src/__tests__/components.test.tsx +0 -206
  176. package/src/__tests__/preview-block.test.ts +0 -84
  177. package/src/__tests__/project.test.ts +0 -128
  178. package/src/__tests__/readme-version.test.ts +0 -51
  179. package/src/__tests__/render-cert-block.test.ts +0 -657
  180. package/src/__tests__/render-def.test.tsx +0 -295
  181. package/src/__tests__/verify.test.ts +0 -239
  182. package/src/__tests__/webcomponent.test.tsx +0 -116
  183. package/src/builder/__tests__/builder.test.tsx +0 -158
  184. package/src/modal/cert-modal-view.test.ts +0 -175
  185. package/src/modal/certrev-cert-modal.test.ts +0 -125
  186. package/src/modal/ftc-guard.test.ts +0 -153
  187. package/src/modal/interactions.test.ts +0 -250
  188. package/src/modal/placement.test.ts +0 -398
  189. package/src/modal/revalidate.test.ts +0 -111
  190. package/src/modal/verify-client.test.ts +0 -164
package/README.md CHANGED
@@ -6,14 +6,14 @@ This is the SDK the brand SSRs into a Hydrogen loader, a Next server component,
6
6
 
7
7
  ## What this is
8
8
 
9
- CertREV's durable asset is a portable, forgery-proof "this content was reviewed by a credentialed expert" credential — a single Ed25519-signed `CertDeliveryEnvelope`. Each delivery surface renders the visible UI + JSON-LD from that envelope and enforces its validity. The WordPress plugin does it in PHP; the Shopify Liquid extension does it in Liquid; **this package does it in TS/JS for any React or vanilla-JS surface that can run Node/WebCrypto.**
9
+ CertREV's durable asset is a portable, forgery-proof "this content was reviewed by a credentialed expert" credential: a single Ed25519-signed `CertDeliveryEnvelope`. Each delivery surface renders the visible UI + JSON-LD from that envelope and enforces its validity. The WordPress plugin does it in PHP; the Shopify Liquid extension does it in Liquid; **this package does it in TS/JS for any React or vanilla-JS surface that can run Node/WebCrypto.**
10
10
 
11
11
  The package gives a brand four things:
12
12
 
13
- 1. **React components** — `<CertBadge>`, `<ExpertBio>`, `<CertRevBacklink>`, `<CertJsonLd>`, and the `<CertReview>` composite. SSR-safe (render-pure; no `useState`/`useEffect`/browser globals), theme-light, accessible, and they escape every field. Presentation is driven by the signed `content.display` config (`badgeStyle`, `accentColor`, `showExpertPhoto`, `showMemo`, `showAuthor`).
14
- 2. **A deterministic JSON-LD projector** — `projectCertJsonLd(facts)` → a schema.org `Article` + `reviewedBy(Person)` + `Review` + `Organization` `@graph`, designed to **merge by `@id`** into the host page's existing Article graph (won't collide with Yoast / the theme's structured data). The JSON-LD is **projected from the facts at render time, never stored in the envelope.**
15
- 3. **A thin verify layer** — `getVerifiedEnvelope(source)` fetches the signed envelope from **either a Shopify metafield OR the Delivery API** (`GET /api/cert/v1/delivery/{platform}/{externalId}`), runs the fail-closed `VerdictKernel`, and caches the verdict (TTL + single-flight) so concurrent SSR renders don't stampede the origin.
16
- 4. **A Web Component** — `<certrev-badge>`, the `universal_embed` surface for sites with no native integration, wrapping the same render as `<CertBadge>` via a shared string renderer.
13
+ 1. **React components**: `<CertBadge>`, `<ExpertBio>`, `<CertRevBacklink>`, `<CertJsonLd>`, and the `<CertReview>` composite. SSR-safe (render-pure; no `useState`/`useEffect`/browser globals), theme-light, accessible, and they escape every field. Presentation is driven by the signed `content.display` config (`badgeStyle`, `accentColor`, `showExpertPhoto`, `showMemo`, `showAuthor`), which is deprecated on the wire from v0.2 (no current issuer writes it) and still read at the render edge, so a v0.1.x envelope renders exactly as it was signed.
14
+ 2. **A deterministic JSON-LD projector**: `projectCertJsonLd(facts)` → a schema.org `@graph` of `WebPage` + `Article` + `reviewedBy(Person)` + `EducationalOccupationalCredential` + `Review` + `Organization`, designed to **merge by `@id`** into the host page's existing WebPage/Article graph (won't collide with Yoast / the theme's structured data). The JSON-LD is **projected from the facts at render time, never stored in the envelope**, and every field mirroring something the card hides is gated by the same `resolveDisplay` the card uses; the graph never asserts what the reader cannot see.
15
+ 3. **A thin verify layer**: `getVerifiedEnvelope(source)` fetches the signed envelope from **either a Shopify metafield OR the Delivery API** (`GET /api/cert/v1/delivery/{platform}/{externalId}`), runs the fail-closed `VerdictKernel`, and caches the verdict (TTL + single-flight) so concurrent SSR renders don't stampede the origin.
16
+ 4. **A Web Component**: `<certrev-badge>`, the `universal_embed` surface for sites with no native integration, wrapping the same render as `<CertBadge>` via a shared string renderer.
17
17
 
18
18
  ## The contract shape (settled)
19
19
 
@@ -24,14 +24,15 @@ payload = {
24
24
  contractVersion: 1,
25
25
  certId,
26
26
  subject: { platform, externalId, logicalArticleId, canonicalUrls[], installationId, contentDigest },
27
- content: { expert{displayName,credentials[],profileUrl,photoUrl}, author{name,title},
28
- memo, certifiedAt, contentModifiedAt, verifyUrl,
29
- display{accentColor,showExpertPhoto,showAuthor,showMemo,badgeStyle} },
27
+ content: { expert{displayName,credentials[],profileUrl,photoUrl,bio?,background?,compensationCue?},
28
+ author{name,title,photoUrl?,bio?}, memo, certifiedAt, contentModifiedAt, verifyUrl,
29
+ articleTitle?, displayCertId?, // v0.5, signed
30
+ display? }, // deprecated v0.2: accept-on-read only
30
31
  lifecycle: { issuedAt, expiresAt, revokedAt|null, revision },
31
32
  }
32
33
  ```
33
34
 
34
- `content` is **structured FACTS**, not rendered JSON-LD — the JSON-LD is projected from these facts at render, never stored. The `VerdictKernel` (shared) verifies the signature, then checks subject (`platform`/`externalId`), lifecycle (`revokedAt`/`expiresAt`), and content drift (`contentDigest` vs the live hash), failing closed at every step.
35
+ `content` is **structured FACTS**, not rendered JSON-LD; the JSON-LD is projected from these facts at render, never stored. The `VerdictKernel` (shared) verifies the signature, then checks subject (`platform`/`externalId`), lifecycle (`revokedAt`/`expiresAt`), and content drift (`contentDigest` vs the live hash), failing closed at every step.
35
36
 
36
37
  ## Public API
37
38
 
@@ -50,21 +51,44 @@ import { staticKidResolver, fetchingKidResolver } from '@certrev/cert-block'
50
51
  import type { CertDeliveryEnvelope, CertPayload, CertVerdict, RenderContext } from '@certrev/cert-block'
51
52
  import { verifyEnvelope, renderVerdict, verifySignatureOnly } from '@certrev/cert-block'
52
53
 
53
- // Web Component (separate subpath — does NOT touch customElements on import)
54
+ // Link qualification: the `rel` every anchor this package emits carries (read-only)
55
+ import { CERTREV_LINK_REL } from '@certrev/cert-block'
56
+
57
+ // Web Component (separate subpath: does NOT touch customElements on import)
54
58
  import { defineCertRevBadge, setCertRevKidResolver } from '@certrev/cert-block/webcomponent'
55
59
 
56
60
  // Builder.io registration (separate subpath; no Builder dependency)
57
61
  import { certRevCertComponent, BUILDER_REGISTRATION, CERT_COMPONENT_NAME } from '@certrev/cert-block/builder'
62
+
63
+ // Certificate modal: the ONE SIDE-EFFECTING entry in the package. Importing it registers
64
+ // <certrev-cert-modal> and, in a browser, starts four DOM passes (placement, interactions,
65
+ // FTC guard, badge revalidation). Browser only. Never import it from server code. Most
66
+ // integrations should load the prebuilt IIFE as an external <script> instead (see below).
67
+ import '@certrev/cert-block/modal'
68
+ import { CERT_ISSUER_KID, CERT_ISSUER_PUBLIC_KEY_PEM, CERT_MODAL_TAG } from '@certrev/cert-block/modal'
69
+ import certScriptUrl from '@certrev/cert-block/modal/certrev-cert.js?url'
70
+
71
+ // The placement engine on its own: pure functions + selector constants, no registration,
72
+ // no DOM pass on import. This is the subpath to reach for if you only want the anchors.
73
+ import { placeCert, initCertPlacement, PLACEMENT_ANCHORS } from '@certrev/cert-block/modal/placement'
74
+
75
+ // Test/dev fixtures: Node-only (they sign with node:crypto), which is why they are NOT on
76
+ // the main entry: importing `@certrev/cert-block` must never pull a Node builtin into an edge bundle.
77
+ import { makeMockPayload, makeSignedEnvelope, FIXTURE_KID } from '@certrev/cert-block/fixtures'
58
78
  ```
59
79
 
60
- ## Usage — headless React (Hydrogen / Next server component)
80
+ ## Usage: headless React (Hydrogen / Next server component)
61
81
 
62
- Fetch + verify in the **server** runtime (it can run Ed25519 over JCS — full `crypto_verify` parity), then render the badge + JSON-LD only on a `render` verdict.
82
+ Fetch + verify in the **server** runtime (it can run Ed25519 over JCS: full `crypto_verify` parity), then render the badge + JSON-LD only on a `render` verdict.
63
83
 
64
84
  ```tsx
65
- import { getVerifiedEnvelope, staticKidResolver, CertReview } from '@certrev/cert-block'
85
+ import { getVerifiedEnvelope, fetchingKidResolver, CertReview } from '@certrev/cert-block'
66
86
 
67
- const resolveKid = staticKidResolver({ 'certrev-2026-1': process.env.CERTREV_PUBKEY_PEM! })
87
+ // Reach for this one first: it reads CertREV's published key set, so a key rotation resolves
88
+ // without a redeploy. One fetch populates every kid; concurrent misses single-flight; the set
89
+ // is cached for an hour by default (`ttlMs`) and the fetch itself has a 3s deadline
90
+ // (`timeoutMs`), because this runs on the render path.
91
+ const resolveKid = fetchingKidResolver({ jwksUrl: 'https://certrev.com/.well-known/jwks.json' })
68
92
 
69
93
  // In a Hydrogen loader / Next server component:
70
94
  const verdict = await getVerifiedEnvelope({
@@ -78,6 +102,32 @@ const verdict = await getVerifiedEnvelope({
78
102
  return <CertReview verdict={verdict} pageUrl={canonicalUrl} />
79
103
  ```
80
104
 
105
+ ### Picking a key resolver
106
+
107
+ Two resolvers ship, and the difference matters at rotation time:
108
+
109
+ - **`fetchingKidResolver({ jwksUrl, ttlMs?, timeoutMs?, fetchImpl? })`**: fetches CertREV's
110
+ published key set and caches it. Right for anything long-running: a rotated kid resolves on
111
+ the next cache miss with no redeploy. Costs one cross-origin fetch per TTL window.
112
+ - **`staticKidResolver({ [kid]: pem })`**: a key set baked into the deploy. Zero network at
113
+ render time, which is why it is right for an edge/Workers runtime that must not make an
114
+ outbound call, or for a build whose key you pin deliberately. The cost is that a rotation
115
+ needs a redeploy. Today CertREV publishes exactly one key, `cert-issuer-1`:
116
+
117
+ ```ts
118
+ import { staticKidResolver } from '@certrev/cert-block'
119
+ const resolveKid = staticKidResolver({ 'cert-issuer-1': process.env.CERTREV_PUBKEY_PEM! })
120
+ ```
121
+
122
+ Get the PEM from `https://certrev.com/.well-known/jwks.json` (the `x` value is the base64url
123
+ raw Ed25519 key; the resolver also accepts a JWK directly).
124
+
125
+ **When the kid is wrong the failure is silent.** A resolver that returns `null` for the kid on
126
+ the envelope (a typo'd key name, a stale bake after a rotation) produces
127
+ `{ decision: 'suppress', reason: 'unknown_key' }`, and `<CertReview>` renders nothing. This
128
+ package writes nothing to the console on any path, so `verdict.reason` is the only signal you
129
+ get. Log it while integrating.
130
+
81
131
  For finer control, render the pieces independently from `verdict.payload`:
82
132
 
83
133
  ```tsx
@@ -91,7 +141,36 @@ For finer control, render the pieces independently from `verdict.payload`:
91
141
  )}
92
142
  ```
93
143
 
94
- ## Usage — JSON-LD merge (don't collide with Yoast)
144
+ ### Caching + deadlines
145
+
146
+ `getVerifiedEnvelope` is safe to call on every render. What bounds the origin load:
147
+
148
+ - **A process-local `TtlCache`.** Default: 60s for a `render` verdict, 5s for a `suppress`, 1000
149
+ entries, and single-flight. N concurrent renders of the same page do one fetch, not N.
150
+ - **`ttlMs`** raises or lowers the TTL for a `render` verdict written by that call. It
151
+ deliberately does **not** apply to a `suppress`, which keeps the short negative TTL, so a
152
+ longer `ttlMs` can never make a transient origin failure sticky.
153
+ - **`timeoutMs`** (default 3s) is the deadline on the Delivery-API fetch. On expiry the request
154
+ is aborted and the call fails closed; a hung origin never holds a render open. The metafield
155
+ source does no I/O and ignores it. `fetchingKidResolver` carries the same deadline for its own
156
+ JWKS fetch.
157
+ - **`cache`** takes your own `TtlCache` instance instead of the module-level
158
+ `sharedVerdictCache`: the seam a test or a multi-tenant server uses to avoid one shared map.
159
+ - **`invalidateVerdict(source, context, cache?)`** drops every cached verdict for one placement,
160
+ whatever live content hash each was cached under. Call it from a revocation webhook: the
161
+ webhook knows the article, never the body hash a given render observed.
162
+
163
+ The cache key covers the source URL (or a hash of the metafield value), the platform/externalId,
164
+ and the live content hash. So staging and production do not share an entry, and a verdict
165
+ computed before the article drifted is not served after it.
166
+
167
+ This cache is **per instance, and nothing more**. On a multi-instance deploy each instance keeps
168
+ its own copy. A shared L2 (a CDN in front of the Delivery API, cacheable on
169
+ `lifecycle.revision`, or a KV store) is the caller's to add, and this package has no hook for
170
+ one. If you do add one, bound it: a positive verdict held past a revocation is exactly the
171
+ failure the verify layer exists to prevent.
172
+
173
+ ## Usage: JSON-LD merge (don't collide with Yoast)
95
174
 
96
175
  The Article node carries the SAME `@id` the host page's primary Article uses (`{pageUrl}#article`, query + fragment stripped), so a consumer **merges** our `reviewedBy` / `dateModified` into the existing node instead of emitting a competing primary entity. Pass `wrapGraph: false` to get a bare node array to splice into a `@graph` you already own.
97
176
 
@@ -100,9 +179,29 @@ const graph = projectCertJsonLd(payload, { pageUrl: canonicalUrl }) //
100
179
  const nodes = projectCertJsonLd(payload, { pageUrl: canonicalUrl, wrapGraph: false }) // bare node[]
101
180
  ```
102
181
 
103
- ## Usage — universal embed (Web Component)
182
+ Three `@id` conventions the merge depends on. Any surface emitting a CertREV graph (this package,
183
+ Portal, the WordPress plugin, the Liquid extension) has to agree on all three, or the halves land
184
+ as sibling nodes instead of merging:
104
185
 
105
- Preferred mode is **SSR + hydrate** (crawlable): a server-side include emits the badge HTML (via `renderBadgeHtml`) inside the element; the component leaves the server-rendered child alone. Client-fetch mode (not crawlable) is a documented fallback and is **fail-closed** — it renders nothing without a configured key resolver.
186
+ - **WebPage** → the canonical page URL, query and fragment stripped.
187
+ - **Article** → that same URL + `#article` (the Yoast convention). A page reached with a `?utm=…`
188
+ param must still derive the canonical `@id`, which is why the query is stripped.
189
+ - **The CertREV `Organization`** → the hard literal `https://certrev.com/#certrev-organization`,
190
+ never derived from whichever host served the verify link. CertREV is one real-world
191
+ organization; a staging origin, a vanity verify domain or a future regional host must not mint a
192
+ second org node that never merges with the first. Emit the literal.
193
+
194
+ Our own nodes (`Review`, the expert `Person`, their credentials) are keyed to the certification:
195
+ the verify URL, the expert profile, that fixed organization id. They never sit under the host
196
+ page's `@id` namespace, so they cannot clash with anything the host emits.
197
+
198
+ Pass `renderDef` alongside `pageUrl` when the brand hides fields on the card: the projector gates
199
+ the memo, the author byline and the reviewer photo through the same `resolveDisplay` the card
200
+ uses, so a hidden field is absent from the graph too.
201
+
202
+ ## Usage: universal embed (Web Component)
203
+
204
+ Preferred mode is **SSR + hydrate** (crawlable): a server-side include emits the badge HTML (via `renderBadgeHtml`) inside the element; the component leaves the server-rendered child alone. Client-fetch mode (not crawlable) is a documented fallback and is **fail-closed**; it renders nothing without a configured key resolver.
106
205
 
107
206
  ```html
108
207
  <!-- SSR: server emits the badge markup inside the tag; crawler reads it -->
@@ -115,21 +214,27 @@ Preferred mode is **SSR + hydrate** (crawlable): a server-side include emits the
115
214
  </script>
116
215
  ```
117
216
 
118
- ## Usage — Builder.io (the cert component)
217
+ ## Usage: Builder.io (the cert component)
119
218
 
120
219
  Two ways to place the visible cert on a Builder.io page. **PUSH is the primary story** for
121
220
  exporter-driven brands (the cert block arrives with the CertREV-drafted entry); the PULL anchor
122
- is the editor-placed alternative (W4, POR-10721).
221
+ is the editor-placed alternative.
222
+
223
+ **They do not have the same trust model, and that should drive the choice more than layout does.**
224
+ PULL renders from a verdict your own loader verified and passed down through `CertRevProvider`:
225
+ the editor picks the spot, the system picks the credential, and a `suppress` verdict renders
226
+ nothing. PUSH renders from strings the exporter wrote into the CMS entry, and verifies nothing.
227
+ Read [PUSH trust model](#push-trust-model) before choosing it.
123
228
 
124
- ### PUSH — the registered cert block (recommended)
229
+ ### PUSH: the registered cert block
125
230
 
126
231
  CertREV's exporter emits a cert block whose options are the `BuilderCertChromeData` projection.
127
- Register the ready component once, from the `./builder` subpath — one import, one entry in the
232
+ Register the ready component once, from the `./builder` subpath: one import, one entry in the
128
233
  `customComponents` array your app already passes to the gen2 SDK's `<Content>` (the same array
129
234
  your other custom components render from). No hand-mapped options, no hand-maintained inputs:
130
235
 
131
236
  ```tsx
132
- // gen2 SDKs (@builder.io/sdk-react and friends — Hydrogen/Remix, Next, Gatsby)
237
+ // gen2 SDKs (@builder.io/sdk-react and friends: Hydrogen/Remix, Next, Gatsby)
133
238
  import { certRevCertComponent } from '@certrev/cert-block/builder'
134
239
 
135
240
  <Content
@@ -141,7 +246,7 @@ import { certRevCertComponent } from '@certrev/cert-block/builder'
141
246
  ```
142
247
 
143
248
  On `0.5.2` and earlier (before `certRevCertComponent` existed), build the entry from the
144
- canonical readonly view — the spread sheds the `readonly` typing, which the gen2 SDK's mutable
249
+ canonical readonly view; the spread sheds the `readonly` typing, which the gen2 SDK's mutable
145
250
  `inputs[]` type won't accept directly:
146
251
 
147
252
  ```ts
@@ -159,18 +264,64 @@ Gen1 SDK (`@builder.io/react`) consumers register the same data imperatively:
159
264
 
160
265
  `CERT_COMPONENT_NAME` (`"CertREV Cert"`) is the canonical block name the portal's
161
266
  `certComponent` connection field defaults to; the inputs are single-sourced from the
162
- `BuilderCertChromeData` wire type (drift-locked — the exporter option keys, the registered
267
+ `BuilderCertChromeData` wire type (drift-locked: the exporter option keys, the registered
163
268
  inputs, and the type can never diverge silently). The 19 exporter-populated wire inputs are
164
269
  marked `advanced`, so the editor's Options tab leads with the two placement choices an editor
165
270
  actually makes (`mode`, `part`). The component enforces the fused-credential rule by
166
271
  construction (a credential is unrenderable without its dated verification) and themes to the
167
272
  brand's render-def (accent / surface / corners / font + v2 bar-ink / body-ink).
168
273
 
274
+ ### PUSH trust model
275
+
276
+ Read this before choosing PUSH. The registered card is **chrome, not a verifier**, and it is a
277
+ different trust model from the headless-React path above.
278
+
279
+ **Nothing cryptographic runs at card render.** The card draws entirely from the block options the
280
+ CertREV exporter wrote into the CMS entry: display strings that were projected from the signed
281
+ envelope at export time. Its whole guard is that `reviewerName` and `verifyUrl` are non-empty
282
+ strings; anything malformed degrades to the name-only face. There is no signature check, no
283
+ subject check, and `revokedAt` / `expiresAt` / `contentDigest` are not fields on
284
+ `BuilderCertChromeData` at all; they are not on the wire, so the card could not check them.
285
+
286
+ **What follows from that:** anyone who can write to the CMS entry can render a fully-branded cert
287
+ card, because to the card a hand-typed option and an exporter-written option are the same string.
288
+ The trust boundary on this path is **write access to the Builder space**, not a signature.
289
+ Treat the space's editor permissions and the exporter's API key accordingly. If you need the
290
+ credential itself verified at render, use the headless-React path (`getVerifiedEnvelope` +
291
+ `<CertReview>`), where a bad signature, wrong subject, revocation, expiry or content drift each
292
+ collapse to `suppress`.
293
+
294
+ **Revocation, precisely.** A CMS snapshot cannot self-invalidate: a revocation upstream does not
295
+ reach the entry, and the card keeps rendering the chrome until something rewrites or removes that
296
+ entry. One narrower thing does happen, and only this. When a visitor **clicks** a
297
+ `data-certrev-modal-open` control (the "View certificate" / reviewer links the card emits) on a
298
+ page that loaded the modal script and carries `data-delivery-api` + `data-platform` +
299
+ `data-external-id`, the modal fetches the Delivery API live. If that returns a revocation
300
+ tombstone, or an envelope with `revokedAt` set or `expiresAt` passed, the modal refuses to open
301
+ and calls `markSuppressed()`: it stamps `data-certrev-suppressed` on itself and sets `hidden` +
302
+ `aria-hidden` on **every** `[data-certrev-modal-open]` control in the document. So the certificate
303
+ links disappear on that page view. The card body (the reviewer's name, the credential, the memo,
304
+ the "Expert reviewed" chrome) is not touched, and nothing happens at all for a visitor who never
305
+ clicks, for a page without the modal script, or for a crawler. One more way to lose even that: the
306
+ modal prefers a synchronous envelope source, so if the page inlines one (a light-DOM
307
+ `<script data-certrev-envelope>` child or a `data-envelope` attribute) it opens from that snapshot
308
+ and never fetches. Leave the modal on the routing attributes if you want the live check.
309
+
310
+ **How a revoked cert actually stops rendering on this path is a CMS-side question**: who rewrites
311
+ or unpublishes the entry, and how fast. This package does not implement it and does not assume it.
312
+
313
+ **The card emits no structured data.** By design: a drag-droppable page-builder block must not own
314
+ JSON-LD, or an editor can duplicate it, move it out of `<head>`, or delete it. So the card renders
315
+ chrome only, and the **article route** emits the JSON-LD server-side via `projectCertJsonLd` from
316
+ the verified payload. See [The article route example](#the-article-route-example). It cannot come
317
+ from the block options: `projectCertJsonLd` takes a `CertPayload`, and the CMS hands the card
318
+ display strings.
319
+
169
320
  ### The certificate modal + CSP
170
321
 
171
322
  The "View certificate" affordance opens an envelope-verified modal. The browser bundle **ships
172
323
  in-package**: a prebuilt, dependency-free, minified IIFE at `@certrev/cert-block/modal/certrev-cert.js`.
173
- Serve it as an external `<script>` — do **not** side-effect-import it into server code (it
324
+ Serve it as an external `<script>`. Do **not** side-effect-import it into server code (it
174
325
  registers `customElements` and touches `window`; keeping it an external script keeps it out of
175
326
  the SSR bundle entirely):
176
327
 
@@ -187,50 +338,225 @@ import certScriptUrl from '@certrev/cert-block/modal/certrev-cert.js?url'
187
338
  <script src={certScriptUrl} defer />
188
339
  ```
189
340
 
190
- Two CSP allowances for the headless host — the modal is the ONLY part that needs any (the card
191
- renders zero external assets: no fonts fetched, no images, inline-SVG logo):
341
+ CSP allowances for the headless host. The **registered card** needs none; it fetches no font,
342
+ loads no image (the avatar is rendered initials) and inlines its logo as SVG. These are what the
343
+ **modal** needs:
192
344
 
193
345
  - `connect-src` → the CertREV Delivery origin the element fetches the signed envelope from
194
346
  (production `https://portal.certrev.com`; staging `https://staging.portal.certrev.com`).
195
- - `img-src` → `https://cdn.builder.io` + whatever origin the envelope's expert-headshot
196
- `photoUrl` points at (CertREV portal assets in production).
197
-
198
- `data-delivery-api` is the **origin only** — the element appends
199
- `/api/cert/v1/delivery/{platform}/{externalId}`. Preview drafts with `includeUnpublished` on the
200
- public key.
201
-
202
- ### PULL — the editor-placed anchor (alternative)
347
+ - `img-src` → `https://assets.certrev.com` (where CertREV headshots live, and where the modal's
348
+ `/cdn-cgi/image/` display-sized derivative is served from) plus any other origin the envelope's
349
+ `photoUrl` points at. `<CertBadge>` and `renderBadgeHtml` need the same allowance on the
350
+ headless-React and universal-embed paths, since both emit the expert photo as an `<img>`.
351
+ - `font-src` → `https://assets.certrev.com`, unless you set `data-fonts="host"`. This one is easy
352
+ to miss: Hydrogen's default `font-src` falls back to `default-src`, which does not include that
353
+ host, so the modal's font faces fail to load and it renders in the fallback stack.
354
+
355
+ **`data-fonts` is the switch for that last one.** On first open the modal injects a `<style>` into
356
+ `document.head` declaring the five self-hosted families it uses: DM Sans, Playfair Display,
357
+ JetBrains Mono, Plus Jakarta Sans and Allura (12 `@font-face` rules, all pointing at
358
+ `https://assets.certrev.com/fonts/`), plus a crossorigin `preconnect` to that origin. Font faces
359
+ do not scope into a shadow root, which is why the injection is at document level. Set
360
+ `data-fonts="host"` to suppress it entirely; then the host page must self-host those five families
361
+ itself, and the modal adds no `font-src` origin to its CSP. Omitting the attribute is the default
362
+ and is fine; it just means the origin has to be in `font-src`.
363
+
364
+ `data-delivery-api` is the **origin only**: the element appends
365
+ `/api/cert/v1/delivery/{platform}/{externalId}`.
366
+
367
+ ### PULL: the editor-placed anchor (alternative)
203
368
 
204
369
  `CertRevProvider` + the zero-input `CertRevAnchor` (`@certrev/cert-block/builder`) let an editor
205
- drop the cert wherever they want it (panel-validated). Use this when placement is editorial
206
- rather than exporter-driven. *(The anchor is preset-default only today — render-def theming on
370
+ drop the cert wherever they want it. Use this when placement is editorial
371
+ rather than exporter-driven. *(The anchor is preset-default only today; render-def theming on
207
372
  the anchor path is a follow-up.)*
208
373
 
209
- ## Fail-closed, everywhere
374
+ ## The article route example
210
375
 
211
- The package never renders an unverified credential. Every error path — bad signature, unknown kid, wrong post, revoked, expired, content drift, network failure, malformed JSON, a throwing resolver — collapses to a `suppress` verdict (or a `null` render), never an exception that a caller could swallow into a render. `<CertReview>` and `<CertRevBacklink>` also fail closed at the component boundary (suppress / unsafe URL → render nothing). URLs pass through `safeHttpUrl` (drops `javascript:`/`data:`); accent colors through `safeCssColor`; the JSON-LD body neutralizes `<`/`>` so a hostile memo can't break out of the `<script>` tag.
376
+ The route is where the two halves meet: `<Content>` renders the CMS-driven card, and the route
377
+ itself (server-side, from the verified envelope) emits the JSON-LD the card deliberately does
378
+ not own. This is the example `src/builder/index.tsx` refers to.
379
+
380
+ **Hydrogen / Remix** (`app/routes/blog.$handle.tsx`):
381
+
382
+ ```tsx
383
+ import { useLoaderData } from '@remix-run/react'
384
+ import { Content, fetchOneEntry, isPreviewing } from '@builder.io/sdk-react'
385
+ import { certRevCertComponent } from '@certrev/cert-block/builder'
386
+ import { CertJsonLd, fetchingKidResolver, getVerifiedEnvelope } from '@certrev/cert-block'
387
+ import certScriptUrl from '@certrev/cert-block/modal/certrev-cert.js?url'
388
+
389
+ const MODEL = 'blog-article'
390
+ const resolveKid = fetchingKidResolver({ jwksUrl: 'https://certrev.com/.well-known/jwks.json' })
391
+
392
+ export async function loader({ params, request, context }) {
393
+ const urlPath = new URL(request.url).pathname
394
+ const apiKey = context.env.BUILDER_PUBLIC_API_KEY
395
+
396
+ const content = await fetchOneEntry({ model: MODEL, apiKey, userAttributes: { urlPath } })
397
+ if (!content) throw new Response('Not found', { status: 404 })
398
+
399
+ // The externalId the cert was issued against: the Builder entry id for a Builder placement.
400
+ const externalId = content.id
401
+ const canonicalUrl = new URL(urlPath, 'https://brand.example.com').toString()
402
+
403
+ // Verify per request. Do NOT long-cache this HTML: a revoked credential sitting in a stale
404
+ // edge cache defeats fail-closed. The in-process TTL cache below already bounds origin load.
405
+ const verdict = await getVerifiedEnvelope({
406
+ source: {
407
+ kind: 'delivery_api',
408
+ baseUrl: 'https://portal.certrev.com',
409
+ platform: 'builder',
410
+ externalId,
411
+ },
412
+ resolveKid,
413
+ context: { platform: 'builder', externalId },
414
+ })
415
+
416
+ return { content, apiKey, externalId, canonicalUrl, verdict }
417
+ }
418
+
419
+ export default function ArticleRoute() {
420
+ const { content, apiKey, externalId, canonicalUrl, verdict } = useLoaderData<typeof loader>()
421
+
422
+ return (
423
+ <>
424
+ {/* The STRUCTURED DATA: route-owned, from the verified payload. The card emits none. */}
425
+ {verdict.decision === 'render' && (
426
+ <CertJsonLd payload={verdict.payload} pageUrl={canonicalUrl} />
427
+ )}
428
+
429
+ {/* The VISIBLE CARD: placed by the CMS, rendered from the exporter's block options. */}
430
+ <Content
431
+ model={MODEL}
432
+ content={content}
433
+ apiKey={apiKey}
434
+ customComponents={[certRevCertComponent]}
435
+ />
436
+
437
+ {/* The certificate modal the card's "View certificate" link opens. */}
438
+ <certrev-cert-modal
439
+ data-delivery-api="https://portal.certrev.com"
440
+ data-platform="builder"
441
+ data-external-id={externalId}
442
+ />
443
+ <script src={certScriptUrl} defer />
444
+ </>
445
+ )
446
+ }
447
+ ```
448
+
449
+ The **Next.js App Router** shape is the same two halves in one async server component:
450
+
451
+ ```tsx
452
+ import { Content, fetchOneEntry } from '@builder.io/sdk-react'
453
+ import { certRevCertComponent } from '@certrev/cert-block/builder'
454
+ import { CertJsonLd, fetchingKidResolver, getVerifiedEnvelope } from '@certrev/cert-block'
455
+
456
+ const resolveKid = fetchingKidResolver({ jwksUrl: 'https://certrev.com/.well-known/jwks.json' })
457
+
458
+ export default async function Page({ params }: { params: { slug: string[] } }) {
459
+ const urlPath = `/${params.slug.join('/')}`
460
+ const apiKey = process.env.NEXT_PUBLIC_BUILDER_API_KEY!
461
+ const content = await fetchOneEntry({ model: 'blog-article', apiKey, userAttributes: { urlPath } })
462
+ if (!content) return null
463
+
464
+ const verdict = await getVerifiedEnvelope({
465
+ source: {
466
+ kind: 'delivery_api',
467
+ baseUrl: 'https://portal.certrev.com',
468
+ platform: 'builder',
469
+ externalId: content.id,
470
+ },
471
+ resolveKid,
472
+ context: { platform: 'builder', externalId: content.id },
473
+ })
474
+
475
+ return (
476
+ <>
477
+ {verdict.decision === 'render' && (
478
+ <CertJsonLd payload={verdict.payload} pageUrl={`https://brand.example.com${urlPath}`} />
479
+ )}
480
+ <Content
481
+ model="blog-article"
482
+ content={content}
483
+ apiKey={apiKey}
484
+ customComponents={[certRevCertComponent]}
485
+ />
486
+ </>
487
+ )
488
+ }
489
+ ```
490
+
491
+ Four things about this shape that are easy to get wrong:
492
+
493
+ - **`<CertJsonLd>` is the route's, not the block's.** It wraps `projectCertJsonLd` +
494
+ `serializeJsonLdForScript`; call those two directly if you are not rendering through React. Put
495
+ it where your framework hoists `<script>` into `<head>` (Next: a layout/head; Hydrogen: React 19
496
+ hoists it).
497
+ - **The two halves can disagree, and the failure is one-directional.** The card comes from the CMS
498
+ and the JSON-LD comes from the verify layer, so on a `suppress` verdict the JSON-LD is correctly
499
+ omitted while the card **still renders**. See [PUSH trust model](#push-trust-model). Only the
500
+ structured data is fail-closed here.
501
+ - **Verify per request.** Treat the loader as a dynamic edge subrequest and do not long-cache the
502
+ rendered HTML. `getVerifiedEnvelope`'s own TTL + single-flight cache is what keeps origin load
503
+ bounded; see [Caching + deadlines](#caching--deadlines).
504
+ - **`externalId` must be the id the cert was issued against.** For a Builder placement that is the
505
+ entry id the portal recorded at publish. A mismatch is not a soft failure: the kernel returns
506
+ `suppress` with `reason: 'subject_mismatch'`, and nothing is logged.
507
+
508
+ ## Fail-closed, where the verifying happens
509
+
510
+ **The verify layer never yields a credential it could not verify.** Every error path through
511
+ `getVerifiedEnvelope` / the `VerdictKernel` (bad signature, unknown kid, wrong post, revoked,
512
+ expired, content drift, network failure, fetch deadline, malformed JSON, a throwing resolver)
513
+ collapses to a `suppress` verdict, never an exception that a caller could swallow into a render.
514
+ `<CertReview>` and `<CertRevBacklink>` fail closed at the component boundary too (suppress /
515
+ unsafe URL → render nothing). The verify path is the one to reach for when the guarantee has to
516
+ be cryptographic.
517
+
518
+ **Scope it deliberately: this is a property of the verify layer, not of every entry point.** The
519
+ registered Builder card takes CMS block options, not an envelope, so it verifies nothing. See
520
+ [PUSH trust model](#push-trust-model). `renderCertBlock` and `renderBadgeHtml` are string
521
+ renderers over facts a caller supplies; whether those facts were verified is the caller's
522
+ business. A component that never receives a signature cannot check one, and this section used to
523
+ read as though it did.
524
+
525
+ What IS true everywhere, including those paths: nothing here throws into a render, and every
526
+ field is escaped. URLs pass through `safeHttpUrl` (drops `javascript:`/`data:`); accent colors
527
+ through `safeCssColor`; a value of the wrong type degrades to an empty render instead of crashing
528
+ the route; the JSON-LD body neutralizes `<`/`>` so a hostile memo can't break out of the
529
+ `<script>` tag; and every anchor the package emits carries `CERTREV_LINK_REL`
530
+ (`nofollow sponsored noopener`).
212
531
 
213
532
  ## Server-safe guarantee
214
533
 
215
- - The main entry never touches `customElements` / `window` — the Web Component ships from the `./webcomponent` subpath so importing `@certrev/cert-block` in an RSC/Node loader is side-effect-free. Registration only happens when you call `defineCertRevBadge()` in a browser.
216
- - The React components are render-pure (verified by `react-dom/server` SSR tests) so they work as Server Components and as client components that SSR identically — same crawlable output either way.
534
+ - The main entry never touches `customElements` / `window`; the Web Component ships from the `./webcomponent` subpath so importing `@certrev/cert-block` in an RSC/Node loader is side-effect-free. Registration only happens when you call `defineCertRevBadge()` in a browser.
535
+ - The React components are render-pure (verified by `react-dom/server` SSR tests) so they work as Server Components and as client components that SSR identically: same crawlable output either way.
217
536
  - Date formatting is deterministic UTC (fixed English month abbreviations, not `toLocaleDateString`) so SSR output is byte-stable and never causes a hydration mismatch.
218
537
 
219
538
  ## The contract dependency
220
539
 
221
540
  The shared contract types + the fail-closed `VerdictKernel` live in
222
541
  [`@certrev/cert-contract`](https://www.npmjs.com/package/@certrev/cert-contract) (published, MIT).
223
- Since `0.5.3` it is a **regular dependency** — `npm i @certrev/cert-block` brings it along; a
542
+ Since `0.5.3` it is a **regular dependency**: `npm i @certrev/cert-block` brings it along; a
224
543
  brand integration never imports it directly. Its one runtime dependency is `canonicalize` (the
225
544
  RFC-8785 JCS implementation used for signature verification). To be clear about that word:
226
- **nothing cryptographic runs at card render** — the cert card draws purely from block options;
545
+ **nothing cryptographic runs at card render**. The cert card draws purely from block options;
227
546
  verification enters only with the modal / the `getVerifiedEnvelope` verify layer.
228
547
 
229
- React is the one **peer** dependency left — **`react >=18.0.0`**, declared non-optional, so a
548
+ React is the one **peer** dependency left: **`react >=18.0.0`**, declared non-optional, so a
230
549
  peer-enforcing resolver (npm 7+) fails the install on React 17 with `ERESOLVE` rather than
231
550
  warning. Only the React entries need it: `./webcomponent` and `./modal` build React-free (no
232
- `react/jsx-runtime`) and there is no `react-dom` peer. CI builds and runs the SSR tests on
233
- React 18.3; the range admits 19.
551
+ `react/jsx-runtime`) and there is no `react-dom` peer.
552
+
553
+ **What CI actually proves about that range: React 18.3 only.** One job, Node 20, installing the
554
+ `react@^18.3.1` devDependency: it builds, typechecks and runs the whole suite including the
555
+ `react-dom/server` SSR tests, but there is no version matrix and no React 19 job. `>=18.0.0` is
556
+ the range `package.json` declares, not a range CI exercises. Nothing in the package imports a
557
+ React-19-only API (the package's only `react` import is `createContext` / `useContext` in the
558
+ PULL anchor, and every other component is render-pure with no hooks), so 19 is expected to work.
559
+ That is expectation, not evidence; if you run 19 you are the first to find out.
234
560
 
235
561
  ## Known issues (upstream)
236
562
 
@@ -244,25 +570,60 @@ React 18.3; the range admits 19.
244
570
  - Brand-owned Hydrogen / Next / Remix storefronts (`headless_react`), Builder.io spaces
245
571
  (`headless_visual_cms`), and any site dropping in `<certrev-badge>` (`universal_embed`).
246
572
 
573
+ ## Current consumers
574
+
575
+ | Consumer | Status |
576
+ |---|---|
577
+ | Portal | **Not importing yet.** Portal is where the vendored halves came FROM, and it still holds its own copies: `BuilderCertChromeData` is duplicated on the Portal side, and `src/modal/font-face.ts` + `src/modal/avatar-resize.ts` here are byte-faithful copies of Portal's. Portal also owns the exporter that writes the block options, and its `certComponent` connection field defaults to `CERT_COMPONENT_NAME`. The migration (Portal importing this package instead of hand-syncing) is what the package headers call "gated on publishing". Ping on any change to `BuilderCertChromeData`, `renderCertBlock`, the modal bundle, or `CERT_ISSUER_KID` / `CERT_ISSUER_PUBLIC_KEY_PEM`. |
578
+ | Brand headless storefronts | **Live, via npmjs, and NOT enumerable from this repo.** `@certrev/cert-block` publishes publicly, so anyone can `npm i` it. Nothing in this repo records which brands have. |
579
+ | AgOS | None. No AgOS-side import is referenced anywhere in this repo. |
580
+ | This monorepo | None. No other package here declares a dependency on `@certrev/cert-block` (it depends on `@certrev/cert-contract`, not the reverse). |
581
+
582
+ **The edge this list admits: the public npm consumers are unknown to it.** A version bump here can
583
+ reach a brand storefront nobody in this repo can name, which is the difference between this package
584
+ and the `file:`-dep packages alongside it. Treat the CHANGELOG's semver contract (patch and minor
585
+ never change the rendered cert output and never break a compiling integration) as the actual
586
+ notification channel, because for those consumers it is the only one.
587
+
247
588
  ## Tests
248
589
 
249
- `pnpm test` (Vitest). Coverage:
590
+ `pnpm test` (Vitest) runs all 17 test files. They sit next to what they cover: `src/__tests__/`
591
+ for the cross-cutting suites, `src/builder/__tests__/` for the Builder kit, and `src/modal/*.test.ts`
592
+ alongside each modal module. The load-bearing ones:
250
593
 
251
- - **`__tests__/verify.test.ts`** — the kernel via the SDK binding with **real Ed25519 over JCS** (freshly-signed fixture envelopes, no mocked crypto): render, tamper → `invalid_signature`, unknown kid, platform/subject mismatch, revoked, expired, content drift; `getVerifiedEnvelope` over metafield (object + JSON-string) and Delivery API sources, 404/410 fail-closed, and the **single-flight** thundering-herd guard (N concurrent renders → one fetch).
252
- - **`__tests__/project.test.ts`** — the JSON-LD projector: `@graph` shape, Article `@id` host-merge alignment (query + fragment stripped), expert-as-`reviewedBy`, credentials → `hasCredential` + `honorificSuffix`, CertREV-namespaced node `@id`s, determinism, and `</script>` / HTML-comment neutralization.
253
- - **`__tests__/components.test.tsx`** — every React component rendered through `react-dom/server` with mock facts: structure, accessibility (`aria-label`, heading levels), accent theming, display-flag honoring, hostile-input escaping, `javascript:`-URL dropping, and fail-closed (`suppress` verdict / unsafe URL → nothing).
254
- - **`__tests__/webcomponent.test.tsx`** — the shared `renderBadgeHtml` string renderer (hand-escaping, unsafe-URL/color dropping, compact style) and the `<certrev-badge>` custom element (idempotent registration, SSR light-DOM preservation, client-mode fail-closed without a resolver).
594
+ - **`__tests__/verify.test.ts`** exercises the kernel via the SDK binding with **real Ed25519 over JCS** (freshly-signed fixture envelopes, no mocked crypto): render, tamper → `invalid_signature`, unknown kid, platform/subject mismatch, revoked, expired, content drift; `getVerifiedEnvelope` over metafield (object + JSON-string) and Delivery API sources, 404/410 fail-closed, fetch deadlines (a hung origin and a hung JWKS endpoint both abort), cache-key identity (base URL, placement, live content hash), and the **single-flight** thundering-herd guard (N concurrent renders → one fetch).
595
+ - **`__tests__/project.test.ts`** covers the JSON-LD projector: `@graph` shape, WebPage / Article `@id` host-merge alignment (query + fragment stripped), expert-as-`reviewedBy`, credentials → `hasCredential` + `honorificSuffix`, CertREV-namespaced node `@id`s, visibility gating through `resolveDisplay`, determinism, and `</script>` / HTML-comment neutralization.
596
+ - **`__tests__/components.test.tsx`** renders every React component through `react-dom/server` with mock facts: structure, accessibility (`aria-label`, heading levels), accent theming, display-flag honoring, hostile-input escaping, `javascript:`-URL dropping, link qualification, and fail-closed (`suppress` verdict / unsafe URL → nothing).
597
+ - **`__tests__/render-cert-block.test.ts`** + **`__tests__/render-def.test.tsx`**: the locked 3-mode string renderer (placement default, banner `part` split, locked compliance copy) and render-def theming / subtractive visibility.
598
+ - **`__tests__/escape-family.test.ts`** puts both escape families (`components/` and `modal/`) against wrong-typed input, not just null: a half-filled CMS object must degrade, never throw.
599
+ - **`__tests__/webcomponent.test.tsx`**: the shared `renderBadgeHtml` string renderer (hand-escaping, unsafe-URL/color dropping, compact style) and the `<certrev-badge>` custom element (idempotent registration, SSR light-DOM preservation, client-mode fail-closed without a resolver).
600
+ - **`builder/__tests__/builder.test.tsx`** covers the PUSH card and its registration: the wire-type ⇄ inputs parity lock, the fused-credential gate, and the mistyped-option degradation.
601
+ - **`modal/*.test.ts`**, per module: the modal element (tombstone → `markSuppressed`), the dialog view, interactions, placement, badge revalidation, the FTC guard, and the Delivery-API verify client.
602
+ - **`__tests__/readme-version.test.ts`** + **`__tests__/readme-contract.test.ts`** pin the claims in this file that rot silently: the version stamp against `package.json`, the key id, the route-example heading, and every `exports` subpath appearing under Public API.
255
603
 
256
- Facts are mocked via `makeMockPayload` / `makeSignedEnvelope` (`src/contract/fixtures.ts`).
604
+ Facts are mocked via `makeMockPayload` / `makeSignedEnvelope` (`src/contract/fixtures.ts`, also
605
+ published at `@certrev/cert-block/fixtures`).
257
606
 
258
607
  ## Version
259
608
 
260
- `0.5.5` — see [CHANGELOG.md](./CHANGELOG.md) for the release history and the semver contract
609
+ `1.0.1`: see [CHANGELOG.md](./CHANGELOG.md) for the release history and the semver contract
261
610
  (patch/minor never change the rendered cert output or break a compiling integration). Publishes
262
611
  **publicly** to npm as `@certrev/cert-block` (`publishConfig.access: public`) via GitHub Actions
263
612
  trusted publishing (OIDC); the internal `@certrev` GitHub-Packages channel mirrors it.
264
613
 
265
- Source is public at [`CertREV/cert-kit`](https://github.com/CertREV/cert-kit) (MIT) — this
614
+ Source is public at [`CertREV/cert-kit`](https://github.com/CertREV/cert-kit) (MIT). This
266
615
  package is `packages/cert-block/`; report a vulnerability privately through that repo's
267
616
  `SECURITY.md`. The tarball also ships `src/` alongside `dist/`, so a security review reads the
268
617
  exact code for the version it installed.
618
+
619
+ **cert-kit is a published-source mirror, not the build repo, so don't go looking for the CI in
620
+ it.** It is a flat snapshot: source and config, one commit per release, no `.github/`. The
621
+ workflows referenced above (build, typecheck, test, biome; and the OIDC trusted publish) live in
622
+ CertREV's private release repo, which is what npm records as this package's `gitHead`. Two
623
+ consequences worth naming rather than leaving a reviewer to discover: those `gitHead` commits 404
624
+ against the public mirror, and the publishes carry **no provenance attestation**: npm generates
625
+ provenance under trusted publishing only when the OIDC claim says the source repo is public, and
626
+ this one is private. Moving the publish job into the mirror is the only real fix; adding
627
+ `--provenance` to a private-repo publish changes nothing. What you CAN check today is the code
628
+ itself: the mirror is gated to rebuild every published `dist/` file byte for byte from the `src/`
629
+ in the same tag.