@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.
- package/README.md +48 -1
- package/dist/builder/cert-chrome-data.d.ts +129 -0
- package/dist/builder/cert-chrome-data.d.ts.map +1 -0
- package/dist/builder/cert-chrome-data.js +59 -0
- package/dist/builder/cert-chrome-data.js.map +1 -0
- package/dist/builder/cert-review-card.d.ts +68 -0
- package/dist/builder/cert-review-card.d.ts.map +1 -0
- package/dist/builder/cert-review-card.js +129 -0
- package/dist/builder/cert-review-card.js.map +1 -0
- package/dist/builder/index.d.ts +2 -0
- package/dist/builder/index.d.ts.map +1 -1
- package/dist/builder/index.js +3 -0
- package/dist/builder/index.js.map +1 -1
- package/dist/components/preview-block.d.ts +43 -0
- package/dist/components/preview-block.d.ts.map +1 -0
- package/dist/components/preview-block.js +53 -0
- package/dist/components/preview-block.js.map +1 -0
- package/dist/components/render-cert-block.d.ts +50 -7
- package/dist/components/render-cert-block.d.ts.map +1 -1
- package/dist/components/render-cert-block.js +304 -80
- package/dist/components/render-cert-block.js.map +1 -1
- package/dist/components/render-def.d.ts +27 -0
- package/dist/components/render-def.d.ts.map +1 -1
- package/dist/components/render-def.js +38 -0
- package/dist/components/render-def.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/modal/avatar-resize.d.ts +60 -0
- package/dist/modal/avatar-resize.d.ts.map +1 -0
- package/dist/modal/avatar-resize.js +91 -0
- package/dist/modal/avatar-resize.js.map +1 -0
- package/dist/modal/cert-modal-view.d.ts +74 -0
- package/dist/modal/cert-modal-view.d.ts.map +1 -0
- package/dist/modal/cert-modal-view.js +160 -0
- package/dist/modal/cert-modal-view.js.map +1 -0
- package/dist/modal/certrev-cert-modal.d.ts +72 -0
- package/dist/modal/certrev-cert-modal.d.ts.map +1 -0
- package/dist/modal/certrev-cert-modal.js +270 -0
- package/dist/modal/certrev-cert-modal.js.map +1 -0
- package/dist/modal/certrev-cert-modal.styles.d.ts +35 -0
- package/dist/modal/certrev-cert-modal.styles.d.ts.map +1 -0
- package/dist/modal/certrev-cert-modal.styles.js +488 -0
- package/dist/modal/certrev-cert-modal.styles.js.map +1 -0
- package/dist/modal/certrev-cert.entry.d.ts +19 -0
- package/dist/modal/certrev-cert.entry.d.ts.map +1 -0
- package/dist/modal/certrev-cert.entry.js +39 -0
- package/dist/modal/certrev-cert.entry.js.map +1 -0
- package/dist/modal/certrev-cert.js +557 -0
- package/dist/modal/display-strings.d.ts +22 -0
- package/dist/modal/display-strings.d.ts.map +1 -0
- package/dist/modal/display-strings.js +35 -0
- package/dist/modal/display-strings.js.map +1 -0
- package/dist/modal/escape.d.ts +13 -0
- package/dist/modal/escape.d.ts.map +1 -0
- package/dist/modal/escape.js +20 -0
- package/dist/modal/escape.js.map +1 -0
- package/dist/modal/font-face.d.ts +4 -0
- package/dist/modal/font-face.d.ts.map +1 -0
- package/dist/modal/font-face.js +137 -0
- package/dist/modal/font-face.js.map +1 -0
- package/dist/modal/ftc-disclosure.d.ts +12 -0
- package/dist/modal/ftc-disclosure.d.ts.map +1 -0
- package/dist/modal/ftc-disclosure.js +12 -0
- package/dist/modal/ftc-disclosure.js.map +1 -0
- package/dist/modal/ftc-guard.d.ts +50 -0
- package/dist/modal/ftc-guard.d.ts.map +1 -0
- package/dist/modal/ftc-guard.js +117 -0
- package/dist/modal/ftc-guard.js.map +1 -0
- package/dist/modal/index.d.ts +20 -0
- package/dist/modal/index.d.ts.map +1 -0
- package/dist/modal/index.js +25 -0
- package/dist/modal/index.js.map +1 -0
- package/dist/modal/interactions.d.ts +53 -0
- package/dist/modal/interactions.d.ts.map +1 -0
- package/dist/modal/interactions.js +147 -0
- package/dist/modal/interactions.js.map +1 -0
- package/dist/modal/logo-paths.d.ts +15 -0
- package/dist/modal/logo-paths.d.ts.map +1 -0
- package/dist/modal/logo-paths.js +24 -0
- package/dist/modal/logo-paths.js.map +1 -0
- package/dist/modal/placement.d.ts +95 -0
- package/dist/modal/placement.d.ts.map +1 -0
- package/dist/modal/placement.js +302 -0
- package/dist/modal/placement.js.map +1 -0
- package/dist/modal/revalidate.d.ts +48 -0
- package/dist/modal/revalidate.d.ts.map +1 -0
- package/dist/modal/revalidate.js +65 -0
- package/dist/modal/revalidate.js.map +1 -0
- package/dist/modal/verify-client.d.ts +24 -0
- package/dist/modal/verify-client.d.ts.map +1 -0
- package/dist/modal/verify-client.js +38 -0
- package/dist/modal/verify-client.js.map +1 -0
- package/package.json +10 -4
- package/src/__tests__/__snapshots__/render-cert-block.test.ts.snap +10 -6
- package/src/__tests__/preview-block.test.ts +84 -0
- package/src/__tests__/render-cert-block.test.ts +380 -5
- package/src/builder/__tests__/builder.test.tsx +71 -0
- package/src/builder/cert-chrome-data.ts +159 -0
- package/src/builder/cert-review-card.tsx +177 -0
- package/src/builder/index.tsx +18 -0
- package/src/components/preview-block.ts +74 -0
- package/src/components/render-cert-block.ts +387 -102
- package/src/components/render-def.ts +55 -0
- package/src/index.ts +9 -0
- package/src/modal/avatar-resize.ts +99 -0
- package/src/modal/cert-modal-view.test.ts +175 -0
- package/src/modal/cert-modal-view.ts +210 -0
- package/src/modal/certrev-cert-modal.styles.ts +494 -0
- package/src/modal/certrev-cert-modal.test.ts +125 -0
- package/src/modal/certrev-cert-modal.ts +273 -0
- package/src/modal/certrev-cert.entry.ts +44 -0
- package/src/modal/display-strings.ts +38 -0
- package/src/modal/escape.ts +20 -0
- package/src/modal/font-face.ts +138 -0
- package/src/modal/ftc-disclosure.ts +13 -0
- package/src/modal/ftc-guard.test.ts +153 -0
- package/src/modal/ftc-guard.ts +119 -0
- package/src/modal/index.ts +34 -0
- package/src/modal/interactions.test.ts +250 -0
- package/src/modal/interactions.ts +166 -0
- package/src/modal/logo-paths.ts +23 -0
- package/src/modal/placement.test.ts +398 -0
- package/src/modal/placement.ts +331 -0
- package/src/modal/revalidate.test.ts +111 -0
- package/src/modal/revalidate.ts +81 -0
- package/src/modal/verify-client.test.ts +164 -0
- 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 `'`, THIS one as `'`. 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 `'` 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, "&")
|
|
16
|
+
.replace(/</g, "<")
|
|
17
|
+
.replace(/>/g, ">")
|
|
18
|
+
.replace(/"/g, """)
|
|
19
|
+
.replace(/'/g, "'");
|
|
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
|
+
});
|