@certrev/cert-block 0.3.0 → 0.5.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 (129) hide show
  1. package/README.md +48 -1
  2. package/dist/builder/cert-chrome-data.d.ts +129 -0
  3. package/dist/builder/cert-chrome-data.d.ts.map +1 -0
  4. package/dist/builder/cert-chrome-data.js +59 -0
  5. package/dist/builder/cert-chrome-data.js.map +1 -0
  6. package/dist/builder/cert-review-card.d.ts +68 -0
  7. package/dist/builder/cert-review-card.d.ts.map +1 -0
  8. package/dist/builder/cert-review-card.js +129 -0
  9. package/dist/builder/cert-review-card.js.map +1 -0
  10. package/dist/builder/index.d.ts +2 -0
  11. package/dist/builder/index.d.ts.map +1 -1
  12. package/dist/builder/index.js +3 -0
  13. package/dist/builder/index.js.map +1 -1
  14. package/dist/components/preview-block.d.ts +43 -0
  15. package/dist/components/preview-block.d.ts.map +1 -0
  16. package/dist/components/preview-block.js +53 -0
  17. package/dist/components/preview-block.js.map +1 -0
  18. package/dist/components/render-cert-block.d.ts +50 -7
  19. package/dist/components/render-cert-block.d.ts.map +1 -1
  20. package/dist/components/render-cert-block.js +304 -80
  21. package/dist/components/render-cert-block.js.map +1 -1
  22. package/dist/components/render-def.d.ts +27 -0
  23. package/dist/components/render-def.d.ts.map +1 -1
  24. package/dist/components/render-def.js +38 -0
  25. package/dist/components/render-def.js.map +1 -1
  26. package/dist/index.d.ts +3 -2
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +3 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/modal/avatar-resize.d.ts +60 -0
  31. package/dist/modal/avatar-resize.d.ts.map +1 -0
  32. package/dist/modal/avatar-resize.js +91 -0
  33. package/dist/modal/avatar-resize.js.map +1 -0
  34. package/dist/modal/cert-modal-view.d.ts +74 -0
  35. package/dist/modal/cert-modal-view.d.ts.map +1 -0
  36. package/dist/modal/cert-modal-view.js +160 -0
  37. package/dist/modal/cert-modal-view.js.map +1 -0
  38. package/dist/modal/certrev-cert-modal.d.ts +72 -0
  39. package/dist/modal/certrev-cert-modal.d.ts.map +1 -0
  40. package/dist/modal/certrev-cert-modal.js +270 -0
  41. package/dist/modal/certrev-cert-modal.js.map +1 -0
  42. package/dist/modal/certrev-cert-modal.styles.d.ts +35 -0
  43. package/dist/modal/certrev-cert-modal.styles.d.ts.map +1 -0
  44. package/dist/modal/certrev-cert-modal.styles.js +488 -0
  45. package/dist/modal/certrev-cert-modal.styles.js.map +1 -0
  46. package/dist/modal/certrev-cert.entry.d.ts +19 -0
  47. package/dist/modal/certrev-cert.entry.d.ts.map +1 -0
  48. package/dist/modal/certrev-cert.entry.js +39 -0
  49. package/dist/modal/certrev-cert.entry.js.map +1 -0
  50. package/dist/modal/certrev-cert.js +557 -0
  51. package/dist/modal/display-strings.d.ts +22 -0
  52. package/dist/modal/display-strings.d.ts.map +1 -0
  53. package/dist/modal/display-strings.js +35 -0
  54. package/dist/modal/display-strings.js.map +1 -0
  55. package/dist/modal/escape.d.ts +13 -0
  56. package/dist/modal/escape.d.ts.map +1 -0
  57. package/dist/modal/escape.js +20 -0
  58. package/dist/modal/escape.js.map +1 -0
  59. package/dist/modal/font-face.d.ts +4 -0
  60. package/dist/modal/font-face.d.ts.map +1 -0
  61. package/dist/modal/font-face.js +137 -0
  62. package/dist/modal/font-face.js.map +1 -0
  63. package/dist/modal/ftc-disclosure.d.ts +12 -0
  64. package/dist/modal/ftc-disclosure.d.ts.map +1 -0
  65. package/dist/modal/ftc-disclosure.js +12 -0
  66. package/dist/modal/ftc-disclosure.js.map +1 -0
  67. package/dist/modal/ftc-guard.d.ts +50 -0
  68. package/dist/modal/ftc-guard.d.ts.map +1 -0
  69. package/dist/modal/ftc-guard.js +117 -0
  70. package/dist/modal/ftc-guard.js.map +1 -0
  71. package/dist/modal/index.d.ts +20 -0
  72. package/dist/modal/index.d.ts.map +1 -0
  73. package/dist/modal/index.js +25 -0
  74. package/dist/modal/index.js.map +1 -0
  75. package/dist/modal/interactions.d.ts +53 -0
  76. package/dist/modal/interactions.d.ts.map +1 -0
  77. package/dist/modal/interactions.js +147 -0
  78. package/dist/modal/interactions.js.map +1 -0
  79. package/dist/modal/logo-paths.d.ts +15 -0
  80. package/dist/modal/logo-paths.d.ts.map +1 -0
  81. package/dist/modal/logo-paths.js +24 -0
  82. package/dist/modal/logo-paths.js.map +1 -0
  83. package/dist/modal/placement.d.ts +95 -0
  84. package/dist/modal/placement.d.ts.map +1 -0
  85. package/dist/modal/placement.js +302 -0
  86. package/dist/modal/placement.js.map +1 -0
  87. package/dist/modal/revalidate.d.ts +48 -0
  88. package/dist/modal/revalidate.d.ts.map +1 -0
  89. package/dist/modal/revalidate.js +65 -0
  90. package/dist/modal/revalidate.js.map +1 -0
  91. package/dist/modal/verify-client.d.ts +24 -0
  92. package/dist/modal/verify-client.d.ts.map +1 -0
  93. package/dist/modal/verify-client.js +38 -0
  94. package/dist/modal/verify-client.js.map +1 -0
  95. package/package.json +10 -4
  96. package/src/__tests__/__snapshots__/render-cert-block.test.ts.snap +10 -6
  97. package/src/__tests__/preview-block.test.ts +84 -0
  98. package/src/__tests__/render-cert-block.test.ts +380 -5
  99. package/src/builder/__tests__/builder.test.tsx +71 -0
  100. package/src/builder/cert-chrome-data.ts +159 -0
  101. package/src/builder/cert-review-card.tsx +177 -0
  102. package/src/builder/index.tsx +18 -0
  103. package/src/components/preview-block.ts +74 -0
  104. package/src/components/render-cert-block.ts +387 -102
  105. package/src/components/render-def.ts +55 -0
  106. package/src/index.ts +9 -0
  107. package/src/modal/avatar-resize.ts +99 -0
  108. package/src/modal/cert-modal-view.test.ts +175 -0
  109. package/src/modal/cert-modal-view.ts +210 -0
  110. package/src/modal/certrev-cert-modal.styles.ts +494 -0
  111. package/src/modal/certrev-cert-modal.test.ts +125 -0
  112. package/src/modal/certrev-cert-modal.ts +273 -0
  113. package/src/modal/certrev-cert.entry.ts +44 -0
  114. package/src/modal/display-strings.ts +38 -0
  115. package/src/modal/escape.ts +20 -0
  116. package/src/modal/font-face.ts +138 -0
  117. package/src/modal/ftc-disclosure.ts +13 -0
  118. package/src/modal/ftc-guard.test.ts +153 -0
  119. package/src/modal/ftc-guard.ts +119 -0
  120. package/src/modal/index.ts +34 -0
  121. package/src/modal/interactions.test.ts +250 -0
  122. package/src/modal/interactions.ts +166 -0
  123. package/src/modal/logo-paths.ts +23 -0
  124. package/src/modal/placement.test.ts +398 -0
  125. package/src/modal/placement.ts +331 -0
  126. package/src/modal/revalidate.test.ts +111 -0
  127. package/src/modal/revalidate.ts +81 -0
  128. package/src/modal/verify-client.test.ts +164 -0
  129. package/src/modal/verify-client.ts +42 -0
@@ -0,0 +1,273 @@
1
+ /**
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.
5
+ *
6
+ * Design:
7
+ * - One element, one OPEN shadow root, two `<dialog>`s (`#cert`, `#expert`). Public
8
+ * `open('cert'|'expert')` → `dialog.showModal()` (guarded). Data is public SSR
9
+ * content already, so an open shadow adds no exposure and eases debugging/tests.
10
+ * - CSS is adopted via a shared constructable stylesheet (falls back to an inline
11
+ * `<style>` on older engines). Shadow scoping means the brand theme can't bleed in
12
+ * and these rules can't leak out — the literal-hex/theme-armor constraints that
13
+ * bind the `body_html` modal do not apply here.
14
+ * - Envelope acquisition, in priority order: (1) a light-DOM
15
+ * `<script type="application/json" data-certrev-envelope>` child (Shopify + WP SSR,
16
+ * parsed lazily on first open — no network, crawl-safe); (2) a `data-envelope`
17
+ * attribute (stories/tests); (3) a Delivery-API fetch from
18
+ * `{data-delivery-api}/api/cert/v1/delivery/{data-platform}/{data-external-id}`
19
+ * (Builder/Hydrogen). The triggers (`data-certrev-modal-open`) stay in the light
20
+ * DOM (badge/memo) and are wired by `interactions.ts`.
21
+ * - Close plumbing (×, Close, backdrop, Esc) lives HERE, not in `interactions.ts`:
22
+ * a click inside a shadow root is retargeted to the host for a document listener,
23
+ * so the document-level delegate can't see the close controls.
24
+ * - Fonts inject at document level on first open (font faces don't scope into a
25
+ * shadow root); deferring to first open avoids loading them on every article view.
26
+ */
27
+
28
+ import { type CertDeliveryArtifact, isTombstone } from '@certrev/cert-contract'
29
+ import { CERT_FONT_FACE_CSS, CERT_FONTS_ORIGIN } from './font-face.js'
30
+ import { type CertModalContent, renderCertDialogInner, renderExpertDialogInner } from './cert-modal-view.js'
31
+ import { CERT_MODAL_CSS } from './certrev-cert-modal.styles.js'
32
+
33
+ export const CERT_MODAL_TAG = 'certrev-cert-modal'
34
+ type ModalKind = 'cert' | 'expert'
35
+
36
+ /** Shared constructable stylesheet (one per document lifetime). */
37
+ let sharedSheet: CSSStyleSheet | null = null
38
+ function supportsConstructableSheets(): boolean {
39
+ return (
40
+ typeof CSSStyleSheet !== 'undefined' &&
41
+ 'replaceSync' in CSSStyleSheet.prototype &&
42
+ 'adoptedStyleSheets' in Document.prototype
43
+ )
44
+ }
45
+ function getSharedSheet(): CSSStyleSheet | null {
46
+ if (!supportsConstructableSheets()) return null
47
+ if (!sharedSheet) {
48
+ sharedSheet = new CSSStyleSheet()
49
+ sharedSheet.replaceSync(CERT_MODAL_CSS)
50
+ }
51
+ return sharedSheet
52
+ }
53
+
54
+ /**
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
57
+ * render-blocking `fonts.googleapis.com` stylesheet (which also leaked the visitor's IP to Google).
58
+ * @font-face at document level resolves across the shadow boundary, so the shadow-DOM modal picks
59
+ * these up exactly as it did the old Google <link>. A crossorigin preconnect warms the R2 origin
60
+ * (an @font-face fetch is always a CORS request). Idempotent across instances via the marker attr.
61
+ */
62
+ let fontsInjected = false
63
+ function injectFontsOnce(doc: Document): void {
64
+ if (fontsInjected) return
65
+ fontsInjected = true
66
+ if (doc.querySelector('style[data-certrev-modal-fonts]')) return
67
+ if (!doc.querySelector('link[data-certrev-modal-preconnect]')) {
68
+ const pre = doc.createElement('link')
69
+ pre.rel = 'preconnect'
70
+ pre.href = CERT_FONTS_ORIGIN
71
+ pre.crossOrigin = ''
72
+ pre.setAttribute('data-certrev-modal-preconnect', '')
73
+ doc.head.appendChild(pre)
74
+ }
75
+ const style = doc.createElement('style')
76
+ style.textContent = CERT_FONT_FACE_CSS
77
+ style.setAttribute('data-certrev-modal-fonts', '')
78
+ doc.head.appendChild(style)
79
+ }
80
+
81
+ export class CertRevCertModal extends HTMLElement {
82
+ private root: ShadowRoot
83
+ private rendered = false
84
+ private content: CertModalContent | null = null
85
+ private fetchInFlight: Promise<void> | null = null
86
+
87
+ constructor() {
88
+ super()
89
+ this.root = this.attachShadow({ mode: 'open' })
90
+ const sheet = getSharedSheet()
91
+ if (sheet) {
92
+ this.root.adoptedStyleSheets = [sheet]
93
+ } else {
94
+ const style = document.createElement('style')
95
+ style.textContent = CERT_MODAL_CSS
96
+ this.root.appendChild(style)
97
+ }
98
+ // One delegated close listener inside the shadow root.
99
+ this.root.addEventListener('click', (e) => this.onShadowClick(e))
100
+ }
101
+
102
+ /**
103
+ * Open the given dialog. Returns `true` when this element will handle the open
104
+ * (so a link-shaped trigger should `preventDefault`), `false` when it cannot
105
+ * (no envelope + no fetch source) so the caller lets the `href` fall back.
106
+ */
107
+ open(kind: ModalKind): boolean {
108
+ // A host page that already provides the five families (e.g. the design guide, which
109
+ // self-hosts them) suppresses the modal's font injection with `data-fonts="host"` — so
110
+ // embedding the component adds no font-src/style-src origins to that page's CSP.
111
+ if (this.getAttribute('data-fonts') !== 'host') injectFontsOnce(document)
112
+ if (this.ensureRendered()) {
113
+ this.show(kind)
114
+ return true
115
+ }
116
+ if (this.hasFetchSource()) {
117
+ void this.fetchThenShow(kind)
118
+ return true
119
+ }
120
+ return false
121
+ }
122
+
123
+ /** Parse the synchronous envelope source (embedded JSON / attr) + render once. */
124
+ private ensureRendered(): boolean {
125
+ if (this.rendered) return this.content != null
126
+ const content = this.readSyncContent()
127
+ if (!content) return false
128
+ this.content = content
129
+ this.renderDialogs(content)
130
+ this.rendered = true
131
+ return true
132
+ }
133
+
134
+ private readSyncContent(): CertModalContent | null {
135
+ const attr = this.getAttribute('data-envelope')
136
+ if (attr) return safeParseContent(attr)
137
+ const script = this.querySelector('script[data-certrev-envelope]')
138
+ if (script?.textContent) return safeParseContent(script.textContent)
139
+ return null
140
+ }
141
+
142
+ private hasFetchSource(): boolean {
143
+ return !!(
144
+ this.getAttribute('data-delivery-api') &&
145
+ this.getAttribute('data-platform') &&
146
+ this.getAttribute('data-external-id')
147
+ )
148
+ }
149
+
150
+ private async fetchThenShow(kind: ModalKind): Promise<void> {
151
+ if (!this.fetchInFlight) {
152
+ const base = (this.getAttribute('data-delivery-api') || '').replace(/\/$/, '')
153
+ const platform = this.getAttribute('data-platform') || ''
154
+ const externalId = this.getAttribute('data-external-id') || ''
155
+ this.fetchInFlight = (async () => {
156
+ try {
157
+ const res = await fetch(
158
+ `${base}/api/cert/v1/delivery/${encodeURIComponent(platform)}/${encodeURIComponent(externalId)}`,
159
+ {
160
+ cache: 'no-store',
161
+ credentials: 'omit',
162
+ },
163
+ )
164
+ if (!res.ok) return
165
+ const artifact = (await res.json()) as CertDeliveryArtifact
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
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
170
+ // "verified" content from a revoked/expired artifact.
171
+ if (isArtifactSuppressed(artifact)) {
172
+ this.markSuppressed()
173
+ return
174
+ }
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).
177
+ const content = isTombstone(artifact)
178
+ ? undefined
179
+ : (artifact.payload?.content as CertModalContent | undefined)
180
+ if (content) {
181
+ this.content = content
182
+ this.renderDialogs(content)
183
+ this.rendered = true
184
+ }
185
+ } catch {
186
+ // fail-open: no modal, the trigger's href (if any) already prevented — a dead click at worst
187
+ }
188
+ })()
189
+ }
190
+ await this.fetchInFlight
191
+ if (this.rendered) this.show(kind)
192
+ }
193
+
194
+ private renderDialogs(content: CertModalContent): void {
195
+ const cert = renderCertDialogInner(content)
196
+ const expert = renderExpertDialogInner(content)
197
+ const wrap = document.createElement('div')
198
+ wrap.innerHTML = `${cert ? `<dialog id="cert" class="crm-dialog crm-dialog--cert" aria-label="CertREV certificate">${cert}</dialog>` : ''}${expert ? `<dialog id="expert" class="crm-dialog crm-dialog--expert" aria-label="Verified reviewer profile">${expert}</dialog>` : ''}`
199
+ // keep the adopted stylesheet / fallback <style>; append the dialogs
200
+ for (const node of Array.from(wrap.childNodes)) this.root.appendChild(node)
201
+ }
202
+
203
+ private show(kind: ModalKind): void {
204
+ const dlg = this.root.getElementById(kind) as HTMLDialogElement | null
205
+ if (dlg && typeof dlg.showModal === 'function' && !dlg.open) dlg.showModal()
206
+ }
207
+
208
+ /**
209
+ * The modal kill-switch (POR-10101): a fetched envelope is REVOKED/EXPIRED. Don't open;
210
+ * mark the host suppressed (mirrors the badge revalidation's `data-certrev-suppressed`) and
211
+ * hide the light-DOM triggers so a stale click can't re-open a pulled cert. Best-effort DOM.
212
+ */
213
+ private markSuppressed(): void {
214
+ this.setAttribute('data-certrev-suppressed', '')
215
+ for (const t of Array.from(document.querySelectorAll('[data-certrev-modal-open]'))) {
216
+ t.setAttribute('hidden', '')
217
+ t.setAttribute('aria-hidden', 'true')
218
+ }
219
+ }
220
+
221
+ private onShadowClick(e: Event): void {
222
+ const target = e.target as HTMLElement | null
223
+ if (!target) return
224
+ // backdrop click (the click landed directly on the dialog element)
225
+ if (target instanceof HTMLDialogElement && target.classList.contains('crm-dialog')) {
226
+ target.close()
227
+ return
228
+ }
229
+ const closer = target.closest('[data-certrev-modal-close]')
230
+ if (closer) {
231
+ e.preventDefault()
232
+ closer.closest('dialog')?.close()
233
+ }
234
+ }
235
+ }
236
+
237
+ function safeParseContent(json: string): CertModalContent | null {
238
+ try {
239
+ const parsed = JSON.parse(json)
240
+ return parsed && typeof parsed === 'object' ? (parsed as CertModalContent) : null
241
+ } catch {
242
+ return null
243
+ }
244
+ }
245
+
246
+ /**
247
+ * The modal-side lifecycle gate (POR-10101 / POR-10684): a fetched Delivery-API artifact that is
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) —
250
+ * detected by the `kind: 'tombstone'` discriminator via `isTombstone`. It carries NO payload,
251
+ * so a `payload.lifecycle` read alone would MISS it (and `markSuppressed()` would never fire);
252
+ * • a legacy envelope with `lifecycle.revokedAt` set, or any envelope whose `expiresAt` passed.
253
+ * The Delivery API only serves CertREV-signed artifacts, so this shape read is a safe suppress
254
+ * signal for the display surface (the `<certrev-badge>` revalidation runs the full ed25519
255
+ * verdict via `verifyArtifact` — this is the modal's belt-and-suspenders).
256
+ */
257
+ export function isArtifactSuppressed(artifact: CertDeliveryArtifact | null | undefined): boolean {
258
+ if (!artifact) return false
259
+ if (isTombstone(artifact)) return true
260
+ const lc = artifact.payload?.lifecycle
261
+ if (!lc) return false
262
+ if (lc.revokedAt != null) return true
263
+ if (lc.expiresAt && Date.now() >= new Date(lc.expiresAt).getTime()) return true
264
+ return false
265
+ }
266
+
267
+ /** Register the element once (idempotent — a double script-load must not throw). */
268
+ export function defineCertModal(): void {
269
+ if (typeof customElements === 'undefined') return
270
+ if (!customElements.get(CERT_MODAL_TAG)) customElements.define(CERT_MODAL_TAG, CertRevCertModal)
271
+ }
272
+
273
+ defineCertModal()
@@ -0,0 +1,44 @@
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).
5
+ *
6
+ * Glue only: it resolves the published issuer key + the Delivery-API verifier (which runs the
7
+ * canonical `verifyEnvelope` kernel) and kicks off the DOM revalidation pass. No crypto here.
8
+ */
9
+
10
+ import type { ResolvePublicKeyByKid } from '@certrev/cert-contract'
11
+ import './certrev-cert-modal.js' // registers the unified <certrev-cert-modal> element (POR-10102)
12
+ import { installFtcGuard } from './ftc-guard.js'
13
+ import { initCertInteractionsOnReady } from './interactions.js'
14
+ import { initCertPlacement } from './placement.js'
15
+ import { initBadgeRevalidation } from './revalidate.js'
16
+ import { createDeliveryVerifier } from './verify-client.js'
17
+
18
+ /**
19
+ * The published CertREV issuer signing key (SPKI PEM) for kid `cert-issuer-1` — the PUBLIC
20
+ * 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.)
24
+ */
25
+ export const CERT_ISSUER_KID = 'cert-issuer-1'
26
+ export const CERT_ISSUER_PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----
27
+ MCowBQYDK2VwAyEAMWN956IOPjpAq900dL428VzA28TO/pVXnq3brwqUmwM=
28
+ -----END PUBLIC KEY-----`
29
+
30
+ const resolveKid: ResolvePublicKeyByKid = (kid) =>
31
+ kid === CERT_ISSUER_KID ? { format: 'pem', pem: CERT_ISSUER_PUBLIC_KEY_PEM } : null
32
+
33
+ if (typeof document !== 'undefined') {
34
+ // Placement runs FIRST — relocate the @graph to <head> + move the card wrapper to its final
35
+ // in-article position — so the badge re-verify pass below finds `<certrev-badge>` already in
36
+ // place and never re-runs against a detached/re-attached node.
37
+ initCertPlacement(document)
38
+ // Tap-to-expand contributor bios + the in-page certificate modal (progressive enhancement).
39
+ initCertInteractionsOnReady(document)
40
+ // FTC §3 anti-stripping: neutralize a memo whose verbatim disclosure was stripped/hidden/altered
41
+ // (restores the iframe kill switch in the native, in-DOM render). No-op when there's no memo.
42
+ installFtcGuard(document)
43
+ initBadgeRevalidation(document, { verify: createDeliveryVerifier(resolveKid) })
44
+ }
@@ -0,0 +1,38 @@
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.
5
+ *
6
+ * ⚠ Deliberately SEPARATE from `../components/render-def.ts`/`format.ts`. `formatCertDisplayDate`
7
+ * uses `toLocaleDateString` (NOT the components' fixed MONTHS array): the live modal formats dates
8
+ * via `toLocaleDateString` at runtime in the browser, so a fixed-array formatter would VISIBLY
9
+ * diverge on any browser whose ICU renders a month abbreviation differently (the "Sep"/"Sept"
10
+ * CLDR-42 case). Byte-parity with the deployed asset requires preserving the exact source.
11
+ */
12
+
13
+ import { FTC_DISCLOSURE_LINE } from "./ftc-disclosure.js";
14
+
15
+ /** The compensation cue (guide "line A"), byte-verbatim from portal `COMPENSATED_EXPERT_CUE`. */
16
+ export const COMPENSATED_EXPERT_CUE = "Compensated expert";
17
+
18
+ /** The FTC scope disclaimer, re-exported from the FTC SoT (portal `CERT_SCOPE_LINE = FTC_DISCLOSURE_LINE`). */
19
+ export const CERT_SCOPE_LINE = FTC_DISCLOSURE_LINE;
20
+
21
+ /**
22
+ * 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".
25
+ */
26
+ export function formatCertDisplayDate(
27
+ iso: string | Date | null | undefined,
28
+ ): string | null {
29
+ if (!iso) return null;
30
+ const date = iso instanceof Date ? iso : new Date(iso);
31
+ if (Number.isNaN(date.getTime())) return null;
32
+ return date.toLocaleDateString("en-US", {
33
+ month: "short",
34
+ day: "numeric",
35
+ year: "numeric",
36
+ timeZone: "UTC",
37
+ });
38
+ }
@@ -0,0 +1,20 @@
1
+ /**
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).
5
+ *
6
+ * ⚠ Deliberately SEPARATE from `../components/escape.ts`: that SSR helper encodes the
7
+ * apostrophe as `&#39;`, THIS one as `&#039;`. They render identically in a browser, but
8
+ * this module is a byte-frozen relocation of the live Shopify asset, so it must reproduce
9
+ * portal's exact `&#039;` bytes — the two escapes are NOT interchangeable here. (Unifying
10
+ * them is a deliberate post-publish follow-up, tracked with the unification program.)
11
+ */
12
+
13
+ export function escapeHtml(text: string): string {
14
+ return text
15
+ .replace(/&/g, "&amp;")
16
+ .replace(/</g, "&lt;")
17
+ .replace(/>/g, "&gt;")
18
+ .replace(/"/g, "&quot;")
19
+ .replace(/'/g, "&#039;");
20
+ }
@@ -0,0 +1,138 @@
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.
6
+ //
7
+ // 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.
14
+
15
+ export const CERT_FONT_FACE_CSS = `
16
+ @font-face {
17
+ font-family: "DM Sans";
18
+ font-style: normal;
19
+ font-weight: 400 700;
20
+ font-display: swap;
21
+ src: url("https://assets.certrev.com/fonts/cert-dm-sans-latin-ext.woff2") format("woff2");
22
+ unicode-range:
23
+ U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
24
+ }
25
+
26
+ @font-face {
27
+ font-family: "DM Sans";
28
+ font-style: normal;
29
+ font-weight: 400 700;
30
+ font-display: swap;
31
+ src: url("https://assets.certrev.com/fonts/cert-dm-sans-latin.woff2") format("woff2");
32
+ unicode-range:
33
+ U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
34
+ }
35
+
36
+ @font-face {
37
+ font-family: "Playfair Display";
38
+ font-style: italic;
39
+ font-weight: 400 600;
40
+ font-display: swap;
41
+ src: url("https://assets.certrev.com/fonts/cert-playfair-italic-latin-ext.woff2") format("woff2");
42
+ unicode-range:
43
+ U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
44
+ }
45
+
46
+ @font-face {
47
+ font-family: "Playfair Display";
48
+ font-style: italic;
49
+ font-weight: 400 600;
50
+ font-display: swap;
51
+ src: url("https://assets.certrev.com/fonts/cert-playfair-italic-latin.woff2") format("woff2");
52
+ unicode-range:
53
+ U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
54
+ }
55
+
56
+ @font-face {
57
+ font-family: "Playfair Display";
58
+ font-style: normal;
59
+ font-weight: 600 700;
60
+ font-display: swap;
61
+ src: url("https://assets.certrev.com/fonts/cert-playfair-latin-ext.woff2") format("woff2");
62
+ unicode-range:
63
+ U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
64
+ }
65
+
66
+ @font-face {
67
+ font-family: "Playfair Display";
68
+ font-style: normal;
69
+ font-weight: 600 700;
70
+ font-display: swap;
71
+ src: url("https://assets.certrev.com/fonts/cert-playfair-latin.woff2") format("woff2");
72
+ unicode-range:
73
+ U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
74
+ }
75
+
76
+ @font-face {
77
+ font-family: "JetBrains Mono";
78
+ font-style: normal;
79
+ font-weight: 400 700;
80
+ font-display: swap;
81
+ src: url("https://assets.certrev.com/fonts/cert-jetbrains-mono-latin-ext.woff2") format("woff2");
82
+ unicode-range:
83
+ U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
84
+ }
85
+
86
+ @font-face {
87
+ font-family: "JetBrains Mono";
88
+ font-style: normal;
89
+ font-weight: 400 700;
90
+ font-display: swap;
91
+ src: url("https://assets.certrev.com/fonts/cert-jetbrains-mono-latin.woff2") format("woff2");
92
+ unicode-range:
93
+ U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
94
+ }
95
+
96
+ @font-face {
97
+ font-family: "Plus Jakarta Sans";
98
+ font-style: normal;
99
+ font-weight: 700 800;
100
+ font-display: swap;
101
+ src: url("https://assets.certrev.com/fonts/cert-jakarta-latin-ext.woff2") format("woff2");
102
+ unicode-range:
103
+ U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
104
+ }
105
+
106
+ @font-face {
107
+ font-family: "Plus Jakarta Sans";
108
+ font-style: normal;
109
+ font-weight: 700 800;
110
+ font-display: swap;
111
+ src: url("https://assets.certrev.com/fonts/cert-jakarta-latin.woff2") format("woff2");
112
+ unicode-range:
113
+ U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
114
+ }
115
+
116
+ @font-face {
117
+ font-family: "Allura";
118
+ font-style: normal;
119
+ font-weight: 400;
120
+ font-display: swap;
121
+ src: url("https://assets.certrev.com/fonts/cert-allura-latin-ext.woff2") format("woff2");
122
+ unicode-range:
123
+ U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
124
+ }
125
+
126
+ @font-face {
127
+ font-family: "Allura";
128
+ font-style: normal;
129
+ font-weight: 400;
130
+ font-display: swap;
131
+ src: url("https://assets.certrev.com/fonts/cert-allura-latin.woff2") format("woff2");
132
+ unicode-range:
133
+ U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
134
+ }
135
+ `;
136
+
137
+ /** Preconnect origin for the self-hosted cert fonts (crossorigin — @font-face is always CORS). */
138
+ export const CERT_FONTS_ORIGIN = "https://assets.certrev.com";
@@ -0,0 +1,13 @@
1
+ /**
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"
5
+ * is byte-exact; a reword is a contract divergence.
6
+ *
7
+ * Kept as its OWN module (imported by both ftc-guard + display-strings, exactly as in portal) so
8
+ * the browser bundle emits the same `var` structure as the deployed certrev-cert.js — do not merge
9
+ * it into display-strings.ts (that consolidation drops one `var` and breaks byte-parity).
10
+ */
11
+
12
+ export const FTC_DISCLOSURE_LINE =
13
+ "Independent editorial review of this article’s text and sources. Not a product endorsement, nor medical advice." as const;
@@ -0,0 +1,153 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * Unit tests for the native FTC disclosure tamper guard (iframe→native parity for the MSA §3
4
+ * anti-stripping kill switch). Pure DOM. The expected text is the single source of truth
5
+ * `FTC_DISCLOSURE_LINE`, so these also pin the guard to the verbatim copy.
6
+ */
7
+
8
+ import { beforeEach, describe, expect, it } from "vitest";
9
+ import { FTC_DISCLOSURE_LINE } from "./ftc-disclosure.js";
10
+ import {
11
+ enforceFtcDisclosure,
12
+ FTC_NEUTRALIZED_ATTR,
13
+ installFtcGuard,
14
+ isDisclosureIntact,
15
+ neutralizeMemo,
16
+ } from "./ftc-guard.js";
17
+
18
+ beforeEach(() => {
19
+ document.head.innerHTML = "";
20
+ document.body.innerHTML = "";
21
+ });
22
+
23
+ /** jsdom returns a 0×0 box for everything; stub a non-zero rect so the visibility check passes. */
24
+ function makeVisible(el: Element): void {
25
+ (el as HTMLElement).getBoundingClientRect = () =>
26
+ ({
27
+ width: 200,
28
+ height: 18,
29
+ top: 0,
30
+ left: 0,
31
+ right: 200,
32
+ bottom: 18,
33
+ x: 0,
34
+ y: 0,
35
+ toJSON() {},
36
+ }) as DOMRect;
37
+ }
38
+
39
+ /** Mount a memo wrap + section carrying the verbatim disclosure line. Returns the line node too. */
40
+ function mountMemo(lineText: string = FTC_DISCLOSURE_LINE): {
41
+ wrap: HTMLElement;
42
+ memo: HTMLElement;
43
+ line: HTMLElement;
44
+ } {
45
+ const wrap = document.createElement("div");
46
+ wrap.className = "certrev-memo-wrap";
47
+ wrap.innerHTML = `
48
+ <section class="certrev-memo">
49
+ <p class="certrev-memo__ftc" data-ftc-disclosure><span data-ftc-line>${lineText}</span></p>
50
+ </section>`;
51
+ document.body.appendChild(wrap);
52
+ const line = wrap.querySelector<HTMLElement>("[data-ftc-line]")!;
53
+ makeVisible(line);
54
+ return {
55
+ wrap,
56
+ memo: wrap.querySelector<HTMLElement>(".certrev-memo")!,
57
+ line,
58
+ };
59
+ }
60
+
61
+ describe("isDisclosureIntact", () => {
62
+ it("true when the line is present, verbatim, and visible", () => {
63
+ const { memo } = mountMemo();
64
+ expect(isDisclosureIntact(memo)).toBe(true);
65
+ });
66
+
67
+ it("false when the disclosure line node is missing (stripped)", () => {
68
+ const { memo, line } = mountMemo();
69
+ line.remove();
70
+ expect(isDisclosureIntact(memo)).toBe(false);
71
+ });
72
+
73
+ it("false when the text was altered (a single changed word)", () => {
74
+ const { memo } = mountMemo(
75
+ FTC_DISCLOSURE_LINE.replace("Independent", "Sponsored"),
76
+ );
77
+ expect(isDisclosureIntact(memo)).toBe(false);
78
+ });
79
+
80
+ it("false when the line is hidden via display:none", () => {
81
+ const { memo, line } = mountMemo();
82
+ line.style.display = "none";
83
+ expect(isDisclosureIntact(memo)).toBe(false);
84
+ });
85
+
86
+ it("false when the line is hidden via visibility:hidden", () => {
87
+ const { memo, line } = mountMemo();
88
+ line.style.visibility = "hidden";
89
+ expect(isDisclosureIntact(memo)).toBe(false);
90
+ });
91
+
92
+ it("false when the line is collapsed to a zero box (rect 0×0)", () => {
93
+ const { memo, line } = mountMemo();
94
+ (line as HTMLElement).getBoundingClientRect = () =>
95
+ ({ width: 0, height: 0 }) as DOMRect;
96
+ expect(isDisclosureIntact(memo)).toBe(false);
97
+ });
98
+ });
99
+
100
+ describe("neutralizeMemo", () => {
101
+ it("replaces the memo with the marked neutral notice (no attribution survives)", () => {
102
+ const { memo } = mountMemo();
103
+ memo.querySelector("span")!; // sanity: exists pre-neutralize
104
+ neutralizeMemo(memo);
105
+ expect(document.querySelector(".certrev-memo")).toBeNull();
106
+ const notice = document.querySelector(`[${FTC_NEUTRALIZED_ATTR}]`);
107
+ expect(notice).not.toBeNull();
108
+ expect(notice!.textContent).toContain("required disclosure was not shown");
109
+ });
110
+ });
111
+
112
+ describe("enforceFtcDisclosure", () => {
113
+ it("leaves an intact memo untouched and returns true", () => {
114
+ mountMemo();
115
+ expect(enforceFtcDisclosure(document)).toBe(true);
116
+ expect(document.querySelector(".certrev-memo")).not.toBeNull();
117
+ expect(document.querySelector(`[${FTC_NEUTRALIZED_ATTR}]`)).toBeNull();
118
+ });
119
+
120
+ it("neutralizes a tampered memo and returns false", () => {
121
+ const { line } = mountMemo();
122
+ line.remove(); // strip the disclosure
123
+ expect(enforceFtcDisclosure(document)).toBe(false);
124
+ expect(document.querySelector(".certrev-memo")).toBeNull();
125
+ expect(document.querySelector(`[${FTC_NEUTRALIZED_ATTR}]`)).not.toBeNull();
126
+ });
127
+
128
+ it("NO-OP (no false positive) when there is no memo at all — the no-memo certified path", () => {
129
+ // A certified article without an expert memo renders no .certrev-memo (only the card cue).
130
+ document.body.innerHTML =
131
+ '<section class="certrev-badge certrev-card">card only, no memo</section>';
132
+ expect(enforceFtcDisclosure(document)).toBe(true);
133
+ expect(document.querySelector(`[${FTC_NEUTRALIZED_ATTR}]`)).toBeNull();
134
+ expect(document.querySelector(".certrev-card")).not.toBeNull();
135
+ });
136
+ });
137
+
138
+ describe("installFtcGuard", () => {
139
+ it("enforces on install (document already interactive)", () => {
140
+ const { line } = mountMemo();
141
+ line.remove();
142
+ installFtcGuard(document);
143
+ expect(document.querySelector(`[${FTC_NEUTRALIZED_ATTR}]`)).not.toBeNull();
144
+ });
145
+
146
+ it("is idempotent — a second install does not double-bind / re-throw", () => {
147
+ mountMemo();
148
+ installFtcGuard(document);
149
+ expect(() => installFtcGuard(document)).not.toThrow();
150
+ // Still intact (the second call is a no-op; nothing neutralized a valid memo).
151
+ expect(document.querySelector(".certrev-memo")).not.toBeNull();
152
+ });
153
+ });