@certrev/cert-block 0.5.4 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. package/CHANGELOG.md +195 -12
  2. package/LICENSE +21 -0
  3. package/README.md +422 -50
  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 +115 -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 +14 -2
  136. package/src/builder/cert-chrome-data.ts +26 -8
  137. package/src/builder/cert-review-card.tsx +147 -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 +12 -7
  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__/render-cert-block.test.ts +0 -657
  179. package/src/__tests__/render-def.test.tsx +0 -295
  180. package/src/__tests__/verify.test.ts +0 -239
  181. package/src/__tests__/webcomponent.test.tsx +0 -116
  182. package/src/builder/__tests__/builder.test.tsx +0 -158
  183. package/src/modal/cert-modal-view.test.ts +0 -175
  184. package/src/modal/certrev-cert-modal.test.ts +0 -125
  185. package/src/modal/ftc-guard.test.ts +0 -153
  186. package/src/modal/interactions.test.ts +0 -250
  187. package/src/modal/placement.test.ts +0 -398
  188. package/src/modal/revalidate.test.ts +0 -111
  189. package/src/modal/verify-client.test.ts +0 -164
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `<certrev-cert-modal>` — the ONE Shadow-DOM element that renders both in-page cert
3
- * dialogs (certificate 3a + reviewer 4b) across Shopify / WordPress / Builder
4
- * (POR-10102). Replaces the three drifting per-platform modal implementations.
3
+ * dialogs (certificate + reviewer) across Shopify / WordPress / Builder.
4
+ * Replaces the three drifting per-platform modal implementations.
5
5
  *
6
6
  * Design:
7
7
  * - One element, one OPEN shadow root, two `<dialog>`s (`#cert`, `#expert`). Public
@@ -53,7 +53,7 @@ function getSharedSheet(): CSSStyleSheet | null {
53
53
 
54
54
  /**
55
55
  * Inject the modal font families once, at document level, on first open — SELF-HOSTED from R2
56
- * (POR-10657). Declares the shared CERT_FONT_FACE_CSS @font-face block inline instead of loading a
56
+ * Declares the shared CERT_FONT_FACE_CSS @font-face block inline instead of loading a
57
57
  * render-blocking `fonts.googleapis.com` stylesheet (which also leaked the visitor's IP to Google).
58
58
  * @font-face at document level resolves across the shadow boundary, so the shadow-DOM modal picks
59
59
  * these up exactly as it did the old Google <link>. A crossorigin preconnect warms the R2 origin
@@ -164,16 +164,16 @@ export class CertRevCertModal extends HTMLElement {
164
164
  if (!res.ok) return
165
165
  const artifact = (await res.json()) as CertDeliveryArtifact
166
166
  // A 200 may be a REVOCATION — either the SLIM `CertTombstone` the control plane now
167
- // serves for a revoked placement (POR-10481, no payload) or a legacy envelope with
167
+ // serves for a revoked placement (no payload) or a legacy envelope with
168
168
  // `revokedAt` set — plus an EXPIRED envelope. The Delivery API serves those signed so
169
- // the edge SUPPRESSES rather than fail-opening on a 404 (POR-10101). Never render
169
+ // the edge SUPPRESSES rather than fail-opening on a 404. Never render
170
170
  // "verified" content from a revoked/expired artifact.
171
171
  if (isArtifactSuppressed(artifact)) {
172
172
  this.markSuppressed()
173
173
  return
174
174
  }
175
175
  // Not suppressed → a live envelope (a tombstone always suppresses above); `isTombstone`
176
- // narrows the union so the payload read is type-safe (POR-10684).
176
+ // narrows the union so the payload read is type-safe.
177
177
  const content = isTombstone(artifact)
178
178
  ? undefined
179
179
  : (artifact.payload?.content as CertModalContent | undefined)
@@ -206,7 +206,7 @@ export class CertRevCertModal extends HTMLElement {
206
206
  }
207
207
 
208
208
  /**
209
- * The modal kill-switch (POR-10101): a fetched envelope is REVOKED/EXPIRED. Don't open;
209
+ * The modal kill-switch: a fetched envelope is REVOKED/EXPIRED. Don't open;
210
210
  * mark the host suppressed (mirrors the badge revalidation's `data-certrev-suppressed`) and
211
211
  * hide the light-DOM triggers so a stale click can't re-open a pulled cert. Best-effort DOM.
212
212
  */
@@ -244,9 +244,9 @@ function safeParseContent(json: string): CertModalContent | null {
244
244
  }
245
245
 
246
246
  /**
247
- * The modal-side lifecycle gate (POR-10101 / POR-10684): a fetched Delivery-API artifact that is
247
+ * The modal-side lifecycle gate: a fetched Delivery-API artifact that is
248
248
  * REVOKED or EXPIRED must NOT render "verified" content. Two revocation shapes suppress:
249
- * • the SLIM `CertTombstone` the control plane serves for a revoked placement (POR-10481) —
249
+ * • the SLIM `CertTombstone` the control plane serves for a revoked placement —
250
250
  * detected by the `kind: 'tombstone'` discriminator via `isTombstone`. It carries NO payload,
251
251
  * so a `payload.lifecycle` read alone would MISS it (and `markSuppressed()` would never fire);
252
252
  * • a legacy envelope with `lifecycle.revokedAt` set, or any envelope whose `expiresAt` passed.
@@ -1,14 +1,15 @@
1
1
  /**
2
- * Browser bundle ENTRY for the Shopify cert-badge Web Component (built by
3
- * scripts/build-shopify-cert-asset.mjs → shopify/extensions/certrev-schema/assets/certrev-cert.js,
4
- * loaded by the `certrev-cert` app block via its schema `javascript` key).
2
+ * Browser bundle ENTRY for the cert-badge Web Component. `pnpm build:modal-bundle`
3
+ * bundles this file into the IIFE at `dist/modal/certrev-cert.js` — the asset a
4
+ * storefront loads with a plain `<script>` tag, and the same asset the Shopify
5
+ * `certrev-cert` app block loads via its schema `javascript` key.
5
6
  *
6
7
  * Glue only: it resolves the published issuer key + the Delivery-API verifier (which runs the
7
8
  * canonical `verifyEnvelope` kernel) and kicks off the DOM revalidation pass. No crypto here.
8
9
  */
9
10
 
10
11
  import type { ResolvePublicKeyByKid } from '@certrev/cert-contract'
11
- import './certrev-cert-modal.js' // registers the unified <certrev-cert-modal> element (POR-10102)
12
+ import './certrev-cert-modal.js' // registers the unified <certrev-cert-modal> element
12
13
  import { installFtcGuard } from './ftc-guard.js'
13
14
  import { initCertInteractionsOnReady } from './interactions.js'
14
15
  import { initCertPlacement } from './placement.js'
@@ -18,9 +19,10 @@ import { createDeliveryVerifier } from './verify-client.js'
18
19
  /**
19
20
  * The published CertREV issuer signing key (SPKI PEM) for kid `cert-issuer-1` — the PUBLIC
20
21
  * verification key, safe to ship to every storefront browser. Kept BYTE-IDENTICAL to
21
- * `CERT_ENVELOPE_SIGNING_PUBLIC_KEY_PEM` in src/lib/cert-delivery/signer.ts; revalidate.test.ts
22
- * asserts the equality so a key rotation can't silently desync this copy. (signer.ts is
23
- * node-only, so it cannot be imported into a browser bundle — hence the asserted copy.)
22
+ * `CERT_ENVELOPE_SIGNING_PUBLIC_KEY_PEM` in the portal's cert-delivery signer. The equality is
23
+ * asserted PORTAL-side against this package's exported `CERT_ISSUER_*`, so a key rotation can't
24
+ * silently desync this copy; it cannot be asserted here, because the signer is node-only and
25
+ * cannot be imported into a browser bundle — hence the copy.
24
26
  */
25
27
  export const CERT_ISSUER_KID = 'cert-issuer-1'
26
28
  export const CERT_ISSUER_PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----
@@ -1,13 +1,17 @@
1
1
  /**
2
- * Cert-render display strings + date formatter for the MODAL — a BYTE-FAITHFUL relocation of
3
- * portal `src/lib/cert-delivery/display-strings.ts` (POR-10721 W2). Kept a SEPARATE module from
4
- * ftc-disclosure.ts (matching portal) so the bundle's `var` layout is byte-identical to the live asset.
2
+ * Cert-render display strings + date formatter for the MODAL — a BYTE-FAITHFUL relocation of the
3
+ * portal's own display-strings module. Kept a SEPARATE module from ftc-disclosure.ts (matching the
4
+ * portal's file split) so the bundle's `var` layout is byte-identical to the live deployed asset.
5
5
  *
6
6
  * ⚠ Deliberately SEPARATE from `../components/render-def.ts`/`format.ts`. `formatCertDisplayDate`
7
7
  * uses `toLocaleDateString` (NOT the components' fixed MONTHS array): the live modal formats dates
8
8
  * via `toLocaleDateString` at runtime in the browser, so a fixed-array formatter would VISIBLY
9
9
  * diverge on any browser whose ICU renders a month abbreviation differently (the "Sep"/"Sept"
10
10
  * CLDR-42 case). Byte-parity with the deployed asset requires preserving the exact source.
11
+ *
12
+ * Byte-frozen about OUTPUT, type-guarded about INPUT — the same split `escape.ts` makes here:
13
+ * every well-formed value renders the exact string portal renders, and a value the wire could
14
+ * never legitimately carry renders nothing (see `formatCertDisplayDate`).
11
15
  */
12
16
 
13
17
  import { FTC_DISCLOSURE_LINE } from "./ftc-disclosure.js";
@@ -20,13 +24,30 @@ export const CERT_SCOPE_LINE = FTC_DISCLOSURE_LINE;
20
24
 
21
25
  /**
22
26
  * Format a timestamp the way every cert byline renders dates ("Jun 14, 2026", en-US short month,
23
- * UTC-pinned). Byte-faithful to portal `formatCertDisplayDate`. Null for an unparseable input —
24
- * callers treat that as "no date, no byline".
27
+ * UTC-pinned). Null for an unparseable input — callers treat that as "no date, no byline".
28
+ * Byte-faithful to portal `formatCertDisplayDate` for every value the wire can legitimately carry.
29
+ *
30
+ * STRINGS AND `Date`s ONLY — any other type is null, the same rule `../components/format.ts`'s
31
+ * `formatDate` settled on. `certifiedAt` reaches here off a `JSON.parse(...) as` cast whose
32
+ * SIGNATURE was checked and whose SHAPE never was, and the `Date` constructor coerces whatever it
33
+ * is handed: `certifiedAt: 42` put "Jan 1, 1970" in the certificate's Issued row, and `[2026]`
34
+ * put "Jan 1, 2026" there. There is no reading to rescue — the wire types this field as an
35
+ * ISO-8601 string, so a number is always a malformed payload, and epoch seconds vs milliseconds
36
+ * is not decidable from the value anyway. A compliance-facing card that prints a confident wrong
37
+ * certification date is worse than one that prints none.
38
+ *
39
+ * The guard is on the TYPE, not the value: the string "42" still parses as the year 2042, exactly
40
+ * as `formatDate` does with it. Both helpers hand string parsing to the engine.
41
+ *
42
+ * One behaviour narrows: a `Date` from another realm fails `instanceof` and is now null instead of
43
+ * being coerced through `new Date(obj)`. Nothing passes a `Date` here at all — both call sites in
44
+ * `cert-modal-view.ts` pass `content.certifiedAt` straight off the parsed envelope.
25
45
  */
26
46
  export function formatCertDisplayDate(
27
47
  iso: string | Date | null | undefined,
28
48
  ): string | null {
29
49
  if (!iso) return null;
50
+ if (typeof iso !== "string" && !(iso instanceof Date)) return null;
30
51
  const date = iso instanceof Date ? iso : new Date(iso);
31
52
  if (Number.isNaN(date.getTime())) return null;
32
53
  return date.toLocaleDateString("en-US", {
@@ -1,17 +1,45 @@
1
1
  /**
2
2
  * HTML escaping for the cert MODAL string builder — a BYTE-FAITHFUL relocation of
3
- * portal `src/lib/utils/html.ts` `escapeHtml` (the exact `.replace` chain the deployed
4
- * `certrev-cert.js` was bundled from — POR-10721 W2).
3
+ * the portal's `escapeHtml` (the exact `.replace` chain the deployed `certrev-cert.js`
4
+ * was bundled from).
5
5
  *
6
6
  * ⚠ Deliberately SEPARATE from `../components/escape.ts`: that SSR helper encodes the
7
7
  * apostrophe as `&#39;`, THIS one as `&#039;`. They render identically in a browser, but
8
8
  * this module is a byte-frozen relocation of the live Shopify asset, so it must reproduce
9
9
  * portal's exact `&#039;` bytes — the two escapes are NOT interchangeable here. (Unifying
10
10
  * them is a deliberate post-publish follow-up, tracked with the unification program.)
11
+ *
12
+ * The two files are separate about BYTES and identical about TYPES: `asText` below mirrors
13
+ * `../components/escape.ts`'s guard of the same name, for the same reason. This escape runs
14
+ * downstream of a `JSON.parse(...) as CertDeliveryEnvelope` cast, and `verifyEnvelope` checks
15
+ * the SIGNATURE, never the SHAPE — so a number / object / array arrives where the contract
16
+ * promised a string, and `text.replace` on one is a `TypeError`. In the browser that throw
17
+ * takes down the dialog the reader clicked, so the family's contract is NEVER THROW.
11
18
  */
12
19
 
13
- export function escapeHtml(text: string): string {
14
- return text
20
+ /**
21
+ * Coerce an untyped value to renderable text — byte-for-byte the same rule the components
22
+ * copy applies: a string passes through UNTOUCHED (the escape chain below is the only XSS
23
+ * boundary the modal has, and any change to its string output is a security regression);
24
+ * a finite number renders as its digits; everything else — null, undefined, booleans,
25
+ * objects, arrays, NaN/Infinity — renders as empty.
26
+ *
27
+ * Deliberately not `String(input)`: blind coercion splices `[object Object]` (or a hostile
28
+ * `toString`'s payload) into the dialog, and on a null-prototype object it throws the very
29
+ * TypeError this exists to prevent. A value that is not text is not copy.
30
+ *
31
+ * Exported for `cert-modal-view.ts`, whose pre-escape `.trim()` reads need the same guard —
32
+ * a coercion applied only inside `escapeHtml` would still leave the dereference above it
33
+ * throwing. Modal-internal (the `/modal` entry does not re-export it).
34
+ */
35
+ export function asText(input: unknown): string {
36
+ if (typeof input === "string") return input;
37
+ if (typeof input === "number") return Number.isFinite(input) ? String(input) : "";
38
+ return "";
39
+ }
40
+
41
+ export function escapeHtml(text: string | null | undefined): string {
42
+ return asText(text)
15
43
  .replace(/&/g, "&amp;")
16
44
  .replace(/</g, "&lt;")
17
45
  .replace(/>/g, "&gt;")
@@ -1,16 +1,13 @@
1
- // ⚠ VENDORED, BYTE-FAITHFUL copy of portal `src/lib/cert-delivery/fonts/font-face.generated.ts`
2
- // (POR-10721 W2) — the exact @font-face block the deployed `certrev-cert.js` was bundled from.
3
- // Regenerated upstream in portal; do not hand-edit the CSS here (a drift would break byte-parity).
4
- //
5
- // GENERATED by scripts/build-cert-fonts.mjs — DO NOT EDIT BY HAND.
1
+ // ⚠ VENDORED, BYTE-FAITHFUL copy of the portal's GENERATED cert @font-face module — the exact
2
+ // @font-face block the deployed `certrev-cert.js` was bundled from. It is generated (and
3
+ // regenerated) upstream in portal, never in this repo: do not hand-edit the CSS here, because a
4
+ // drift would break byte-parity with the deployed asset.
6
5
  //
7
6
  // The CertREV self-hosted cert @font-face block (absolute R2 URLs on assets.certrev.com),
8
- // mirrored from the block spliced into
9
- // shopify/extensions/certrev-schema/assets/certrev-cert.css so the surfaces that CAN'T @import a
10
- // .css asset share ONE canonical declaration: the standalone /embed renderers and the
11
- // <certrev-cert-modal> web component (POR-10657). Regenerate with
12
- // `node scripts/build-cert-fonts.mjs` (or `--emit-ts-only` to re-derive from the CSS without
13
- // re-downloading). Inline this inside a <style> to load the fonts non-blocking from R2.
7
+ // mirrored from the block spliced into the Shopify extension's `certrev-cert.css` so the surfaces
8
+ // that CAN'T @import a .css asset share ONE canonical declaration: the standalone /embed renderers
9
+ // and the <certrev-cert-modal> web component. Inline this inside a <style> to load the fonts
10
+ // non-blocking from R2.
14
11
 
15
12
  export const CERT_FONT_FACE_CSS = `
16
13
  @font-face {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The verbatim FTC material-connection + scope disclosure line — a BYTE-FAITHFUL relocation of
3
- * the (tree-shaken) `FTC_DISCLOSURE_LINE` from portal `src/lib/embed/ftc-disclosure.ts`
4
- * (POR-9317 / POR-10721 W2). Legally load-bearing: the curly apostrophe (U+2019) in "article’s"
3
+ * the (tree-shaken) `FTC_DISCLOSURE_LINE` from the portal's embed disclosure
4
+ * module. Legally load-bearing: the curly apostrophe (U+2019) in "article’s"
5
5
  * is byte-exact; a reword is a contract divergence.
6
6
  *
7
7
  * Kept as its OWN module (imported by both ftc-guard + display-strings, exactly as in portal) so
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * FTC disclosure tamper guard for the NATIVE Shopify cert render (iframe→native parity).
3
3
  *
4
- * In the cross-origin iframe model the expert-memo embed inlined `ftcTamperGuardScript()`
5
- * (src/lib/embed/ftc-disclosure.ts): a kill switch that blanks the memo if the verbatim FTC
4
+ * In the cross-origin iframe model the expert-memo embed inlined the portal's
5
+ * `ftcTamperGuardScript()`: a kill switch that blanks the memo if the verbatim FTC
6
6
  * material-connection disclosure is stripped, hidden, or altered — making the MSA §3
7
7
  * anti-stripping covenant TECHNICALLY enforced, not just contractual. The native memo renders
8
8
  * in the host theme's DOM (no sandbox), so without this it would lose that runtime enforcement.
@@ -17,6 +17,13 @@
17
17
  * - On tamper it neutralizes ONLY the memo (never `document.body` — we're in the host page).
18
18
  * - The contributors card is governed by the badge re-verify (`revalidate.ts`), not this guard;
19
19
  * the card carries only the one-word "Compensated" cue, never the guarded disclosure.
20
+ * - COVERAGE, and it is narrower than it reads: `.certrev-memo` / `.certrev-memo-wrap` are portal
21
+ * LIQUID markup — NOTHING in this package emits either. So the kill switch covers the Liquid
22
+ * memo surface and nothing else. `<CertBadge>` and `render-badge-html.ts` emit the same
23
+ * `[data-ftc-line]` hook inside a `.certrev-badge` (no memo), and `renderCertBlock` emits no
24
+ * hook at all; on those surfaces a pass verifies nothing and says so (`'unguarded'`) rather
25
+ * than reporting success, and no observer is installed at all. Widening enforcement to the
26
+ * badge is a live design decision, not an omission — see `FtcEnforcementResult`.
20
27
  *
21
28
  * Pure DOM, no crypto, no network. The expected text is the single source of truth
22
29
  * `FTC_DISCLOSURE_LINE` (imported, not copied — so a reword can never desync this guard).
@@ -30,6 +37,21 @@ export const MEMO_WRAP_SELECTOR = '.certrev-memo-wrap'
30
37
  /** Marker on the neutral notice node that replaces a tampered memo (testable / idempotent). */
31
38
  export const FTC_NEUTRALIZED_ATTR = 'data-certrev-ftc-neutralized'
32
39
 
40
+ /**
41
+ * Link qualification for the notice's CertREV anchor — a CertREV link on a brand's page that the
42
+ * brand did not author, so it carries the same qualification as every other anchor this package
43
+ * emits. Deliberately a LOCAL literal rather than an import of `CERTREV_LINK_REL`
44
+ * (`../components/rel.ts`): `src/modal/` imports NOTHING from `src/components/` by design — every
45
+ * shared string here (`ftc-disclosure.ts`, `logo-paths.ts`, `display-strings.ts`) is a modal-local
46
+ * copy so the browser IIFE never drags the SSR renderer in. `ftc-guard.test.ts` imports the
47
+ * components constant and asserts this value stays a superset of it, so the copies cannot drift.
48
+ *
49
+ * `noreferrer` rides ON TOP of the shared tokens (which omit it): this is the only anchor the
50
+ * package opens with `target="_blank"`, and it fires from a page whose operator has just stripped
51
+ * the disclosure — withholding the referrer there is worth the token.
52
+ */
53
+ const NOTICE_LINK_REL = 'nofollow sponsored noopener noreferrer'
54
+
33
55
  /** Documents already guarded, so a double script-load can't double-bind. */
34
56
  const guardedDocs = new WeakSet<Document>()
35
57
 
@@ -65,32 +87,63 @@ export function neutralizeMemo(memo: Element): void {
65
87
  'font-family:system-ui,-apple-system,sans-serif;font-size:12px;color:#555;padding:12px;line-height:1.4'
66
88
  notice.innerHTML =
67
89
  'This CertREV verification cannot be displayed because its required disclosure was not shown. ' +
68
- '<a href="https://certrev.com" style="color:#555" target="_blank" rel="noopener noreferrer">Learn more</a>.'
90
+ `<a href="https://certrev.com" style="color:#555" target="_blank" rel="${NOTICE_LINK_REL}">Learn more</a>.`
69
91
  memo.replaceWith(notice)
70
92
  }
71
93
 
94
+ /**
95
+ * What ONE enforcement pass actually established:
96
+ * - `'intact'` — at least one memo was checked, and every one carried its verbatim, visible line.
97
+ * - `'tampered'` — at least one memo failed the check and was neutralized.
98
+ * - `'unguarded'` — the document holds no `.certrev-memo`, so the pass checked nothing.
99
+ *
100
+ * `'unguarded'` used to be reported as `true`, indistinguishable from `'intact'`, and that
101
+ * conflation was a correctness bug, not a cosmetic one: `.certrev-memo` is portal Liquid markup
102
+ * this package never emits, so on every React / Web Component / `renderCertBlock` surface the pass
103
+ * finds nothing and answered "all intact" about a disclosure it had never looked at — the kill
104
+ * switch silently reporting success on exactly the surfaces it does not protect.
105
+ *
106
+ * The legitimate no-memo certified article (card only, no memo, no disclosure owed) lands here too,
107
+ * and the guard genuinely cannot tell the two apart from the DOM alone. That is the point of a
108
+ * third state rather than a second boolean: "nothing was verified" is the honest answer to both,
109
+ * and it is the caller — which knows which surface it rendered — that can tell whether that is
110
+ * expected or a coverage gap.
111
+ */
112
+ export type FtcEnforcementResult = 'intact' | 'tampered' | 'unguarded'
113
+
72
114
  /**
73
115
  * Enforce the disclosure across every memo in the doc: neutralize any whose disclosure isn't
74
- * intact. A doc with no `.certrev-memo` (no-memo article) is a no-op. Returns true if all memos
75
- * present were intact (or none existed).
116
+ * intact. A doc with no `.certrev-memo` is a no-op — see `FtcEnforcementResult` for why that is
117
+ * `'unguarded'` and not success.
76
118
  */
77
- export function enforceFtcDisclosure(doc: Document): boolean {
78
- let allIntact = true
79
- for (const memo of Array.from(doc.querySelectorAll(MEMO_SELECTOR))) {
119
+ export function enforceFtcDisclosure(doc: Document): FtcEnforcementResult {
120
+ const memos = Array.from(doc.querySelectorAll(MEMO_SELECTOR))
121
+ if (!memos.length) return 'unguarded'
122
+ let result: FtcEnforcementResult = 'intact'
123
+ for (const memo of memos) {
80
124
  if (!isDisclosureIntact(memo)) {
81
- allIntact = false
125
+ result = 'tampered'
82
126
  neutralizeMemo(memo)
83
127
  }
84
128
  }
85
- return allIntact
129
+ return result
86
130
  }
87
131
 
88
132
  /**
89
133
  * Install the guard once per document: enforce now (on DOM ready) and re-enforce on later DOM
90
- * mutations within the memo wrap(s) — so a host script that strips / hides / edits the disclosure
91
- * after load still trips the kill switch. Scoping the observer to the small `.certrev-memo-wrap`
92
- * subtree(s) keeps it cheap on a busy storefront page (cf. the iframe, which observed its whole
93
- * — tiny — body). Idempotent.
134
+ * mutations within the guarded subtree(s) — so a host script that strips / hides / edits the
135
+ * disclosure after load still trips the kill switch. Idempotent.
136
+ *
137
+ * The observer scope is the memo wrap(s) — placement.ts relocates a wrap as a node, so the observer
138
+ * tracks it wherever it moves — plus any memo sitting outside a wrap. When the document has
139
+ * NEITHER, NO observer is installed at all. That bail-out is the whole storefront case, not an edge
140
+ * one: this package emits neither selector (both are portal Liquid), so the old `<body>` fallback
141
+ * was the only branch a React / Web Component / `renderCertBlock` page could take, and it pinned a
142
+ * subtree + attributes + characterData observer — no `attributeFilter`, no coalescing — on the
143
+ * host's entire page for the life of the tab, re-running an enforce pass on EVERY mutation a busy
144
+ * storefront makes, to query a selector that could never match. A guard watching a page it cannot
145
+ * act on is pure cost; `enforceFtcDisclosure`'s `'unguarded'` result is the correctness half of the
146
+ * same gap.
94
147
  */
95
148
  export function installFtcGuard(doc: Document): void {
96
149
  if (guardedDocs.has(doc)) return
@@ -104,11 +157,13 @@ export function installFtcGuard(doc: Document): void {
104
157
  run()
105
158
  const MO = doc.defaultView?.MutationObserver
106
159
  if (!MO) return
107
- // Observe the stable memo wrap(s) — placement.js relocates the wrap as a node, so the
108
- // observer tracks it wherever it moves. Fall back to <body> only if no wrap exists.
160
+ // The guarded roots: the stable wrap(s), plus any memo not inside one (in the Liquid shape a
161
+ // wrap always contains its memo, so this is normally just the wraps). No root ⇒ no observer.
109
162
  const wraps = Array.from(doc.querySelectorAll(MEMO_WRAP_SELECTOR))
110
- const scope = wraps.length ? wraps : doc.body ? [doc.body] : []
111
- for (const node of scope) {
163
+ const looseMemos = Array.from(doc.querySelectorAll(MEMO_SELECTOR)).filter(
164
+ (memo) => !memo.closest(MEMO_WRAP_SELECTOR),
165
+ )
166
+ for (const node of [...wraps, ...looseMemos]) {
112
167
  const mo = new MO(run)
113
168
  mo.observe(node, { childList: true, subtree: true, characterData: true, attributes: true })
114
169
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * `@certrev/cert-block/modal` — the browser cert MODAL + badge-revalidation Web Component
3
- * (POR-10721 W2). A BYTE-FAITHFUL relocation of portal `src/lib/cert-delivery/wc/`; the IIFE
3
+ * — a BYTE-FAITHFUL relocation of the portal's cert-delivery web-component sources. The IIFE
4
4
  * built from `certrev-cert.entry.ts` (`build:modal-bundle`) is structurally byte-identical to the
5
5
  * Shopify-deployed `certrev-cert.js`, so a headless brand can drop the SAME verified modal onto its
6
6
  * storefront (`<script src=".../dist/modal/certrev-cert.js">`) instead of vendoring a copy.
@@ -2,14 +2,14 @@
2
2
  * Cert-card INTERACTIONS for the native Shopify cert experience.
3
3
  *
4
4
  * Progressive enhancement, delegated from the document (one listener, survives the placement
5
- * reposition). Two behaviours (POR-9827 chevron model):
5
+ * reposition). Two behaviours (the chevron model):
6
6
  *
7
7
  * • Accordion bios — a contributor row carrying `[data-certrev-acc]` toggles its `.open` class
8
8
  * (revealing the row's `.acc-bio` in place; the name caret rotates via CSS) and mirrors the
9
9
  * state on `aria-expanded`. A click on a link inside the row (e.g. the "Compensated expert"
10
10
  * cue) is NOT a toggle. Keyboard: Enter/Space on the role=button row.
11
11
  * • In-page modals: a `[data-certrev-modal-open]` control opens the unified
12
- * `<certrev-cert-modal>` element (POR-10102) via its `.open('cert'|'expert')` method. Bare /
12
+ * `<certrev-cert-modal>` element via its `.open('cert'|'expert')` method. Bare /
13
13
  * `"cert"` opens the certificate dialog; `"expert"` opens the reviewer dialog. With no JS / no
14
14
  * element / no envelope a link control falls back to its href (the certificate or the CertREV
15
15
  * profile page). The element owns its OWN close plumbing (× / Close / backdrop / Esc) inside its
@@ -1,5 +1,5 @@
1
- // ⚠ VENDORED, BYTE-FAITHFUL copy of portal `src/lib/design-guide/logo.ts` `LOGO_PATHS`
2
- // (POR-10721 W2). Byte-identical to `../components/render-cert-block.ts`'s private copy;
1
+ // ⚠ VENDORED, BYTE-FAITHFUL copy of the portal design-guide's `LOGO_PATHS`.
2
+ // Byte-identical to `../components/render-cert-block.ts`'s private copy;
3
3
  // kept modal-local so the browser bundle never pulls in the SSR renderer. Unifying the
4
4
  // package's two copies onto one shared leaf module is a post-publish follow-up.
5
5
  /**
@@ -18,7 +18,7 @@
18
18
  * When no references exist, each falls back to a chrome-aware semantic-landmark / theme-class
19
19
  * scan, with a STYLED end-of-article fallback when nothing matches.
20
20
  * (c) Records the matched tier on each wrap as `data-certrev-placement` — the observability the
21
- * pre-launch theme-matrix sweep + the POR-9578 liveness check read, so a silent misplacement
21
+ * pre-launch theme-matrix sweep + the liveness check read, so a silent misplacement
22
22
  * becomes an inspectable signal rather than a surprise on a real brand's store.
23
23
  *
24
24
  * It runs BEFORE the badge re-verify pass and never moves a wrap twice, so the `<certrev-badge>`
@@ -62,8 +62,9 @@ export const TOP_CONTENT_SELECTOR = TOP_ANCHORS.map((a) => a.selector).join(', '
62
62
  * footer, nav, or cart drawer often carries its own `.rte` that precedes the article in document
63
63
  * order — IntelliPure's announcement bar is literally `<div class="announcement__content … rte">`.
64
64
  * `isRenderable` can't reject those (they're visible), so `querySelector('.rte')` returned the
65
- * announcement bar and the contributors card was injected there instead of the article (POR-9809 —
66
- * "contributors not showing"). Placement scopes anchors out of chrome STRUCTURALLY via this set. */
65
+ * announcement bar and the contributors card was injected there instead of the article (the
66
+ * "contributors not showing" regression). Placement scopes anchors out of chrome STRUCTURALLY
67
+ * via this set. */
67
68
  export const SITE_CHROME_SELECTOR =
68
69
  'header, [role="banner"], [class*="announcement"], footer, [role="contentinfo"], nav, [role="navigation"], cart-drawer, .cart-drawer, .drawer, .mini-cart, .modal, [role="dialog"]'
69
70
 
@@ -127,7 +128,7 @@ function isRenderable(el: Element): boolean {
127
128
  * Whether `el` sits inside SITE CHROME (announcement bar / header / footer / nav / cart drawer /
128
129
  * modal). Complements `isRenderable`: chrome anchors are usually VISIBLE, so the visibility guard
129
130
  * can't reject them — but a cert wrap must never anchor there. This is the guard that keeps the
130
- * contributors card out of IntelliPure's `.rte`-classed announcement bar (POR-9809).
131
+ * contributors card out of IntelliPure's `.rte`-classed announcement bar.
131
132
  */
132
133
  function isInSiteChrome(el: Element): boolean {
133
134
  return el.closest(SITE_CHROME_SELECTOR) !== null
@@ -143,7 +144,7 @@ function anchorTier(el: Element, anchors: ReadonlyArray<{ readonly selector: str
143
144
 
144
145
  /**
145
146
  * First anchor that exists, is RENDERABLE (not in a hidden drawer/popup), is NOT site chrome (a
146
- * visible announcement bar / header / nav — POR-9809), and is safely positionable relative to
147
+ * visible announcement bar / header / nav), and is safely positionable relative to
147
148
  * `wrap`. Iterates ALL matches per selector (not just the first in document order) so an unusable
148
149
  * early match (a closed drawer's `.rte`, or the announcement bar's) is skipped for the article copy.
149
150
  */
@@ -210,7 +211,7 @@ export function placeWrap(doc: Document, wrap: HTMLElement): PlaceResult {
210
211
  // on a certified article) lives INSIDE the body-content container, so `closest(TOP_CONTENT_SELECTOR)`
211
212
  // resolves the exact article `.rte` / `article` / `main` — never a same-classed site-chrome element
212
213
  // that merely precedes the article in document order (IntelliPure's `.rte` announcement bar, which
213
- // is visible so isRenderable can't reject it — POR-9809 "contributors not showing"). This also ties
214
+ // is visible so isRenderable can't reject it — the "contributors not showing" regression). This ties
214
215
  // the card + memo to the SAME article region by construction.
215
216
  const refs = doc.querySelector(REFERENCES_SELECTOR)
216
217
  const owner = refs?.closest(TOP_CONTENT_SELECTOR) ?? null
@@ -11,10 +11,9 @@
11
11
  * • verdict `render` → leave the SSR badge (verified live);
12
12
  * • verdict `suppress` (invalid-sig / subject-mismatch / revoked / expired) → HIDE it
13
13
  * (fail-closed), stamping `data-certrev-suppressed` with the verdict's reason. A revoked
14
- * placement now serves the SLIM `CertTombstone` (POR-10481), which `verifyArtifact`
14
+ * placement now serves the SLIM `CertTombstone`, which `verifyArtifact`
15
15
  * resolves to `suppress:'revoked'` — so the badge hides AND the suppressed-reason telemetry
16
- * is right (`verifyEnvelope` used to mis-report it as `unsupported_contract_version`;
17
- * POR-10684);
16
+ * is right (`verifyEnvelope` used to mis-report it as `unsupported_contract_version`);
18
17
  * • the Delivery API 404s (no servable artifact) or the network errors → LEAVE the SSR badge.
19
18
  * The SSR copy already passed the Liquid lifecycle gate against the app-owned metafield
20
19
  * (the authoritative revoke/expire surface — the binding retracts it on revoke), so the WC
@@ -5,9 +5,9 @@
5
5
  * `@certrev/cert-contract` (WebCrypto Ed25519 over RFC-8785 JCS — the same kernel the server +
6
6
  * every other edge run). No crypto is reimplemented here.
7
7
  *
8
- * `verifyArtifact` (POR-10684, cert-contract 0.3.0) dispatches on the `kind: 'tombstone'`
8
+ * `verifyArtifact` (cert-contract 0.3.0) dispatches on the `kind: 'tombstone'`
9
9
  * discriminator: a full `CertDeliveryEnvelope` runs the whole crypto + lifecycle pipeline, while
10
- * the SLIM `CertTombstone` the control plane now serves for a revoked placement (POR-10481) —
10
+ * the SLIM `CertTombstone` the control plane now serves for a revoked placement —
11
11
  * which carries NO payload — verifies its own signature + subject and resolves to
12
12
  * `suppress:'revoked'`. The old `verifyEnvelope` fails a tombstone's shape-check and returns
13
13
  * `suppress:'unsupported_contract_version'` (still a blank badge, but the wrong suppressed-reason
@@ -13,9 +13,9 @@
13
13
  * still recovering quickly (short negative TTL).
14
14
  *
15
15
  * It is deliberately tiny + dependency-free (a Map + timestamps) so it runs in any server
16
- * runtime (Node, edge, workerd). For multi-instance deployments this is per-instance L1;
17
- * pair with a shared CDN/KV cache (the Delivery API is CDN-cacheable on
18
- * `lifecycle.revision`) for L2 — see README.
16
+ * runtime (Node, edge, workerd). For multi-instance deployments this is per-instance L1 and
17
+ * nothing more: each instance keeps its own copy, so an L2 (the CDN in front of the Delivery
18
+ * API, which is cacheable on `lifecycle.revision`, or a shared KV) is the caller's to add.
19
19
  */
20
20
 
21
21
  export interface TtlCacheOptions {
@@ -68,8 +68,19 @@ export class TtlCache<V> {
68
68
  * TTL; if `isNegative(value)` returns true (e.g. a 'suppress' verdict) it's cached
69
69
  * with the shorter negative TTL so a transient failure recovers fast. A thrown loader
70
70
  * is NOT cached (it rejects all current waiters and the next call retries).
71
+ *
72
+ * `positiveTtlMs` overrides the cache's positive TTL FOR THIS WRITE only, so one caller
73
+ * can hold a verdict longer/shorter than the cache's default without owning the cache.
74
+ * It deliberately does NOT apply to a negative result: a caller asking for a 10-minute
75
+ * positive TTL is asking for fewer origin reads, not for a transient failure to be
76
+ * remembered for ten minutes (see the negative-TTL rationale in the module header).
71
77
  */
72
- async getOrLoad(key: string, loader: () => Promise<V>, isNegative?: (v: V) => boolean): Promise<V> {
78
+ async getOrLoad(
79
+ key: string,
80
+ loader: () => Promise<V>,
81
+ isNegative?: (v: V) => boolean,
82
+ positiveTtlMs?: number,
83
+ ): Promise<V> {
73
84
  const cached = this.peek(key)
74
85
  if (cached !== undefined) return cached
75
86
 
@@ -79,7 +90,7 @@ export class TtlCache<V> {
79
90
  const promise = (async () => {
80
91
  const value = await loader()
81
92
  const negative = isNegative ? isNegative(value) : false
82
- this.set(key, value, negative)
93
+ this.set(key, value, negative, negative ? undefined : positiveTtlMs)
83
94
  return value
84
95
  })().finally(() => {
85
96
  this.inflight.delete(key)
@@ -89,14 +100,16 @@ export class TtlCache<V> {
89
100
  return promise
90
101
  }
91
102
 
92
- /** Insert/overwrite an entry with the appropriate TTL. */
93
- set(key: string, value: V, negative = false): void {
103
+ /** Insert/overwrite an entry. `ttlMsOverride` replaces the TTL this write would otherwise
104
+ * get (positive or negative); omit it for the cache's configured TTLs. */
105
+ set(key: string, value: V, negative = false, ttlMsOverride?: number): void {
94
106
  if (this.store.size >= this.maxEntries && !this.store.has(key)) {
95
107
  // Evict the oldest inserted key (Map preserves insertion order).
96
108
  const oldest = this.store.keys().next().value
97
109
  if (oldest !== undefined) this.store.delete(oldest)
98
110
  }
99
- const ttl = negative ? this.negativeTtlMs : this.ttlMs
111
+ const configured = negative ? this.negativeTtlMs : this.ttlMs
112
+ const ttl = ttlMsOverride !== undefined && Number.isFinite(ttlMsOverride) ? ttlMsOverride : configured
100
113
  this.store.set(key, { value, expiresAt: this.now() + ttl, negative })
101
114
  }
102
115
 
@@ -105,6 +118,19 @@ export class TtlCache<V> {
105
118
  this.store.delete(key)
106
119
  }
107
120
 
121
+ /**
122
+ * Drop every key under a prefix. WHY it exists: a cache key identifies everything the
123
+ * cached value depends on, which for a verdict includes the live content hash the render
124
+ * observed — a fact a revocation webhook does not know. Invalidating by prefix lets the
125
+ * webhook drop a placement without having to reconstruct every render context that ever
126
+ * cached it.
127
+ */
128
+ deletePrefix(prefix: string): void {
129
+ for (const key of this.store.keys()) {
130
+ if (key.startsWith(prefix)) this.store.delete(key)
131
+ }
132
+ }
133
+
108
134
  clear(): void {
109
135
  this.store.clear()
110
136
  this.inflight.clear()