@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
package/CHANGELOG.md CHANGED
@@ -5,6 +5,241 @@ change the rendered cert output and never break a compiling integration** (regis
5
5
  exports, types). Anything that would alter what a visitor sees, or require integration changes,
6
6
  is announced loudly here first.
7
7
 
8
+ ## 2.0.1 · 2026-10-25
9
+
10
+ **e.l.f.'s one install.** A drop-in on 2.0.0: same package, same `certRevCertComponent` and registration (`CertREV
11
+ Cert`, the same 22 inputs), no new prop, no new peer dependency, and the same four Content-Security-Policy sources (the
12
+ beacon below goes to the delivery origin `connect-src` already allows). Everything that can change later comes from
13
+ CertREV's server.
14
+
15
+ - **Nothing, never unsigned facts.** Every path that painted the block's export-time options (a reviewer name, a
16
+ credential, a memo, a verify link nobody signed) now prints **nothing** and reports why: a block with no delivery
17
+ marker (`no_marker`), a marker this client does not accept or a block with no Builder entry (`marker_invalid`),
18
+ CertREV not reached with no verified answer inside the stale window (`delivery_unreachable`, also for a page whose
19
+ 2.0.0 server wrote an `'options'` record), and a client mount with `clientRead={false}` and no server answer
20
+ (`client_read_off`). The signed paths are unchanged: a contract-1 card prints CertREV's document; an envelope with
21
+ no `contract` key paints the 1.3.x face from its SIGNED facts, as 2.0.0 did. The options-face renderer is gone; the
22
+ inputs stay registered (an entry that carries them still loads) and are not read.
23
+ - **A stale window inside the revocation SLA.** A verified answer is fresh for 30 s and may still paint for 30 s more
24
+ while it is refreshed or CertREV is down (2.0.0: 60 s and 300 s), so the card adds at most a minute to the Delivery
25
+ API's own cache and a revoked card is gone within ADR 0001 §7's five to six minutes (2.0.0 stacked to about twelve).
26
+ Past the window a read that cannot reach CertREV prints nothing; stale is never served indefinitely.
27
+ - **A beacon to CertREV** (`card-beacon.ts`). Each fallback, and one health event on one page view in a hundred, is a
28
+ `POST <delivery origin>/api/cert/v1/beacon` with a `text/plain` JSON body: `{v:1, client:'cert-block',
29
+ clientVersion, contract, platform:'builder', externalId, family, events:[{kind, reason, outcome}]}`. No cookies
30
+ (`credentials: 'omit'`), no page address or text, no user agent beyond a family (chromium, webkit, gecko, other,
31
+ server). At most three per page in the browser (`fetch`, `no-cors`, `keepalive`); on a server, each placement and
32
+ reason at most once an hour per process, with a 1.5 s deadline, never awaited. A beacon that fails is dropped; it
33
+ never changes what prints. The same shape serves the WordPress plugin 2.0.1 and the hosted runtime.
34
+ - **Forward compatible.** Unknown parts, unknown fields and new keys in known objects (how CertREV ships a minor change
35
+ under contract 1) are ignored and the card prints as before; a newer major (`contract` 2 or later in the signed
36
+ binding) prints nothing and reports `card_unsupported_contract` (beacon outcome `unsupported`); the page around it
37
+ always renders.
38
+ - **Placement hints** (`card-placement.ts`). After the page has loaded, the block moves each banner part to the place
39
+ `card.placement` names among the Builder entry's top-level blocks: `content-start` (after a leading image when
40
+ `after: 'leading-media'`), `after-byline` (an element marked `data-certrev-anchor="byline"`, else the first
41
+ `[rel~="author"]`, `[itemprop="author"]`, `.byline`, `[class*="byline"]` or `address`), `before-references` (else
42
+ `content-end`), `content-end`. An unknown anchor, `pinned`, or an anchor the page does not have leaves the part where
43
+ the editor put it; with JavaScript off every part is where the editor put it, and in Builder's visual editor and
44
+ preview nothing moves. A sidebar's rail and a floating pill never move. So CertREV can put the reviewer right after the byline and the memo at the end of the article with no
45
+ install.
46
+ - **The page's CertREV graph.** When the document carries `jsonld.graph` (a complete JSON-LD object CertREV's server
47
+ resolved for the placement: the Certification, its reviewer and CertREV, and the page's WebPage and Article; an
48
+ additive key under contract 1), the block that carries the page's assets prints it verbatim, once per page, as
49
+ `<script type="application/ld+json" data-certrev-graph data-certrev-external-id="<entry>">` (every `<` written as
50
+ `\u003c`). A document without it prints no graph. The block never builds or edits the graph; schema changes are the
51
+ server's. (1.x's rule that a page-builder block owns no structured data held while the block had to build the graph
52
+ itself; now it only prints the server's.)
53
+ - Dialogs are the document's own (`card.dialogs`): nothing in the package draws, styles or names them, so a redesign
54
+ of CertREV's profile and certificate dialogs needs no client change.
55
+ - **Nothing in the console.** Through 2.0.0, React's development build reported a hydration mismatch on every page with
56
+ a contract-1 card (the browser had already attached the card's shadow roots, which React expected as templates), and
57
+ in production every card React wrote itself (an in-app navigation, a re-render after the page's own hydration error)
58
+ drew Chrome's "Found declarative shadowrootmode attribute on a template, but declarative Shadow DOM is not being
59
+ parsed" warning once per template. The wrapper now skips React's hydration comparison (`suppressHydrationWarning`,
60
+ never printed), and in a browser that parses declarative roots the HTML React writes holds each template's mode
61
+ under `data-certrev-shadowrootmode` until the block re-parses the card's exact bytes, as before. The server's bytes
62
+ are unchanged.
63
+ - **A failed envelope is reported too.** An envelope that does not verify (a bad signature, an algorithm the client
64
+ does not accept, another platform, content that drifted from what was signed, a payload version it cannot read)
65
+ prints nothing, as before, and is now reported as `envelope_verify_failed` (beacon outcome `verify_failed`). Revoked,
66
+ expired, not found and another entry's envelope stay silent. When CertREV answers with an HTTP error, the
67
+ `delivery_unreachable` report carries `status` and the beacon says `http_5xx` or `http_4xx`. A beacon never sends the
68
+ page address (`referrerPolicy: 'no-referrer'`). Its three-per-page budget belongs to a document address, so an in-app
69
+ navigation starts a new one.
70
+ - **React 19 no longer rewrites the card.** React 19 rewrites an element's `innerHTML` whenever its
71
+ `dangerouslySetInnerHTML` is a new object (React 18 compared the strings), so on React 19 every re-render of a
72
+ contract-1 block, including the one right after hydration, replaced the card the browser had built (and left it to
73
+ the hosted runtime to attach the roots again). The block now keeps one object per printed string, so the server's
74
+ card nodes are the ones on the page after hydration, and it re-attaches any root after every commit before paint.
75
+
76
+ ## 2.0.0 · 2026-10-21
77
+
78
+ Major: **the contract-v1 Builder client. Install it once; from then on every visual, copy, layout, schema, fold and
79
+ dialog change to the card ships from CertREV's server, with no package release and no install.** CertREV's server
80
+ (contract v1, portal ADR 0001) paints the card as a signed CARD DOCUMENT; this release prints that document verbatim
81
+ and computes nothing visual of its own.
82
+
83
+ **What a brand does, once:** bump `@certrev/cert-block` to `^2.0.0` and add four sources to the storefront's
84
+ Content-Security-Policy (README, "The contract-v1 card (2.0.0)"). Same import, same `certRevCertComponent`, same block
85
+ name (`CertREV Cert`), same inputs, no new prop, no config.
86
+
87
+ - **Which card a placement gets is decided by the SIGNED binding, per placement.** The Builder Delivery response may
88
+ carry the card document as an unsigned top-level sibling, `card`, beside `payload`, `signature`, `renderDef` and
89
+ `face`. The signed `payload.content.faceDigest` binds it with two keys 1.3.x ignores, `contract: 1` and `doc` (the
90
+ lowercase hex sha256 of the document's RFC 8785 bytes): `{ v: 1, defVersion, html, css, contract: 1, doc }`.
91
+ - **No `contract` key** (every brand until CertREV turns contract v1 on for it, from its side): the card paints
92
+ **exactly what 1.3.1 paints**, byte for byte (the delivered face, the reader face, the legacy face, the options
93
+ face, the records), whatever else the response carries.
94
+ - **`contract: 1`**: the block prints the document only when it is a well-formed contract-1 card document that hashes
95
+ (WebCrypto sha256 over cert-contract's `canonicalizeJson`) to the signed `doc` and names the signed design version,
96
+ the envelope's certificate and this Builder entry (`subject: { platform: 'builder', externalId }`). Otherwise it
97
+ prints **nothing** and reports why: `card_missing` (no card served), `card_verify_failed` (malformed, another
98
+ digest, design version, certificate or entry), `card_pin_mismatch` (CertREV's replica pinned another design
99
+ version). Never a broken card, and never a face of its own for such a placement.
100
+ - **Any other `contract` value, or a malformed contract-1 binding**: nothing, and `card_unsupported_contract`.
101
+ - **What a block prints:** the block's part hosts in page order and, in the block holding the document's first part
102
+ (`top`, else `rail`, else `pill`), the document's head fragment (`<style data-certrev-card-head>`: the pill's fixed box
103
+ and the fonts) before them, CertREV's two dialog hosts (the profile and the certificate: CertREV-styled, identical on
104
+ every brand and CMS, blind to the brand's design) and, once CertREV serves it, the hosted runtime's `<script src
105
+ integrity crossorigin="anonymous" defer>` after them, inside the card's wrapper
106
+ (`data-certrev-face="card"`, `data-certrev-contract="1"`, `data-certrev-def-version`, the seed). Every byte inside the
107
+ wrapper but the runtime tag is the document's own. Each part is a `<certrev-card>` host with a declarative shadow root:
108
+ the browser's parser attaches it, so the card is complete with JavaScript off and no theme rule reaches inside it. The
109
+ saved design decides the layout (the block's `mode` is not read): a banner block prints the card and its memo (`part:
110
+ 'header'` the card only, `part: 'memo'` the memo only); a sidebar or floating block prints its rail or pill, and a
111
+ `memo` block outside the banner prints nothing (a page that kept split blocks after the brand chose a sidebar shows one
112
+ card). A page prints the head, the dialogs and the runtime once (ADR 0001's head once per page, as the lead ruled): a
113
+ split banner's header block carries them, its memo block prints the memo only, and the runtime opens a memo control's
114
+ dialog from the header block (it finds a dialog anywhere on the page by the placement). No block coordinates with
115
+ another, so this holds with JavaScript off.
116
+ - **In the browser.** Hydration keeps the server's card as it stands (same node, no read). The seed (the 1.3.1 seed with
117
+ the card appended last) lets React's own re-renders (its recovery from a hydration error elsewhere on the page, a
118
+ record streamed in after the card rendered, a later mount of the same placement) print the card again with no read,
119
+ after the browser has re-checked the seed's card against the seed payload's binding; a seed card that fails is
120
+ never printed and is reported. A card React inserts (an in-app navigation, Builder's editor) gets its roots attached by
121
+ the card: `setHTMLUnsafe`, else `attachShadow` from each template with its mode and options. The runtime, when the
122
+ document names one, is injected once per page into `document.head` by the block that prints its tag (React does not
123
+ run a script it inserts), unless it already runs (`Symbol.for('certrev.card-runtime')` on `document`).
124
+ - **Builder's wrappers and the sidebar card.** Builder renders each block in a flex item of a flex column, which contains
125
+ the rail's float, so no copy could sit beside the sidebar card. `BUILDER_CARD_FLOW_CSS` (printed after the wrapper,
126
+ only with a rail, only above the card's 820px breakpoint, only with `:has()`) lets the float out of Builder's
127
+ wrappers, as 1.2.0-canary.6 did for the reader face.
128
+ - **`clientRead` now defaults to `true` (behaviour change, no new prop).** A card the client mounts with no record on the
129
+ page reads the Delivery API once from the browser: it is the only way the server's card exists after an in-app
130
+ navigation, and it is the read the CSP's `connect-src https://portal.certrev.com` allows. A read the policy blocks did
131
+ not reach CertREV, so the block paints its options face exactly as 1.3.1 painted it (and reports
132
+ `delivery_unreachable`). `clientRead={false}` restores 1.2.0 to 1.3.1's behaviour (no browser read). A card the client
133
+ renders while the document is still loading first waits for a record the server may still stream (1.2.1), with or
134
+ without `clientRead`, and reads only if the page loads without one; through 1.3.1 an explicit `clientRead` read at
135
+ once and could miss that record. Every read sends no cookie (`credentials: 'omit'`).
136
+ - **The trust root is a key SET, so a key rotation needs no reinstall.** A kid this package bakes (`cert-issuer-1`)
137
+ resolves as before. Any other kid makes one read of the delivery origin's published key set,
138
+ `GET <origin>/api/cert/v1/keys` (`credentials: 'omit'`; a browser at most once per page and origin, a server once per
139
+ origin per hour, a failure remembered for ten minutes; in a browser a failed read is not retried on that page), accepted only when a baked kid signs it (Ed25519 over the RFC
140
+ 8785 bytes of `{ kind: 'certrev.keyset', v: 1, issuedAt, keys: [{ kid, crv: 'Ed25519', x }] }`), it is not older
141
+ than the set already accepted from that origin, and it still lists, as the same key, the kid that signed it. Anything
142
+ else leaves the kid unknown: `unknown_key`, nothing painted, as in 1.3.x. Today every envelope is signed by the baked
143
+ kid, so nothing is fetched.
144
+ - **No silent fallback.** Each fallback is one structured report: in the browser
145
+ `CustomEvent('certrev:card-fallback', { detail: { reason, platform: 'builder', externalId, certId? } })` on
146
+ `document`, once per placement and reason per page; on a server one `console.warn` line, `[certrev] card_fallback
147
+ {json}`; and a contract-1 `'none'` record names its `reason`, so a browser hydrating it reports it too. Reported as
148
+ well, with nothing painted differently, where the read that met them ran (the server's log for a server render, the
149
+ event for a browser read): CertREV not reached (`delivery_unreachable`, whether the block then paints its options face
150
+ or has none to paint) and an unknown kid (`unknown_key`). The card sends nothing anywhere: no beacon, no storage, no
151
+ cookie.
152
+ - **React 19.** The card suspends a server render once, on its read: the read itself computes the card's digest (and,
153
+ for 1.3.0's delivered face, its hashes) before it is cached. Under React 19, `use` matches promises by their order in a
154
+ render, so a second suspension (on the digest, after the read) was handed the read's result: the card printed nothing
155
+ (`card_verify_failed`), and through 1.3.1 a delivered face silently fell back to the card's own face. The replica runs
156
+ on React 18 and on React 19 (`CERT_BLOCK_E2E_REACT`). React 19 also streams any Suspense boundary larger than its
157
+ `progressiveChunkSize` (12,800 bytes by default) after the shell; a host that wants the card complete for readers with
158
+ JavaScript off raises it (README, "React 19 hosts").
159
+ - **Found and fixed before release (a break-it pass).** A page with more cards than the digest memo held (32 documents;
160
+ 64 delivered faces in 1.3.0's memo, which had the same flaw) never finished rendering, because a retry found its
161
+ pending digest evicted: both memos now drop only settled entries. A split header and memo pair whose memo block had
162
+ nothing to print wrote a record that took the header block's card down on hydration: such a block now prints nothing
163
+ and writes no record. `Object.hasOwn` was replaced (Safari before 15.4). A runtime `src` must be on `certrev.com`.
164
+ - **New API, additive.** `./builder`: `acceptedCardBinding`, `parseCardDocument`, `cardDocumentDigest`, `cardPartsFor`,
165
+ `CardDocumentV1`, `CardBinding`, `CardPartName`, `CARD_FALLBACK_EVENT`, `CardFallbackDetail`, `CardFallbackReason`,
166
+ `BUILDER_CARD_FLOW_CSS`. Root: `keySetResolver`, `verifyKeySet`, `KEY_SET_PATH` and their types. `VerifiedDelivery` and
167
+ `CachedDelivery` gain an optional `card` (the sibling exactly as served, UNVERIFIED: check it as the card does).
168
+ - **Unchanged:** the registration, its inputs and the props (a test compares them with 1.3.1's own build, and the
169
+ compiler checks the props), every 1.3.1 face and record for a placement without a contract binding (every 1.3.1
170
+ golden and byte test passes unmodified; the six tests of the no-browser-read default now pass `clientRead={false}`),
171
+ the design pin's own rules on that path, the web component, the storefront modal bundle, and
172
+ `@certrev/cert-contract` `^0.6.0` (the binding is two keys in the already-signed `faceDigest`).
173
+ - **Tests.** `builder/__tests__/card-contract.test.ts` (the binding, the shape check, the digest held equal to the
174
+ portal's own on its five real documents, the parts, the printed bytes), `contract-card.test.tsx` (server: every
175
+ branch, the printed bytes, the reports), `contract-card-client.test.tsx` (jsdom: hydration, client mount, the seed's
176
+ re-check, root attachment, the runtime), `registration-1.x.test.ts` + `registration-props.test-d.ts` (the 1.x
177
+ registration and props), `verify/__tests__/key-set.test.ts` (every refusal, one read per origin, a rotation that
178
+ renders). `e2e/` (`pnpm test:replica`, Playwright Chromium): a Hydrogen-shaped Builder replica at 1280 and 393 px.
179
+
180
+ ## 1.3.1 · 2026-10-21
181
+
182
+ Patch: **a Builder card on the reader face now paints the same card as Shopify and WordPress.** The portal's reader
183
+ face moved on after 1.2.0 (the face this package copied was the portal's of 2026-10-06); 1.3.1 copies its current
184
+ state, so the card's design and behaviour are uniform across CMSs. The sentence at the top of this file says patches
185
+ never change the rendered cert output: **this one does, on purpose, for the reader face only** (a render def that
186
+ says `face: 'reader'`), by bringing it to the bytes the portal's Shopify snippets paint today. **No integration
187
+ change is needed** (same exports, types, registration, inputs and seed), and **the delivered-face path of 1.3.0 is
188
+ unchanged**: its gate, hashes, seed, wrapper and the 1.2.1 fallbacks work as before. The legacy face
189
+ (`renderCertBlock`), the options-only block, the web component and the storefront modal bundle are untouched; a
190
+ pinned card keeps its pin rules and paints the caught-up face for its pinned reader def. A card that paints a
191
+ delivered face (1.3.0) paints the server's markup and is not affected at all.
192
+
193
+ **This is the fallback face**, for sites that still read the render def themselves and paint the reader face from it
194
+ in their own page. It is a catch-up and nothing more: no new feature of the own face comes with it (the face's "View
195
+ Profile" and "View Certificate" controls are the same links carrying `data-certrev-modal-open` as before, opened by the
196
+ host's modal script as in 1.3.0). The next release is planned to be the contract-v1 client, which prints the server's
197
+ signed card document instead of painting a face of its own.
198
+
199
+ What a reader sees now, on a reader-face card:
200
+
201
+ - **The sidebar card folds on a phone.** At 640px and under, its bio and memo are hidden and shown again inside a
202
+ closed `<details class="certrev-r-more">` behind "Read the expert memo" ("About the expert" when there is a bio and
203
+ no memo; no fold with neither), exactly as the Shopify extension and the WordPress plugin (1.8.3) paint it; wider
204
+ than 640px the card is as before. The scope line, the compensation cue and both links stay outside the fold. A
205
+ 469-character memo no longer shows in full on a 375px phone. (`sidebar()` in `reader/render.ts`.)
206
+ - **The rung is honoured** (banner, memo card, sidebar): `credentialed` prints no memo, bio or photo (so no memo card;
207
+ the banner is the memo-less card with the scope line and the profile and certificate row), `named` also drops the
208
+ credential, `minimal` is the accent band and the links only, and any other value is the full card (the memo
209
+ rung), as before. The floating pill is the same on every rung. (`readFacts`, `bannerCard`, `bannerMemo`,
210
+ `sidebar`.)
211
+ - **A toned face.** A def with an ink (`tokens.inkColor`) or a surface that is not white (`tokens.surface`) writes
212
+ `--cr-sf` and `--cr-ink` on the card and adds the class `certrev-r-toned` to every section root; the stylesheet
213
+ paints the surface and ink from them. (`readerTokens`, `isTonedStyle` in `reader/tokens.ts`; `sectionOpen`.)
214
+ - **A def with no design paints the memo-rung defaults**: accent `#0a1b3f` and corner `12px` (it was `#0A1B3F` and
215
+ `14px`). A def that sets its own accent and corner is unchanged. (`reader/tokens.ts`.)
216
+ - **The banner's writer takes the same 42px face as the reviewer**, photo or initials, at every width (it was the
217
+ 36px `sm` face). A photo the browser cannot load is still repainted as its person's initials: the person is now
218
+ read from the byline ("Written by" or "Reviewed by"), not from the face's size class. (`bannerCard`;
219
+ `photoPerson` in `builder/cert-review-card.tsx`.)
220
+ - **The banner's credential date never breaks**: it sits in `<span class="certrev-r-nw">`, and the credential line
221
+ breaks only between its parts. (`verifiedLine` in `reader/render.ts`.)
222
+ - **The stylesheet is the portal's current one** (`reader/reader.css` and `READER_CSS`, 42.9 KB, from portal
223
+ `d62ae100b3`; was 31.6 KB): the banner card splits evenly between the writer and the reviewer on a shared
224
+ baseline (821px and up), the certified date and the cue sit on one full-width row, left-justified, under a hairline a
225
+ theme cannot hide, and the footer stacks at 560px and under; the expert memo card on a phone puts the avatar and
226
+ name on one row with a full-width quote and one links row, and a theme's `blockquote` rules cannot bleed into
227
+ it; the sidebar fold and its 44px toggle survive a theme's `details` rules; a toned face's border resets hold.
228
+ The sheet the card inlines (without the 40 remote font faces) grows from 14.8 KB to 26.2 KB before compression.
229
+ - **Unchanged:** the profile and certificate links (`rel` stays `CERTREV_LINK_REL`, the one byte the face does not
230
+ share with the Liquid), the floating pill's markup, the compensation cue and scope line wording, the avatars' 46px
231
+ reviewer face on the memo card and the sidebar, the seed and the wrapper, and every non-reader face.
232
+ - **Tests.** `scripts/refresh-reader.mjs --portal <portal> --oracle` re-copied the stylesheet, the 11 golden pages and
233
+ the Liquid harness's output; the golden, differential and link-rel tests pass byte for byte on all of it. The
234
+ harness now also paints 155 more edge cells (every rung, the sidebar fold's variants, toned faces, a face with no
235
+ design: 399 cells in all, was 244), and `reader/__tests__/reader-one-face.test.ts` spells those rules out.
236
+ Hand-written expectations that pinned the 2026-10-06 face were brought to the portal's current Liquid (the
237
+ writer's `sm` avatar, the credential date's span, the default accent and corner, the stylesheet's hash). The
238
+ reader entries of `__golden__/delivered-face-1.2.1.json` and `delivered-face-client-1.2.1.json` were re-recorded
239
+ from this version; everything in them outside the stylesheet and the reader sections (the wrapper and the seed) is
240
+ still 1.2.1's bytes, and the legacy entries are untouched. New: a failed writer photo repaints the writer, not the
241
+ reviewer (`delivery-card-hydrate.test.tsx`).
242
+
8
243
  ## 1.3.0 · 2026-10-18
9
244
 
10
245
  Minor: **a `delivery` card can paint the face CertREV rendered for the brand's design, when the signed envelope binds
package/README.md CHANGED
@@ -290,6 +290,122 @@ actually makes (`mode`, `part`). The component enforces the fused-credential rul
290
290
  construction (a credential is unrenderable without its dated verification) and themes to the
291
291
  brand's render-def (accent / surface / corners / font + v2 bar-ink / body-ink).
292
292
 
293
+ ### The contract-v1 card (2.0.0)
294
+
295
+ **Install once.** From 2.0.0 the block prints the card CertREV's server renders (contract v1), so every later change to
296
+ the card's design, copy, layout, schema, memo fold or dialogs ships from CertREV with no package release and no install
297
+ on your side. The upgrade is two edits, made once:
298
+
299
+ 1. `@certrev/cert-block` `^2.0.1` (2.0.1 is a drop-in on 2.0.0: see "2.0.1" below). Keep the registration line as it
300
+ is: same import, same `certRevCertComponent`, same block name (`CertREV Cert`), same inputs, no new prop, no config.
301
+ 2. Four sources in the storefront's Content-Security-Policy, and nothing else from CertREV:
302
+
303
+ ```
304
+ script-src https://portal.certrev.com (the hosted card runtime: dialogs, revalidation)
305
+ connect-src https://portal.certrev.com (the card's read after an in-app navigation; the runtime's revalidation;
306
+ 2.0.1: the card's beacon)
307
+ font-src https://assets.certrev.com (the card's fonts)
308
+ img-src https://assets.certrev.com (reviewer photos)
309
+ ```
310
+
311
+ The card also uses inline `<style>` elements and `style` attributes, as every cert-block release has, so the policy's
312
+ `style-src` must keep allowing inline styles.
313
+ 3. On a React 19 storefront only: pass `progressiveChunkSize: 1_000_000` to `renderToReadableStream` in the server entry
314
+ (Hydrogen: `app/entry.server.tsx`), once, so a reader with JavaScript off gets the card in the page (below).
315
+
316
+ **Turned on from CertREV's side, per brand.** Nothing changes on the page until CertREV signs a placement's envelope for
317
+ contract v1: until then the block paints exactly what 1.3.1 paints, byte for byte. The switch is the signed
318
+ `payload.content.faceDigest`, which then carries `contract: 1` and `doc`, the sha256 of the card document's RFC 8785
319
+ bytes; the Delivery response carries the document as its unsigned `card` sibling.
320
+
321
+ | The signed binding | What the block prints |
322
+ |---|---|
323
+ | no `contract` key | exactly what 1.3.1 prints for the same response |
324
+ | `contract: 1`, and the served `card` hashes to `doc` and names the signed design version, the certificate and this Builder entry | the card, verbatim |
325
+ | `contract: 1`, no card served | nothing; reported `card_missing` |
326
+ | `contract: 1`, any mismatch or a malformed document | nothing; reported `card_verify_failed` |
327
+ | any other `contract`, or a malformed binding | nothing; reported `card_unsupported_contract` |
328
+ | CertREV's replica pins another design version | nothing; reported `card_pin_mismatch` |
329
+
330
+ **What a block prints.** Inside the card's wrapper (`data-certrev-face="card"`): the block's part hosts in page order
331
+ and, in the block that holds the document's first part (the card on a banner, else the rail, else the pill), the head
332
+ fragment (`<style data-certrev-card-head>`) before them, CertREV's profile and certificate dialog hosts and, once CertREV
333
+ serves it, the runtime's `<script src integrity crossorigin="anonymous" defer>` after them. So a page prints each of
334
+ those once: a split banner's header block carries them and its memo block prints the memo only, whose controls open the
335
+ page's one dialog (the runtime finds it by the placement). Keep the header block on a page that has a memo block. Each part is a
336
+ `<certrev-card>` with a declarative shadow root, attached by the HTML parser: the card is complete with JavaScript off
337
+ and no theme rule reaches inside it. The saved design decides the layout, not the block's `mode`; `part` splits a banner
338
+ only (`'header'` the card, `'memo'` the memo), and a `'memo'` block on a sidebar or floating design prints nothing, so a
339
+ page keeps one card when a brand changes its design. A sidebar card's float is let out of Builder's block wrappers
340
+ (`BUILDER_CARD_FLOW_CSS`) so the article's copy flows beside it above 820px, as on every other CMS.
341
+
342
+ **In the browser.** Hydration keeps the server's card as it stands, with no read. React's own later renders of the
343
+ card print it again from the seed after re-checking it. A card the client mounts with no record on the page (an in-app
344
+ navigation, Builder's editor) reads the Delivery API once (`clientRead`, on by default from 2.0.0; `clientRead={false}`
345
+ restores 1.3.1's no-read behaviour), prints the card and attaches its roots (`setHTMLUnsafe`, else from each template),
346
+ and loads the runtime once. A read your policy blocks prints nothing and reports `delivery_unreachable` (2.0.1; 2.0.0
347
+ painted the block's options face, as 1.3.1 did).
348
+
349
+ **React 19 hosts and readers with JavaScript off.** React 19 streams a Suspense boundary larger than its
350
+ `progressiveChunkSize` (12,800 bytes by default) after the page's shell, even when it is ready at once, and only a script
351
+ moves it into place; the card (its parts, dialogs and seed) is larger than that, so a reader with JavaScript off on a
352
+ React 19 page sees no card unless the host passes a larger `progressiveChunkSize` (for example `1_000_000`) to
353
+ `renderToReadableStream`. React 18 inlines it. Readers with JavaScript and crawlers that run it see the card either way.
354
+
355
+ **Every fallback is reported**, never silent: in the browser,
356
+
357
+ ```ts
358
+ import { CARD_FALLBACK_EVENT, type CardFallbackDetail } from '@certrev/cert-block/builder'
359
+
360
+ document.addEventListener(CARD_FALLBACK_EVENT, (e) => {
361
+ const { reason, externalId, certId } = (e as CustomEvent<CardFallbackDetail>).detail
362
+ // reason: card_missing | card_verify_failed | card_unsupported_contract | card_pin_mismatch | unknown_key
363
+ // | delivery_unreachable | no_marker | marker_invalid | client_read_off (the last three: 2.0.1)
364
+ })
365
+ ```
366
+
367
+ once per placement and reason per page; on a server, one `console.warn` line `[certrev] card_fallback {json}`. 2.0.1:
368
+ each report is also a beacon to CertREV (below); the card stores nothing and sends no cookie (`credentials: 'omit'`).
369
+
370
+ **Key rotation needs no reinstall.** The baked kid (`cert-issuer-1`) verifies as before. An envelope signed by any
371
+ other kid makes one read of `https://portal.certrev.com/api/cert/v1/keys`, a key set the card accepts only when the baked
372
+ kid signs it (and it is not older than one already accepted, and still lists its signer). Otherwise the kid is unknown
373
+ and the card renders nothing, as in 1.3.x.
374
+
375
+ ### 2.0.1: e.l.f.'s one install
376
+
377
+ A drop-in on 2.0.0 (same package, export, registration, inputs and CSP). What changes on a page:
378
+
379
+ - **Nothing, never unsigned facts.** A block with no delivery marker (`no_marker`), a marker this client does not
380
+ accept (`marker_invalid`), CertREV not reached with no verified answer inside the stale window
381
+ (`delivery_unreachable`), and a client mount with `clientRead={false}` (`client_read_off`) print **nothing** and
382
+ report it. Through 2.0.0 those painted the block's export-time options (a reviewer name, a credential, a memo, a
383
+ verify link that nobody signed); that face is gone. Every section below that still describes "the options face"
384
+ describes 1.x to 2.0.0; 2.0.1 prints nothing there. The signed paths are unchanged.
385
+ - **Stale window: 30 s fresh, 30 s stale** (2.0.0: 60 s and 300 s): with the Delivery API's own cache a revoked card is
386
+ gone within five to six minutes (ADR 0001 §7); past the window a read that cannot reach CertREV prints nothing.
387
+ - **A beacon to CertREV**: each fallback, and one health event on one page view in a hundred, as a `POST <delivery
388
+ origin>/api/cert/v1/beacon` `text/plain` JSON body `{v:1, client:'cert-block', clientVersion, contract,
389
+ platform:'builder', externalId, family, events:[{kind, reason, outcome}]}`. No cookies, no page address or text, no
390
+ user agent beyond a family. At most three per page; on a server, each placement and reason once an hour per process.
391
+ It goes to the delivery origin, already in `connect-src`: no new CSP source.
392
+ - **Forward compatible**: unknown parts, unknown fields and new keys under contract 1 are ignored; a newer major prints
393
+ nothing (`card_unsupported_contract`) and the page renders around it.
394
+ - **Placement hints**: once the page has loaded, each banner part moves to the place the document's `placement` names
395
+ among the Builder entry's top-level blocks: `content-start` (after a leading image with `after: 'leading-media'`),
396
+ `after-byline`, `before-references` (else `content-end`), `content-end`. Mark your byline with
397
+ `data-certrev-anchor="byline"` to make `after-byline` exact; otherwise the first `[rel~="author"]`,
398
+ `[itemprop="author"]`, `.byline`, `[class*="byline"]` or `address` in the entry is taken. An anchor the page lacks,
399
+ or one this client does not know, leaves the part where the editor put it, as JavaScript off and Builder's visual
400
+ editor and preview do. Rails and pills never move.
401
+ - **Nothing in the console**: no hydration mismatch in React's development build, and no "declarative Shadow DOM is
402
+ not being parsed" warning when React writes the card itself (an in-app navigation). The server's bytes are
403
+ unchanged.
404
+ - **The page's CertREV graph**: when the document carries `jsonld.graph` (resolved by CertREV's server for the
405
+ placement), the block that carries the page's assets prints it once as `<script type="application/ld+json"
406
+ data-certrev-graph>`; without it, no graph. The block never builds the graph (the "card emits no structured data" rule
407
+ below describes 1.x's own face).
408
+
293
409
  ### The reader face (1.2.0, opt-in)
294
410
 
295
411
  certrev.com's "reader" design (a banner card plus the expert memo, a sidebar card, a floating pill) is a
@@ -303,7 +419,13 @@ the `delivery` marker (an options-only block) never reads the envelope, so it st
303
419
 
304
420
  - **Same bytes as the Liquid.** The markup is the Shopify `certrev-reader-*` snippets', and the portal's
305
421
  golden pages are copied into `src/reader/__tests__/fixtures/golden/` with their sha256, so the two
306
- cannot drift without a test saying so.
422
+ cannot drift without a test saying so. **From 1.3.1 it is the portal's one face**, the card the Shopify
423
+ extension and the WordPress plugin (1.8.3 and later) paint, so a Builder page, a Shopify page and a WordPress
424
+ page show the same card (`src/reader/__tests__/fixtures/SOURCES.json` names the portal commit the copies came
425
+ from; `pnpm refresh:reader` re-copies them and the golden and differential tests then say what the renderer must
426
+ follow). 1.2.0 to 1.3.0 painted the portal's face of 2026-10-06; the differences are listed in the CHANGELOG.
427
+ It is the **fallback face** for sites that read the def themselves: 1.3.1 only brings it level with the portal,
428
+ and adds no feature of its own.
307
429
  - **The stylesheet** is the generated reader section of `certrev-cert.css`
308
430
  (`/* CERTREV-READER-GEN:BEGIN */ ... END`), copied byte for byte as `src/reader/reader.css` (`./reader.css`)
309
431
  and as `READER_CSS`. The card inlines it once, in a `<style data-certrev-reader-css>`, inside the face's
@@ -349,12 +471,23 @@ the `delivery` marker (an options-only block) never reads the envelope, so it st
349
471
  - **Brand tokens** are an allow-list, as the Shopify and WordPress twins' are. The accent and bar ink must
350
472
  be a `#RGB` or `#RRGGBB` string; the corner radius a bare `0` or one non-negative `px`, `rem`, `em` or `%`
351
473
  length. Anything else (a named colour, `rgb()`, a negative or unitless radius, a value with a quote, a
352
- comment opener or a bracket) paints the default, navy `#0A1B3F` and `14px`, and never reaches the
353
- `style` attribute. The font is one of five stacks (`sans`, `serif`, `grotesk`, `mono`, `system`); an
354
- unknown slot paints sans.
474
+ comment opener or a bracket) paints the default, navy `#0a1b3f` and `12px` (the design guide's memo-rung defaults,
475
+ also what a face with no def paints; 1.3.0 painted `#0A1B3F` and `14px`), and never reaches the `style`
476
+ attribute. The font is one of five stacks (`sans`, `serif`, `grotesk`, `mono`, `system`); an unknown slot paints
477
+ sans. A def with an ink (`inkColor`), or a surface (`surface`) that is not white, is a **toned** face: each
478
+ valid colour is written as `--cr-sf` / `--cr-ink` on the root, which gains the class `certrev-r-toned`.
479
+ - **The rung.** The def's `rung` decides how much the card prints: `memo` (and any value the face does not know,
480
+ or none) is the whole card; `credentialed` prints no memo, bio or photo (so no memo card, and the banner is the
481
+ memo-less card with the scope line and the profile and certificate row); `named` also drops the credential;
482
+ `minimal` is the accent band and the links only. The floating pill is the same on every rung.
483
+ - **The sidebar folds on a phone.** The card's bio and memo are printed twice: as the card's own pair, and again
484
+ inside a closed `<details class="certrev-r-more">` whose toggle reads "Read the expert memo" ("About the
485
+ expert" when there is a bio and no memo; no fold with neither). At 640px and under the stylesheet hides the
486
+ pair and shows the fold; wider, the fold is hidden. The scope line, the compensation cue and both links stay
487
+ outside the fold. It is the same card as Shopify's and WordPress's (owner decision 3d0838a4).
355
488
  - **Avatars.** Every byline paints a round avatar: the person's photo when there is one to paint, else their
356
- initials, else the silhouette (a name with no initials). It is 36 px for the writer, 42 px for the banner
357
- reviewer, 46 px for the memo and sidebar reviewer. The initials and the silhouette are an inline `<svg>`, not an
489
+ initials, else the silhouette (a name with no initials). It is 42 px for the writer and the banner reviewer
490
+ alike (1.3.0 and earlier: 36 px for the writer), 46 px for the memo and sidebar reviewer. The initials and the silhouette are an inline `<svg>`, not an
358
491
  `<img>`, so a page with no photos holds no avatar image. The initials are the same bytes the portal's Liquid
359
492
  snippets and WordPress renderer print: the name trimmed and split on single spaces (empty parts skipped, so
360
493
  "Jane Doe" is "JD"), a leading dr, mr, mrs, ms, prof or mx (with or without the dot) dropped, the first character (by code point) of each of the first two parts left,
@@ -366,7 +499,8 @@ the `delivery` marker (an options-only block) never reads the envelope, so it st
366
499
  setting.
367
500
  - **Credential line.** The banner prints the reviewer's credential, a middle dot and the attribution as
368
501
  three leaves of one `<em>` (`RDN, LD, PhD · Credential verified by CertREV on May 1, 2026`); a reviewer
369
- with no displayed credential makes no verification claim. The writer's `<em>` under the name is the
502
+ with no displayed credential makes no verification claim. The date, when there is one, is wrapped in
503
+ `<span class="certrev-r-nw">` so it never breaks across lines. The writer's `<em>` under the name is the
370
504
  envelope's `author.title`, printed only when it is non-empty.
371
505
 
372
506
  ### The delivery card in the browser (1.2.0)
@@ -427,7 +561,8 @@ browser hydrates from it:
427
561
  `part: 'header'` or `'memo'`), when the def served is that version and both hashes, computed with WebCrypto,
428
562
  equal the signed values. The seed then ends with the same `face`, and the browser hashes it again against the
429
563
  seed payload's digest before it paints it; with no WebCrypto (an insecure `http:` page) or bytes that do not
430
- hash, the card paints what 1.2.1 paints. Every other delivery paints exactly what 1.2.1 paints, seed included.
564
+ hash, the card paints what 1.2.1 paints. Every other delivery paints exactly what 1.2.1 paints, seed included,
565
+ except that a reader-face def paints the reader face of 1.3.1 (below), not 1.2.1's.
431
566
  The face is on the page twice (painted, and in the seed).
432
567
  - **The server's read** waits up to 8 s (the verifier's own default stays 3 s). Its cache keeps a certificate for
433
568
  60 s, then serves it for up to 5 minutes more while one background refresh runs or while CertREV cannot be
@@ -862,6 +997,24 @@ alongside each modal module. The load-bearing ones:
862
997
  - **`builder/__tests__/delivery-card-hydrate.test.tsx`** + **`builder/__tests__/ssr-seed.test.tsx`** (1.2.0): a server-rendered delivery card hydrates on both faces with zero browser reads, no mismatch and the same nodes; its seed is the served envelope and still verifies; revoked and browser-clock-expired cards come down; a later client mount reuses the page's seed; the server's options and none records are kept through hydration and through React's client re-render after a hydration error, with no read; a client mount with no record makes no read and paints the options face or nothing, and only with `clientRead` reads once, or renders nothing and logs nothing when the read is refused; a reviewer photo that fails to load repaints as initials; the seed and fallback-record parsers refuse malformed records and each other's; the design pin is part of the seed's key; a hidden `reviewerPhoto` or `memo`, or `display.showExpertPhoto: false` (the photo URLs), is absent from the whole server HTML and the seed carries no signature; every server render in these files runs under jsdom with no server switch; and the card's inlined reader stylesheet names no font URL. **`builder/__tests__/delivery-card.test.tsx`** pins the unreachable cases to the 1.0.3 options face byte for byte, and the 8 s deadline and the stale window; **`verify/__tests__/cache.test.ts`** the cache's stale-while-revalidate, stale-if-error and in-flight limit. **`reader/__tests__/reader-initials.test.ts`** pins the initials bytes (honorifics, escaping, hidden and missing photos, `showExpertPhoto`, the empty name).
863
998
  - **`builder/__tests__/delivered-face.test.tsx`** + **`builder/__tests__/delivered-face-client.test.tsx`** + **`builder/__tests__/delivered-face-use.test.tsx`** (1.3.0): the delivered face paints, server and browser (hydration, a client mount over the page's record, a streamed record, `clientRead`), only when its hashes match the signed digest for the def served, on an unpinned whole-card block; every other delivery, and a browser that cannot hash, renders what the unmodified 1.2.1 card rendered for the same input, byte for byte, seed included (`__golden__/delivered-face-1.2.1.json`, `__golden__/delivered-face-client-1.2.1.json`); a tampered digest suppresses.
864
999
  - **`builder/__tests__/streamed-record-card.test.tsx`** (1.2.1): a card the client renders while the page loads paints nothing, then the record the stream adds, to the document (#421) or to the server's `<html>` after React replaced it (#423, with and without `MutationObserver`), and what a client mount paints once the page loads with none; pinned, options, none and expired records; no read; hydration, `clientRead` and a loaded page unchanged.
1000
+ - **`reader/__tests__/reader-one-face.test.ts`** + **`reader-golden.test.ts`** + **`reader-differential.test.ts`** (1.3.1): the reader face is byte-equal to the portal's own Liquid harness on its 11 golden pages and 399 edge cells, among them every rung, the sidebar's phone fold, toned faces and a face with no design (`pnpm refresh:reader` re-copies them from a portal checkout).
1001
+ - **`builder/__tests__/card-contract.test.ts`**, **`contract-card.test.tsx`**, **`contract-card-client.test.tsx`** (2.0.0):
1002
+ the contract-1 card on the portal's own card documents (`__fixtures__/contract-v1/`, painted by its
1003
+ `buildCardDocument`; `scripts/card-fixtures.portal.ts` regenerates them): the binding, the shape check, the digest held
1004
+ equal to the portal's own, the printed bytes (the document's own, verbatim), every fallback and its report, hydration,
1005
+ a client mount's read, the seed's re-check, root attachment and the runtime; and every 1.3.1 path unchanged.
1006
+ **`registration-1.x.test.ts`** + **`registration-props.test-d.ts`** hold the registration to 1.3.1's own build and the
1007
+ props to 1.3.1's (compiler-checked). **`verify/__tests__/key-set.test.ts`**: the key set's refusal rules and reads.
1008
+ **`preview-parity.test.tsx`**: against the portal's own Appearance preview page for a contract-1 brand, the block
1009
+ prints the preview's head byte for byte and each host's shadow tree byte for byte but the certificate id.
1010
+ **`contract-wire.test.tsx`**: a Builder Delivery 200 exactly as the portal's route answered it.
1011
+ **`break-it-regressions*.test.tsx`**: the pre-release break-it findings, each failing on the code before its fix.
1012
+ - **`e2e/`** (`pnpm test:replica`, Playwright 1.59.1 Chromium, not part of `pnpm test`): a Hydrogen-shaped Builder replica
1013
+ (streamed SSR under a strict CSP with exactly the four sources above, document hydration, an in-app navigation, and
1014
+ CertREV's hosted runtime as built, `e2e/replica/card-runtime.*.js.txt`) at 1280 and 393 px: every part's shadow tree
1015
+ and pixels equal the same part printed alone and the Appearance preview's own part, the dialogs (focus trapped, Escape,
1016
+ focus returned), the sidebar's memo fold, a revoked placement removed and kept removed, JS off, and a CSP positive
1017
+ control for each source. `CERT_BLOCK_E2E_REACT=<a node_modules with react and react-dom 19>` runs it on React 19.
865
1018
  - **`modal/*.test.ts`**, per module: the modal element (tombstone → `markSuppressed`), the dialog view, interactions, placement, badge revalidation, and the Delivery-API verify client. `modal/own-face-modal.test.ts` puts two faces on one page and proves each control opens its own face's modal; `modal/closed-container-face.test.ts` proves a face that loads inside a closed `<details>`, tab or accordion is left alone and renders when opened (there is no runtime disclosure guard).
866
1019
  - **`__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.
867
1020
 
@@ -870,7 +1023,7 @@ published at `@certrev/cert-block/fixtures`).
870
1023
 
871
1024
  ## Version
872
1025
 
873
- `1.3.0`: see [CHANGELOG.md](./CHANGELOG.md) for the release history and the semver contract
1026
+ `2.0.1`: see [CHANGELOG.md](./CHANGELOG.md) for the release history and the semver contract
874
1027
  (patch/minor never change the rendered cert output or break a compiling integration). Publishes
875
1028
  **publicly** to npm as `@certrev/cert-block` (`publishConfig.access: public`) via GitHub Actions
876
1029
  trusted publishing (OIDC); the internal `@certrev` GitHub-Packages channel mirrors it.
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The Builder card's beacon (2.0.1): what the card tells CertREV about itself, so a card that does not print on a brand's
3
+ * page is seen by CertREV and not only by the brand's console.
4
+ *
5
+ * One `POST <delivery origin>/api/cert/v1/beacon` per event, a `text/plain` JSON body (a CORS simple request, so no
6
+ * preflight; the origin is the delivery origin the page's `connect-src` already allows, so no new CSP source):
7
+ *
8
+ * { v: 1, client: 'cert-block', clientVersion, contract, platform: 'builder', externalId, family,
9
+ * events: [{ kind: 'fallback' | 'health', reason, outcome, status? }] }
10
+ *
11
+ * What it never carries: cookies (`credentials: 'omit'`), the page's address (`referrerPolicy: 'no-referrer'`) or text, the reader's user agent (only a
12
+ * coarse family: chromium, webkit, gecko, other, or server), or anything the reader typed. `externalId` is the Builder
13
+ * entry the block renders in, which the brand's page already sends to CertREV when it reads the card.
14
+ *
15
+ * How much: in a browser, every fallback (each placement and reason once per page, `card-report.ts`) and one health
16
+ * event per page in a hundred, at most three beacons per page (a page is a document address: an in-app navigation to
17
+ * another article starts a new budget); on a server, each placement, reason and origin at most
18
+ * once an hour per process, with a short deadline, never awaited. A beacon that fails is dropped: it never retries,
19
+ * never stores anything and never changes what the card prints.
20
+ */
21
+ export declare const BEACON_PATH = "/api/cert/v1/beacon";
22
+ /** Where a block with no accepted delivery marker reports: CertREV's production delivery origin. */
23
+ export declare const DEFAULT_BEACON_ORIGIN = "https://portal.certrev.com";
24
+ /** The most beacons one page sends. */
25
+ export declare const BEACON_MAX_PER_PAGE = 3;
26
+ /** The share of page views that send one health event. */
27
+ export declare const BEACON_HEALTH_SAMPLE = 0.01;
28
+ /** A server sends one beacon per placement, reason and origin at most this often. */
29
+ export declare const BEACON_SERVER_INTERVAL_MS = 3600000;
30
+ export type BeaconOutcome = 'ok' | 'unreachable' | 'http_4xx' | 'http_5xx' | 'verify_failed' | 'unsupported' | 'other';
31
+ export type BeaconFamily = 'chromium' | 'webkit' | 'gecko' | 'server' | 'other';
32
+ export interface BeaconEvent {
33
+ readonly kind: 'fallback' | 'health';
34
+ readonly reason: string;
35
+ readonly outcome: BeaconOutcome;
36
+ /** The HTTP status CertREV answered with, when a read failed on one (outcome `http_4xx` or `http_5xx`). */
37
+ readonly status?: number;
38
+ }
39
+ export interface CardBeacon {
40
+ readonly v: 1;
41
+ readonly client: 'cert-block';
42
+ readonly clientVersion: string;
43
+ readonly contract: number;
44
+ readonly platform: 'builder';
45
+ readonly externalId?: string;
46
+ readonly family: BeaconFamily;
47
+ readonly events: readonly BeaconEvent[];
48
+ }
49
+ /** How a beacon leaves: `url`, the JSON body, and where it is sent from. */
50
+ export type BeaconTransport = (url: string, body: string, where: 'browser' | 'server') => void;
51
+ /** The transport the card ships with: `fetch`, never awaited, never throwing (exported for CertREV's replica harness). */
52
+ export declare const fetchBeaconTransport: BeaconTransport;
53
+ /** The coarse browser family, from the user agent; nothing else of it is kept. */
54
+ export declare function browserFamily(userAgent: string): BeaconFamily;
55
+ export declare function beaconBody(externalId: string, events: readonly BeaconEvent[], family: BeaconFamily): CardBeacon;
56
+ /** Send one event from the browser, within the page's budget. */
57
+ export declare function sendBrowserBeacon(baseUrl: string | undefined, externalId: string, event: BeaconEvent): void;
58
+ /** Send one health event from the browser on one page view in a hundred (drawn once per page). */
59
+ export declare function maybeSendBrowserHealth(baseUrl: string | undefined, externalId: string): void;
60
+ /** Send one event from a server: each placement, reason and origin at most once an hour per process. */
61
+ export declare function sendServerBeacon(baseUrl: string | undefined, externalId: string, event: BeaconEvent): void;
62
+ /**
63
+ * Test seam: replace the transport (and the sampling draw and clock) and forget the page's and the process's budget.
64
+ * Returns a restore.
65
+ */
66
+ export declare function setCardBeaconForTests(overrides?: {
67
+ transport?: BeaconTransport;
68
+ random?: () => number;
69
+ now?: () => number;
70
+ }): () => void;
71
+ /** Test seam: a new page and a new process. */
72
+ export declare function forgetCardBeaconsForTests(): void;
73
+ //# sourceMappingURL=card-beacon.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"card-beacon.d.ts","sourceRoot":"","sources":["../../src/builder/card-beacon.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAKH,eAAO,MAAM,WAAW,wBAAwB,CAAA;AAChD,oGAAoG;AACpG,eAAO,MAAM,qBAAqB,+BAA+B,CAAA;AACjE,uCAAuC;AACvC,eAAO,MAAM,mBAAmB,IAAI,CAAA;AACpC,0DAA0D;AAC1D,eAAO,MAAM,oBAAoB,OAAO,CAAA;AACxC,qFAAqF;AACrF,eAAO,MAAM,yBAAyB,UAAY,CAAA;AAIlD,MAAM,MAAM,aAAa,GAAG,IAAI,GAAG,aAAa,GAAG,UAAU,GAAG,UAAU,GAAG,eAAe,GAAG,aAAa,GAAG,OAAO,CAAA;AACtH,MAAM,MAAM,YAAY,GAAG,UAAU,GAAG,QAAQ,GAAG,OAAO,GAAG,QAAQ,GAAG,OAAO,CAAA;AAE/E,MAAM,WAAW,WAAW;IAC3B,QAAQ,CAAC,IAAI,EAAE,UAAU,GAAG,QAAQ,CAAA;IACpC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAA;IAC/B,2GAA2G;IAC3G,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CACxB;AAED,MAAM,WAAW,UAAU;IAC1B,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAA;IACb,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAA;IAC7B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;IAC9B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,QAAQ,CAAC,QAAQ,EAAE,SAAS,CAAA;IAC5B,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;IAC5B,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAA;IAC7B,QAAQ,CAAC,MAAM,EAAE,SAAS,WAAW,EAAE,CAAA;CACvC;AAED,4EAA4E;AAC5E,MAAM,MAAM,eAAe,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,GAAG,QAAQ,KAAK,IAAI,CAAA;AAE9F,0HAA0H;AAC1H,eAAO,MAAM,oBAAoB,EAAE,eAsBlC,CAAA;AAoBD,kFAAkF;AAClF,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,YAAY,CAK7D;AAeD,wBAAgB,UAAU,CAAC,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,WAAW,EAAE,EAAE,MAAM,EAAE,YAAY,GAAG,UAAU,CAW/G;AAED,iEAAiE;AACjE,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,EAAE,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,GAAG,IAAI,CAQ3G;AAED,kGAAkG;AAClG,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI,CAO5F;AAED,wGAAwG;AACxG,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,EAAE,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,GAAG,IAAI,CAa1G;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CACpC,SAAS,GAAE;IAAE,SAAS,CAAC,EAAE,eAAe,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CAAO,GACxF,MAAM,IAAI,CAWZ;AAED,+CAA+C;AAC/C,wBAAgB,yBAAyB,IAAI,IAAI,CAKhD"}