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