@certrev/cert-block 1.0.3 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +101 -0
- package/README.md +25 -6
- package/dist/builder/cert-chrome-data.d.ts +26 -1
- package/dist/builder/cert-chrome-data.d.ts.map +1 -1
- package/dist/builder/cert-chrome-data.js +1 -0
- package/dist/builder/cert-chrome-data.js.map +1 -1
- package/dist/builder/cert-review-card.d.ts +35 -2
- package/dist/builder/cert-review-card.d.ts.map +1 -1
- package/dist/builder/cert-review-card.js +112 -2
- package/dist/builder/cert-review-card.js.map +1 -1
- package/dist/builder/delivery-runtime.d.ts +23 -0
- package/dist/builder/delivery-runtime.d.ts.map +1 -0
- package/dist/builder/delivery-runtime.js +38 -0
- package/dist/builder/delivery-runtime.js.map +1 -0
- package/dist/builder/envelope-face.d.ts +63 -0
- package/dist/builder/envelope-face.d.ts.map +1 -0
- package/dist/builder/envelope-face.js +151 -0
- package/dist/builder/envelope-face.js.map +1 -0
- package/dist/builder/index.d.ts +15 -3
- package/dist/builder/index.d.ts.map +1 -1
- package/dist/builder/index.js +14 -2
- package/dist/builder/index.js.map +1 -1
- package/dist/builder/issuer-key.d.ts +12 -0
- package/dist/builder/issuer-key.d.ts.map +1 -0
- package/dist/builder/issuer-key.js +14 -0
- package/dist/builder/issuer-key.js.map +1 -0
- package/dist/components/memo-toggle.d.ts +42 -0
- package/dist/components/memo-toggle.d.ts.map +1 -0
- package/dist/components/memo-toggle.js +60 -0
- package/dist/components/memo-toggle.js.map +1 -0
- package/dist/components/render-cert-block.d.ts +9 -0
- package/dist/components/render-cert-block.d.ts.map +1 -1
- package/dist/components/render-cert-block.js +83 -25
- package/dist/components/render-cert-block.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/modal/certrev-cert-modal.d.ts +34 -1
- package/dist/modal/certrev-cert-modal.d.ts.map +1 -1
- package/dist/modal/certrev-cert-modal.js +40 -2
- package/dist/modal/certrev-cert-modal.js.map +1 -1
- package/dist/modal/certrev-cert.entry.d.ts.map +1 -1
- package/dist/modal/certrev-cert.entry.js +2 -4
- package/dist/modal/certrev-cert.entry.js.map +1 -1
- package/dist/modal/certrev-cert.js +4 -4
- package/dist/modal/deprecated.d.ts +15 -0
- package/dist/modal/deprecated.d.ts.map +1 -0
- package/dist/modal/deprecated.js +22 -0
- package/dist/modal/deprecated.js.map +1 -0
- package/dist/modal/ftc-disclosure.d.ts +7 -3
- package/dist/modal/ftc-disclosure.d.ts.map +1 -1
- package/dist/modal/ftc-disclosure.js +7 -3
- package/dist/modal/ftc-disclosure.js.map +1 -1
- package/dist/modal/index.d.ts +4 -3
- package/dist/modal/index.d.ts.map +1 -1
- package/dist/modal/index.js +4 -3
- package/dist/modal/index.js.map +1 -1
- package/dist/modal/interactions.d.ts +20 -4
- package/dist/modal/interactions.d.ts.map +1 -1
- package/dist/modal/interactions.js +34 -7
- package/dist/modal/interactions.js.map +1 -1
- package/dist/verify/get-verified-delivery.d.ts +91 -0
- package/dist/verify/get-verified-delivery.d.ts.map +1 -0
- package/dist/verify/get-verified-delivery.js +157 -0
- package/dist/verify/get-verified-delivery.js.map +1 -0
- package/package.json +5 -1
- package/src/builder/cert-chrome-data.ts +27 -0
- package/src/builder/cert-review-card.tsx +143 -3
- package/src/builder/delivery-runtime.ts +54 -0
- package/src/builder/envelope-face.ts +173 -0
- package/src/builder/index.tsx +23 -1
- package/src/builder/issuer-key.ts +13 -0
- package/src/components/memo-toggle.ts +63 -0
- package/src/components/render-cert-block.ts +124 -28
- package/src/index.ts +28 -0
- package/src/modal/certrev-cert-modal.ts +45 -2
- package/src/modal/certrev-cert.entry.ts +2 -4
- package/src/modal/deprecated.ts +25 -0
- package/src/modal/ftc-disclosure.ts +7 -3
- package/src/modal/index.ts +4 -3
- package/src/modal/interactions.ts +37 -7
- package/src/verify/get-verified-delivery.ts +211 -0
- package/dist/modal/ftc-guard.d.ts +0 -85
- package/dist/modal/ftc-guard.d.ts.map +0 -1
- package/dist/modal/ftc-guard.js +0 -150
- package/dist/modal/ftc-guard.js.map +0 -1
- package/src/modal/ftc-guard.ts +0 -174
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
* so the document-level delegate can't see the close controls.
|
|
24
24
|
* - Fonts inject at document level on first open (font faces don't scope into a
|
|
25
25
|
* shadow root); deferring to first open avoids loading them on every article view.
|
|
26
|
+
* - A page can carry several faces, each with its own element (a WordPress Query Loop, a
|
|
27
|
+
* related-posts block). A trigger opens its OWN face's element (`ownCertModal`), never the
|
|
28
|
+
* document's first one, and a suppressed element hides only its own face's triggers.
|
|
26
29
|
*/
|
|
27
30
|
|
|
28
31
|
import { type CertDeliveryArtifact, isTombstone } from '@certrev/cert-contract'
|
|
@@ -33,6 +36,44 @@ import { CERT_MODAL_CSS } from './certrev-cert-modal.styles.js'
|
|
|
33
36
|
export const CERT_MODAL_TAG = 'certrev-cert-modal'
|
|
34
37
|
type ModalKind = 'cert' | 'expert'
|
|
35
38
|
|
|
39
|
+
/**
|
|
40
|
+
* One rendered face on a surface that can print several per page: the WordPress plugin wraps each
|
|
41
|
+
* face's card, memo and rail wraps in `<div data-certrev-cert="1">`. A trigger never reaches a
|
|
42
|
+
* `<certrev-cert-modal>` in another face root, and one outside every face root never reaches a
|
|
43
|
+
* modal inside one.
|
|
44
|
+
*/
|
|
45
|
+
export const FACE_ROOT_SELECTOR = '[data-certrev-cert]'
|
|
46
|
+
|
|
47
|
+
/** The element a trigger opens, or why it has none. */
|
|
48
|
+
export type OwnCertModal =
|
|
49
|
+
| { readonly modal: Element; readonly reason: null }
|
|
50
|
+
| { readonly modal: null; readonly reason: 'none' | 'ambiguous'; readonly count: number }
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The `<certrev-cert-modal>` that belongs to `trigger`'s face: the one in the SMALLEST subtree that
|
|
54
|
+
* holds both. Walk up from the trigger; the first ancestor holding a modal decides:
|
|
55
|
+
* - exactly one → that is the face's modal. On Shopify the card wrap (badge + modal) and the memo
|
|
56
|
+
* wrap (triggers only) are placed apart in the article, and their nearest common container holds
|
|
57
|
+
* the page's one modal; a Builder page puts one modal beside the CMS content; a WordPress face root
|
|
58
|
+
* holds its own.
|
|
59
|
+
* - more than one → `ambiguous`: the trigger's face has no modal of its own and sits between other
|
|
60
|
+
* faces'. Picking one would open another cert's certificate, so there is no answer.
|
|
61
|
+
* The walk never leaves the trigger's face root, and only counts modals in that same face root (or,
|
|
62
|
+
* for a trigger in none, modals in none), so a WordPress face without a modal resolves to `none`.
|
|
63
|
+
*/
|
|
64
|
+
export function ownCertModal(trigger: Element): OwnCertModal {
|
|
65
|
+
const face = trigger.closest(FACE_ROOT_SELECTOR)
|
|
66
|
+
for (let node: Element | null = trigger; node; node = node.parentElement) {
|
|
67
|
+
const found = Array.from(node.querySelectorAll(CERT_MODAL_TAG)).filter(
|
|
68
|
+
(m) => m.closest(FACE_ROOT_SELECTOR) === face,
|
|
69
|
+
)
|
|
70
|
+
if (found.length === 1) return { modal: found[0] as Element, reason: null }
|
|
71
|
+
if (found.length > 1) return { modal: null, reason: 'ambiguous', count: found.length }
|
|
72
|
+
if (node === face) break
|
|
73
|
+
}
|
|
74
|
+
return { modal: null, reason: 'none', count: 0 }
|
|
75
|
+
}
|
|
76
|
+
|
|
36
77
|
/** Shared constructable stylesheet (one per document lifetime). */
|
|
37
78
|
let sharedSheet: CSSStyleSheet | null = null
|
|
38
79
|
function supportsConstructableSheets(): boolean {
|
|
@@ -208,11 +249,13 @@ export class CertRevCertModal extends HTMLElement {
|
|
|
208
249
|
/**
|
|
209
250
|
* The modal kill-switch: a fetched envelope is REVOKED/EXPIRED. Don't open;
|
|
210
251
|
* mark the host suppressed (mirrors the badge revalidation's `data-certrev-suppressed`) and
|
|
211
|
-
* hide
|
|
252
|
+
* hide THIS face's light-DOM triggers (those whose `ownCertModal` is this element) so a stale
|
|
253
|
+
* click can't re-open a pulled cert. Another face's triggers on the page are not touched.
|
|
212
254
|
*/
|
|
213
255
|
private markSuppressed(): void {
|
|
214
256
|
this.setAttribute('data-certrev-suppressed', '')
|
|
215
|
-
for (const t of Array.from(
|
|
257
|
+
for (const t of Array.from(this.ownerDocument.querySelectorAll('[data-certrev-modal-open]'))) {
|
|
258
|
+
if (ownCertModal(t).modal !== this) continue
|
|
216
259
|
t.setAttribute('hidden', '')
|
|
217
260
|
t.setAttribute('aria-hidden', 'true')
|
|
218
261
|
}
|
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
|
|
11
11
|
import type { ResolvePublicKeyByKid } from '@certrev/cert-contract'
|
|
12
12
|
import './certrev-cert-modal.js' // registers the unified <certrev-cert-modal> element
|
|
13
|
-
import { installFtcGuard } from './ftc-guard.js'
|
|
14
13
|
import { initCertInteractionsOnReady } from './interactions.js'
|
|
15
14
|
import { initCertPlacement } from './placement.js'
|
|
16
15
|
import { initBadgeRevalidation } from './revalidate.js'
|
|
@@ -37,10 +36,9 @@ if (typeof document !== 'undefined') {
|
|
|
37
36
|
// in-article position — so the badge re-verify pass below finds `<certrev-badge>` already in
|
|
38
37
|
// place and never re-runs against a detached/re-attached node.
|
|
39
38
|
initCertPlacement(document)
|
|
39
|
+
// No disclosure guard since 1.1.1: nothing here hides, replaces or re-checks a face at load or on
|
|
40
|
+
// mutation, so a face that loads inside a closed <details>, tab or accordion renders when opened.
|
|
40
41
|
// Tap-to-expand contributor bios + the in-page certificate modal (progressive enhancement).
|
|
41
42
|
initCertInteractionsOnReady(document)
|
|
42
|
-
// FTC §3 anti-stripping: neutralize a memo whose verbatim disclosure was stripped/hidden/altered
|
|
43
|
-
// (restores the iframe kill switch in the native, in-DOM render). No-op when there's no memo.
|
|
44
|
-
installFtcGuard(document)
|
|
45
43
|
initBadgeRevalidation(document, { verify: createDeliveryVerifier(resolveKid) })
|
|
46
44
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public names kept only so a patch release never breaks a compiling integration (the semver
|
|
3
|
+
* contract at the head of CHANGELOG.md). Each one does nothing it used to do; removal is for 2.0.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Dispatched on the document, once per document, on the first call of a removed API, so a caller
|
|
7
|
+
* that still relies on it can see that it does nothing. `detail: { api, removedIn }`. */
|
|
8
|
+
export const DEPRECATED_API_EVENT = 'certrev:deprecated-api'
|
|
9
|
+
|
|
10
|
+
/** Documents that have already been told, so repeated calls dispatch once. */
|
|
11
|
+
const announcedDocs = new WeakSet<Document>()
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @deprecated Removed in 1.1.1: the runtime FTC disclosure guard is gone. This installs nothing,
|
|
15
|
+
* checks nothing and never hides or replaces a face; whether a face shows its disclosure is settled
|
|
16
|
+
* where the face is configured. The first call per document dispatches {@link DEPRECATED_API_EVENT}.
|
|
17
|
+
* Will be deleted in 2.0.
|
|
18
|
+
*/
|
|
19
|
+
export function installFtcGuard(doc: Document): void {
|
|
20
|
+
if (announcedDocs.has(doc)) return
|
|
21
|
+
announcedDocs.add(doc)
|
|
22
|
+
doc.dispatchEvent(
|
|
23
|
+
new CustomEvent(DEPRECATED_API_EVENT, { detail: { api: 'installFtcGuard', removedIn: '1.1.1' } }),
|
|
24
|
+
)
|
|
25
|
+
}
|
|
@@ -4,9 +4,13 @@
|
|
|
4
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
|
-
* Kept as its OWN module (imported by
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* Kept as its OWN module (imported by display-strings, exactly as in portal) so the browser bundle
|
|
8
|
+
* emits the same `var` structure as the deployed certrev-cert.js — do not merge it into
|
|
9
|
+
* display-strings.ts (that consolidation drops one `var` and breaks byte-parity).
|
|
10
|
+
*
|
|
11
|
+
* It is the text the faces RENDER, nothing more. Since 1.1.1 nothing in this package checks at
|
|
12
|
+
* runtime that it is present or visible (the `ftc-guard` kill switch is gone): whether a face shows
|
|
13
|
+
* its disclosure is settled where the face is configured, not by hiding or replacing it in the page.
|
|
10
14
|
*/
|
|
11
15
|
|
|
12
16
|
export const FTC_DISCLOSURE_LINE =
|
package/src/modal/index.ts
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* storefront (`<script src=".../dist/modal/certrev-cert.js">`) instead of vendoring a copy.
|
|
7
7
|
*
|
|
8
8
|
* Importing this module as ESM has the SAME side effect as loading the IIFE: it registers the
|
|
9
|
-
* `<certrev-cert-modal>` element and (in a browser) runs placement + interactions +
|
|
10
|
-
*
|
|
9
|
+
* `<certrev-cert-modal>` element and (in a browser) runs placement + interactions + badge
|
|
10
|
+
* revalidation (no FTC guard since 1.1.1). Bundler users: `import '@certrev/cert-block/modal'`.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
// Side-effect: register the element + (in a browser) kick off the DOM passes.
|
|
@@ -23,7 +23,8 @@ export {
|
|
|
23
23
|
|
|
24
24
|
// Composable entry points for advanced consumers (the IIFE wires these itself).
|
|
25
25
|
export { CERT_MODAL_TAG } from "./certrev-cert-modal.js";
|
|
26
|
-
|
|
26
|
+
/** @deprecated Since 1.1.1 a no-op (the runtime FTC guard was removed); deleted in 2.0. */
|
|
27
|
+
export { installFtcGuard } from "./deprecated.js";
|
|
27
28
|
export { initCertInteractionsOnReady } from "./interactions.js";
|
|
28
29
|
export { initCertPlacement } from "./placement.js";
|
|
29
30
|
export {
|
|
@@ -8,8 +8,8 @@
|
|
|
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
|
-
* • In-page modals: a `[data-certrev-modal-open]` control opens
|
|
12
|
-
* `<certrev-cert-modal>` element via its `.open('cert'|'expert')` method. Bare /
|
|
11
|
+
* • In-page modals: a `[data-certrev-modal-open]` control opens its OWN face's
|
|
12
|
+
* `<certrev-cert-modal>` element (`ownCertModal`) 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
|
|
@@ -18,12 +18,18 @@
|
|
|
18
18
|
* No crypto, no network — pure DOM. Idempotent install (guarded so a double-load can't double-bind).
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { CERT_MODAL_TAG } from './certrev-cert-modal.js'
|
|
21
|
+
import { CERT_MODAL_TAG, ownCertModal } from './certrev-cert-modal.js'
|
|
22
22
|
|
|
23
23
|
export const ACC_ATTR = 'data-certrev-acc'
|
|
24
24
|
export const MODAL_OPEN_ATTR = 'data-certrev-modal-open'
|
|
25
25
|
export const INITIALS_ATTR = 'data-certrev-initials'
|
|
26
26
|
export const CARD_SELECTOR = '.certrev-card'
|
|
27
|
+
/** Stamped on a control that could not tell which modal is its own (value: the reason), beside the
|
|
28
|
+
* bubbling {@link MODAL_UNRESOLVED_EVENT}, so the miss is inspectable rather than a silent link. */
|
|
29
|
+
export const MODAL_UNRESOLVED_ATTR = 'data-certrev-modal-unresolved'
|
|
30
|
+
/** Dispatched on a control whose face has no modal of its own but sits between other faces'
|
|
31
|
+
* (`ownCertModal` → `ambiguous`). `detail: { reason: 'ambiguous', modals }`. */
|
|
32
|
+
export const MODAL_UNRESOLVED_EVENT = 'certrev:modal-unresolved'
|
|
27
33
|
|
|
28
34
|
/** The dialog a `data-certrev-modal-open` control targets. */
|
|
29
35
|
export type ModalKind = 'cert' | 'expert'
|
|
@@ -53,8 +59,10 @@ export function modalTargetKind(value: string | null): ModalKind {
|
|
|
53
59
|
return value === 'expert' ? 'expert' : 'cert'
|
|
54
60
|
}
|
|
55
61
|
|
|
56
|
-
/** Open a cert dialog via the `<certrev-cert-modal>` element
|
|
57
|
-
*
|
|
62
|
+
/** Open a cert dialog via the document's FIRST `<certrev-cert-modal>` element: a page-level helper
|
|
63
|
+
* for a page with one face. Controls go through `openOwnModal`, which finds their own face's element.
|
|
64
|
+
* Returns false (so the caller lets an href fall back) when the element is absent or declines (no
|
|
65
|
+
* envelope + no fetch source). */
|
|
58
66
|
export function openModal(doc: Document, kind: ModalKind): boolean {
|
|
59
67
|
const el = doc.querySelector(CERT_MODAL_TAG) as CertModalElement | null
|
|
60
68
|
if (el && typeof el.open === 'function') return el.open(kind)
|
|
@@ -66,6 +74,28 @@ export function openCertModal(doc: Document): boolean {
|
|
|
66
74
|
return openModal(doc, 'cert')
|
|
67
75
|
}
|
|
68
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Open a dialog of `control`'s OWN face's `<certrev-cert-modal>` (see `ownCertModal`), which is what
|
|
79
|
+
* the delegated click / key handlers do. Returns false (so a link control follows its own href) when
|
|
80
|
+
* the face has no modal, the element declines, or the control cannot tell which modal is its own; in
|
|
81
|
+
* that last case it is stamped `data-certrev-modal-unresolved="ambiguous"` and receives
|
|
82
|
+
* `certrev:modal-unresolved`, and another face's certificate is never opened in its place.
|
|
83
|
+
*/
|
|
84
|
+
export function openOwnModal(control: Element, kind: ModalKind): boolean {
|
|
85
|
+
const own = ownCertModal(control)
|
|
86
|
+
if (own.modal) {
|
|
87
|
+
const el = own.modal as CertModalElement
|
|
88
|
+
return typeof el.open === 'function' ? el.open(kind) : false
|
|
89
|
+
}
|
|
90
|
+
if (own.reason === 'ambiguous') {
|
|
91
|
+
control.setAttribute(MODAL_UNRESOLVED_ATTR, own.reason)
|
|
92
|
+
control.dispatchEvent(
|
|
93
|
+
new CustomEvent(MODAL_UNRESOLVED_EVENT, { bubbles: true, detail: { reason: own.reason, modals: own.count } }),
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
return false
|
|
97
|
+
}
|
|
98
|
+
|
|
69
99
|
function asElement(target: EventTarget | null): Element | null {
|
|
70
100
|
return target && (target as Element).closest ? (target as Element) : null
|
|
71
101
|
}
|
|
@@ -111,7 +141,7 @@ export function initCertInteractions(doc: Document): void {
|
|
|
111
141
|
// (Close is owned by the <certrev-cert-modal> element inside its shadow root.)
|
|
112
142
|
const opener = el.closest(`[${MODAL_OPEN_ATTR}]`)
|
|
113
143
|
if (opener) {
|
|
114
|
-
if (
|
|
144
|
+
if (openOwnModal(opener, modalTargetKind(opener.getAttribute(MODAL_OPEN_ATTR)))) e.preventDefault()
|
|
115
145
|
return
|
|
116
146
|
}
|
|
117
147
|
|
|
@@ -144,7 +174,7 @@ export function initCertInteractions(doc: Document): void {
|
|
|
144
174
|
|
|
145
175
|
const opener = el.closest(`[${MODAL_OPEN_ATTR}]`)
|
|
146
176
|
if (opener) {
|
|
147
|
-
if (
|
|
177
|
+
if (openOwnModal(opener, modalTargetKind(opener.getAttribute(MODAL_OPEN_ATTR)))) {
|
|
148
178
|
e.preventDefault()
|
|
149
179
|
return
|
|
150
180
|
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
3
|
+
* getVerifiedDelivery — the envelope PLUS the brand's render def, from the Delivery API
|
|
4
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* 1.1.0. The Delivery API answers a Builder placement with the signed artifact and, beside it,
|
|
7
|
+
* the brand's resolved render-def projection as a top-level `renderDef` sibling (the same shape a
|
|
8
|
+
* Shopify store carries in its `render_def` metafield). The envelope's signature covers `payload`
|
|
9
|
+
* only, so the sibling rides the response without touching the signed bytes, and a tombstone
|
|
10
|
+
* carries none. `getVerifiedEnvelope` keeps discarding it; this reads it.
|
|
11
|
+
*
|
|
12
|
+
* The same fetch, deadline, kernel and fail-closed rules as `getVerifiedEnvelope` (every sourcing
|
|
13
|
+
* failure reports the reason it actually is; nothing throws into the render path). Two things are
|
|
14
|
+
* different, and both are the point:
|
|
15
|
+
*
|
|
16
|
+
* 1. WHAT IS CACHED is the cryptographically verified PAYLOAD, not the verdict. The lifecycle
|
|
17
|
+
* policy (revoked, expired) and the subject match are re-run by `renderVerdict` on EVERY call,
|
|
18
|
+
* with the caller's clock. So a payload that was valid when fetched and expires while cached
|
|
19
|
+
* stops rendering at its `expiresAt`, not at the next refetch; and a refetch after the TTL
|
|
20
|
+
* picks up a tombstone (revocation) or a changed def with no write on the brand's side.
|
|
21
|
+
* 2. The CACHE KEY is the Delivery URL plus the rendering placement, so a staging and a
|
|
22
|
+
* production origin never share an entry, and neither do two placements.
|
|
23
|
+
*
|
|
24
|
+
* `peekVerifiedDelivery` is the synchronous read of the same cache: a React component suspends on
|
|
25
|
+
* `getVerifiedDelivery` once, then reads the settled value without a second promise (which is what
|
|
26
|
+
* keeps a streaming SSR render from suspending forever on a fresh promise every retry).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { CertChromeRenderDef } from '../builder/cert-chrome-data.js'
|
|
30
|
+
import type {
|
|
31
|
+
CertDeliveryArtifact,
|
|
32
|
+
CertPayload,
|
|
33
|
+
CertSuppressReason,
|
|
34
|
+
CertVerdict,
|
|
35
|
+
RenderContext,
|
|
36
|
+
ResolvePublicKeyByKid,
|
|
37
|
+
} from '../contract/kernel.js'
|
|
38
|
+
import { renderVerdict, verifyArtifact } from '../contract/kernel.js'
|
|
39
|
+
import { TtlCache } from './cache.js'
|
|
40
|
+
import { DEFAULT_FETCH_TIMEOUT_MS, fetchWithDeadline } from './fetch-with-deadline.js'
|
|
41
|
+
|
|
42
|
+
/** What one Delivery read settles to, as cached: a verified payload + its def, or the reason not. */
|
|
43
|
+
export type CachedDelivery =
|
|
44
|
+
| { readonly kind: 'verified'; readonly payload: CertPayload; readonly renderDef: CertChromeRenderDef | null }
|
|
45
|
+
| { readonly kind: 'suppressed'; readonly reason: CertSuppressReason }
|
|
46
|
+
|
|
47
|
+
/** The answer for one render: the verdict AT THIS RENDER'S CLOCK, and the def to paint it with. */
|
|
48
|
+
export interface VerifiedDelivery {
|
|
49
|
+
readonly verdict: CertVerdict
|
|
50
|
+
/** The brand's render def, or null (no def resolved, a malformed one, or a suppress verdict). */
|
|
51
|
+
readonly renderDef: CertChromeRenderDef | null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface GetVerifiedDeliveryOptions {
|
|
55
|
+
/** Base URL of the CertREV app that serves the Delivery API, e.g. 'https://portal.certrev.com'. */
|
|
56
|
+
readonly baseUrl: string
|
|
57
|
+
readonly platform: string
|
|
58
|
+
readonly externalId: string
|
|
59
|
+
/** kid → public-key resolver. Required: no verify without keys. */
|
|
60
|
+
readonly resolveKid: ResolvePublicKeyByKid
|
|
61
|
+
/** Render context: the platform + externalId this edge IS, and the render's clock (`now`). */
|
|
62
|
+
readonly context: RenderContext
|
|
63
|
+
readonly fetchImpl?: typeof fetch
|
|
64
|
+
/** Cache override. Defaults to the module-level shared delivery cache. */
|
|
65
|
+
readonly cache?: TtlCache<CachedDelivery>
|
|
66
|
+
/** TTL in ms for a VERIFIED read (default: the cache's positive TTL, 60_000 on the shared one). */
|
|
67
|
+
readonly ttlMs?: number
|
|
68
|
+
/** Deadline in ms for the fetch (default `DEFAULT_FETCH_TIMEOUT_MS`, 3s). */
|
|
69
|
+
readonly timeoutMs?: number
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Shared per-process cache: 60s for a verified read, 5s for a suppressed one. */
|
|
73
|
+
const sharedDeliveryCache = new TtlCache<CachedDelivery>({ ttlMs: 60_000, negativeTtlMs: 5_000 })
|
|
74
|
+
|
|
75
|
+
function suppressed(reason: CertSuppressReason): CachedDelivery {
|
|
76
|
+
return { kind: 'suppressed', reason }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function deliveryUrl(baseUrl: string, platform: string, externalId: string): string {
|
|
80
|
+
const base = baseUrl.replace(/\/+$/, '')
|
|
81
|
+
return `${base}/api/cert/v1/delivery/${encodeURIComponent(platform)}/${encodeURIComponent(externalId)}`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Cache key: the URL (origin included) × the placement rendering it. No clock: the TTL bounds time. */
|
|
85
|
+
export function deliveryCacheKey(
|
|
86
|
+
opts: Pick<GetVerifiedDeliveryOptions, 'baseUrl' | 'platform' | 'externalId' | 'context'>,
|
|
87
|
+
): string {
|
|
88
|
+
return `delivery:${deliveryUrl(opts.baseUrl, opts.platform, opts.externalId)}|${encodeURIComponent(opts.context.platform)}:${encodeURIComponent(opts.context.externalId)}`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const isString = (v: unknown): v is string => typeof v === 'string'
|
|
92
|
+
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The def sibling, shape-checked. Anything that is not the metafield shape is null, which paints
|
|
96
|
+
* the preset default (the same thing an absent def paints): a def is presentation, never a claim,
|
|
97
|
+
* so a malformed one degrades the styling and nothing else. The TOKEN VALUES are not judged here;
|
|
98
|
+
* `resolveBlockTheme` re-validates each at paint time and drops what is not a safe value.
|
|
99
|
+
*/
|
|
100
|
+
export function parseRenderDef(value: unknown): CertChromeRenderDef | null {
|
|
101
|
+
if (!isRecord(value)) return null
|
|
102
|
+
const { v, preset, rung, tokens, hidden, barInkFg, layout, customFace } = value
|
|
103
|
+
if (typeof v !== 'number' || !Number.isFinite(v)) return null
|
|
104
|
+
if (!isString(preset) || !isString(rung) || !isRecord(tokens)) return null
|
|
105
|
+
if (!Array.isArray(hidden) || !hidden.every(isString)) return null
|
|
106
|
+
if (layout !== undefined && !isString(layout)) return null
|
|
107
|
+
let face: CertChromeRenderDef['customFace']
|
|
108
|
+
if (customFace !== undefined) {
|
|
109
|
+
if (!isRecord(customFace) || !Array.isArray(customFace.placedFields) || !customFace.placedFields.every(isString)) {
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
face = { placedFields: [...customFace.placedFields] }
|
|
113
|
+
}
|
|
114
|
+
const tokenOut: Record<string, string> = {}
|
|
115
|
+
for (const key of ['accentColor', 'surface', 'cornerRadius', 'fontSlot', 'barInk', 'inkColor'] as const) {
|
|
116
|
+
if (isString(tokens[key])) tokenOut[key] = tokens[key]
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
v,
|
|
120
|
+
preset,
|
|
121
|
+
rung,
|
|
122
|
+
tokens: tokenOut,
|
|
123
|
+
...(barInkFg === '#141414' || barInkFg === '#ffffff' ? { barInkFg } : {}),
|
|
124
|
+
hidden: [...hidden],
|
|
125
|
+
...(layout !== undefined ? { layout } : {}),
|
|
126
|
+
...(face ? { customFace: face } : {}),
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Fetch, parse and cryptographically verify one Delivery response. Never throws. */
|
|
131
|
+
async function loadDelivery(opts: GetVerifiedDeliveryOptions): Promise<CachedDelivery> {
|
|
132
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch
|
|
133
|
+
let res: Response
|
|
134
|
+
try {
|
|
135
|
+
res = await fetchWithDeadline(
|
|
136
|
+
fetchImpl,
|
|
137
|
+
deliveryUrl(opts.baseUrl, opts.platform, opts.externalId),
|
|
138
|
+
{ headers: { accept: 'application/json' } },
|
|
139
|
+
opts.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS,
|
|
140
|
+
)
|
|
141
|
+
} catch {
|
|
142
|
+
// Unreachable, DNS/TLS, or the deadline. Same least-wrong member as getVerifiedEnvelope.
|
|
143
|
+
return suppressed('unsupported_contract_version')
|
|
144
|
+
}
|
|
145
|
+
// Nothing served for this placement: never certified, or withdrawn. A LIVE revocation is a 200
|
|
146
|
+
// tombstone, so a 4xx here genuinely means "no credential for this subject".
|
|
147
|
+
if (res.status === 404 || res.status === 410) return suppressed('subject_mismatch')
|
|
148
|
+
if (!res.ok) return suppressed('unsupported_contract_version')
|
|
149
|
+
let body: unknown
|
|
150
|
+
try {
|
|
151
|
+
body = await res.json()
|
|
152
|
+
} catch {
|
|
153
|
+
return suppressed('unsupported_contract_version')
|
|
154
|
+
}
|
|
155
|
+
if (!isRecord(body)) return suppressed('unsupported_contract_version')
|
|
156
|
+
let verdict: CertVerdict
|
|
157
|
+
try {
|
|
158
|
+
// `verifyArtifact`, so a tombstone verifies as itself and reports 'revoked'. The kernel reads
|
|
159
|
+
// `payload` + `signature` (or the tombstone's own fields); the `renderDef` sibling is not
|
|
160
|
+
// part of what is verified, and is never read unless the artifact verified.
|
|
161
|
+
verdict = await verifyArtifact(body as unknown as CertDeliveryArtifact, opts.resolveKid, { ...opts.context })
|
|
162
|
+
} catch {
|
|
163
|
+
return suppressed('unknown_key')
|
|
164
|
+
}
|
|
165
|
+
if (verdict.decision !== 'render') return suppressed(verdict.reason)
|
|
166
|
+
return { kind: 'verified', payload: verdict.payload, renderDef: parseRenderDef(body.renderDef) }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Apply the render-time policy to a cached read: lifecycle + subject at THIS render's clock. */
|
|
170
|
+
export function settleDelivery(cached: CachedDelivery, context: RenderContext): VerifiedDelivery {
|
|
171
|
+
if (cached.kind === 'suppressed') return { verdict: { decision: 'suppress', reason: cached.reason }, renderDef: null }
|
|
172
|
+
const verdict = renderVerdict(cached.payload, context)
|
|
173
|
+
return { verdict, renderDef: verdict.decision === 'render' ? cached.renderDef : null }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Fetch (if needed), verify, cache, and settle one Delivery read for one placement. FAIL-CLOSED:
|
|
178
|
+
* the verdict is `render` only when the artifact verified against `resolveKid` AND, at
|
|
179
|
+
* `context.now`, the payload is unrevoked, unexpired and for this placement.
|
|
180
|
+
*/
|
|
181
|
+
export async function getVerifiedDelivery(opts: GetVerifiedDeliveryOptions): Promise<VerifiedDelivery> {
|
|
182
|
+
const cache = opts.cache ?? sharedDeliveryCache
|
|
183
|
+
const cached = await cache.getOrLoad(
|
|
184
|
+
deliveryCacheKey(opts),
|
|
185
|
+
() => loadDelivery(opts),
|
|
186
|
+
(v) => v.kind === 'suppressed',
|
|
187
|
+
opts.ttlMs,
|
|
188
|
+
)
|
|
189
|
+
return settleDelivery(cached, opts.context)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The synchronous read: the settled answer when a fresh read is cached, else `undefined` (the
|
|
194
|
+
* caller then awaits `getVerifiedDelivery`). Never fetches.
|
|
195
|
+
*/
|
|
196
|
+
export function peekVerifiedDelivery(
|
|
197
|
+
opts: Pick<GetVerifiedDeliveryOptions, 'baseUrl' | 'platform' | 'externalId' | 'context' | 'cache'>,
|
|
198
|
+
): VerifiedDelivery | undefined {
|
|
199
|
+
const cached = (opts.cache ?? sharedDeliveryCache).peek(deliveryCacheKey(opts))
|
|
200
|
+
return cached === undefined ? undefined : settleDelivery(cached, opts.context)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Drop every cached read for one placement (e.g. from a revocation webhook). */
|
|
204
|
+
export function invalidateDelivery(
|
|
205
|
+
opts: Pick<GetVerifiedDeliveryOptions, 'baseUrl' | 'platform' | 'externalId' | 'context'>,
|
|
206
|
+
cache: TtlCache<CachedDelivery> = sharedDeliveryCache,
|
|
207
|
+
): void {
|
|
208
|
+
cache.delete(deliveryCacheKey(opts))
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export { sharedDeliveryCache }
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* FTC disclosure tamper guard for the NATIVE Shopify cert render (iframe→native parity).
|
|
3
|
-
*
|
|
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
|
-
* material-connection disclosure is stripped, hidden, or altered — making the MSA §3
|
|
7
|
-
* anti-stripping covenant TECHNICALLY enforced, not just contractual. The native memo renders
|
|
8
|
-
* in the host theme's DOM (no sandbox), so without this it would lose that runtime enforcement.
|
|
9
|
-
* This module restores it: on load + on later DOM mutation it verifies the in-DOM
|
|
10
|
-
* `[data-ftc-line]` is present, carries the EXACT verbatim text, and is actually visible; if not,
|
|
11
|
-
* it neutralizes the memo with a neutral notice so the memo NEVER renders without its disclosure.
|
|
12
|
-
*
|
|
13
|
-
* Native-specific semantics (differ from the iframe, which only existed when a memo did):
|
|
14
|
-
* - Enforcement is keyed on the MEMO element (`.certrev-memo`). A certified article with NO
|
|
15
|
-
* memo legitimately has no `.certrev-memo` and no disclosure — the guard does NOT fire
|
|
16
|
-
* (no false positive on the no-memo path).
|
|
17
|
-
* - On tamper it neutralizes ONLY the memo (never `document.body` — we're in the host page).
|
|
18
|
-
* - The contributors card is governed by the badge re-verify (`revalidate.ts`), not this guard;
|
|
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`.
|
|
27
|
-
*
|
|
28
|
-
* Pure DOM, no crypto, no network. The expected text is the single source of truth
|
|
29
|
-
* `FTC_DISCLOSURE_LINE` (imported, not copied — so a reword can never desync this guard).
|
|
30
|
-
*/
|
|
31
|
-
export declare const FTC_LINE_SELECTOR = "[data-ftc-line]";
|
|
32
|
-
export declare const MEMO_SELECTOR = ".certrev-memo";
|
|
33
|
-
export declare const MEMO_WRAP_SELECTOR = ".certrev-memo-wrap";
|
|
34
|
-
/** Marker on the neutral notice node that replaces a tampered memo (testable / idempotent). */
|
|
35
|
-
export declare const FTC_NEUTRALIZED_ATTR = "data-certrev-ftc-neutralized";
|
|
36
|
-
/** True when a memo's disclosure line is present, exactly verbatim, and visible. */
|
|
37
|
-
export declare function isDisclosureIntact(memo: Element): boolean;
|
|
38
|
-
/**
|
|
39
|
-
* Replace a tampered memo with a neutral, self-contained notice — no reviewer attribution, no
|
|
40
|
-
* seal — so the surface can never read as an endorsement without its required disclosure.
|
|
41
|
-
*/
|
|
42
|
-
export declare function neutralizeMemo(memo: Element): void;
|
|
43
|
-
/**
|
|
44
|
-
* What ONE enforcement pass actually established:
|
|
45
|
-
* - `'intact'` — at least one memo was checked, and every one carried its verbatim, visible line.
|
|
46
|
-
* - `'tampered'` — at least one memo failed the check and was neutralized.
|
|
47
|
-
* - `'unguarded'` — the document holds no `.certrev-memo`, so the pass checked nothing.
|
|
48
|
-
*
|
|
49
|
-
* `'unguarded'` used to be reported as `true`, indistinguishable from `'intact'`, and that
|
|
50
|
-
* conflation was a correctness bug, not a cosmetic one: `.certrev-memo` is portal Liquid markup
|
|
51
|
-
* this package never emits, so on every React / Web Component / `renderCertBlock` surface the pass
|
|
52
|
-
* finds nothing and answered "all intact" about a disclosure it had never looked at — the kill
|
|
53
|
-
* switch silently reporting success on exactly the surfaces it does not protect.
|
|
54
|
-
*
|
|
55
|
-
* The legitimate no-memo certified article (card only, no memo, no disclosure owed) lands here too,
|
|
56
|
-
* and the guard genuinely cannot tell the two apart from the DOM alone. That is the point of a
|
|
57
|
-
* third state rather than a second boolean: "nothing was verified" is the honest answer to both,
|
|
58
|
-
* and it is the caller — which knows which surface it rendered — that can tell whether that is
|
|
59
|
-
* expected or a coverage gap.
|
|
60
|
-
*/
|
|
61
|
-
export type FtcEnforcementResult = 'intact' | 'tampered' | 'unguarded';
|
|
62
|
-
/**
|
|
63
|
-
* Enforce the disclosure across every memo in the doc: neutralize any whose disclosure isn't
|
|
64
|
-
* intact. A doc with no `.certrev-memo` is a no-op — see `FtcEnforcementResult` for why that is
|
|
65
|
-
* `'unguarded'` and not success.
|
|
66
|
-
*/
|
|
67
|
-
export declare function enforceFtcDisclosure(doc: Document): FtcEnforcementResult;
|
|
68
|
-
/**
|
|
69
|
-
* Install the guard once per document: enforce now (on DOM ready) and re-enforce on later DOM
|
|
70
|
-
* mutations within the guarded subtree(s) — so a host script that strips / hides / edits the
|
|
71
|
-
* disclosure after load still trips the kill switch. Idempotent.
|
|
72
|
-
*
|
|
73
|
-
* The observer scope is the memo wrap(s) — placement.ts relocates a wrap as a node, so the observer
|
|
74
|
-
* tracks it wherever it moves — plus any memo sitting outside a wrap. When the document has
|
|
75
|
-
* NEITHER, NO observer is installed at all. That bail-out is the whole storefront case, not an edge
|
|
76
|
-
* one: this package emits neither selector (both are portal Liquid), so the old `<body>` fallback
|
|
77
|
-
* was the only branch a React / Web Component / `renderCertBlock` page could take, and it pinned a
|
|
78
|
-
* subtree + attributes + characterData observer — no `attributeFilter`, no coalescing — on the
|
|
79
|
-
* host's entire page for the life of the tab, re-running an enforce pass on EVERY mutation a busy
|
|
80
|
-
* storefront makes, to query a selector that could never match. A guard watching a page it cannot
|
|
81
|
-
* act on is pure cost; `enforceFtcDisclosure`'s `'unguarded'` result is the correctness half of the
|
|
82
|
-
* same gap.
|
|
83
|
-
*/
|
|
84
|
-
export declare function installFtcGuard(doc: Document): void;
|
|
85
|
-
//# sourceMappingURL=ftc-guard.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"ftc-guard.d.ts","sourceRoot":"","sources":["../../src/modal/ftc-guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAIH,eAAO,MAAM,iBAAiB,oBAAoB,CAAA;AAClD,eAAO,MAAM,aAAa,kBAAkB,CAAA;AAC5C,eAAO,MAAM,kBAAkB,uBAAuB,CAAA;AACtD,+FAA+F;AAC/F,eAAO,MAAM,oBAAoB,iCAAiC,CAAA;AAgClE,oFAAoF;AACpF,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAKzD;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,OAAO,GAAG,IAAI,CAUlD;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,MAAM,oBAAoB,GAAG,QAAQ,GAAG,UAAU,GAAG,WAAW,CAAA;AAEtE;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,QAAQ,GAAG,oBAAoB,CAWxE;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,QAAQ,GAAG,IAAI,CA0BnD"}
|