@certrev/cert-block 1.3.0 → 2.0.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 +235 -0
- package/README.md +162 -9
- package/dist/builder/card-beacon.d.ts +73 -0
- package/dist/builder/card-beacon.d.ts.map +1 -0
- package/dist/builder/card-beacon.js +178 -0
- package/dist/builder/card-beacon.js.map +1 -0
- package/dist/builder/card-contract.d.ts +151 -0
- package/dist/builder/card-contract.d.ts.map +1 -0
- package/dist/builder/card-contract.js +286 -0
- package/dist/builder/card-contract.js.map +1 -0
- package/dist/builder/card-placement.d.ts +45 -0
- package/dist/builder/card-placement.d.ts.map +1 -0
- package/dist/builder/card-placement.js +193 -0
- package/dist/builder/card-placement.js.map +1 -0
- package/dist/builder/card-report.d.ts +49 -0
- package/dist/builder/card-report.d.ts.map +1 -0
- package/dist/builder/card-report.js +97 -0
- package/dist/builder/card-report.js.map +1 -0
- package/dist/builder/cert-review-card.d.ts +7 -5
- package/dist/builder/cert-review-card.d.ts.map +1 -1
- package/dist/builder/cert-review-card.js +202 -170
- package/dist/builder/cert-review-card.js.map +1 -1
- package/dist/builder/contract-card.d.ts +70 -0
- package/dist/builder/contract-card.d.ts.map +1 -0
- package/dist/builder/contract-card.js +191 -0
- package/dist/builder/contract-card.js.map +1 -0
- package/dist/builder/delivered-face.d.ts +9 -0
- package/dist/builder/delivered-face.d.ts.map +1 -1
- package/dist/builder/delivered-face.js +18 -5
- package/dist/builder/delivered-face.js.map +1 -1
- package/dist/builder/delivery-runtime.d.ts +27 -3
- package/dist/builder/delivery-runtime.d.ts.map +1 -1
- package/dist/builder/delivery-runtime.js +40 -4
- package/dist/builder/delivery-runtime.js.map +1 -1
- package/dist/builder/index.d.ts +7 -4
- package/dist/builder/index.d.ts.map +1 -1
- package/dist/builder/index.js +8 -4
- package/dist/builder/index.js.map +1 -1
- package/dist/builder/ssr-seed.d.ts +23 -4
- package/dist/builder/ssr-seed.d.ts.map +1 -1
- package/dist/builder/ssr-seed.js +29 -6
- package/dist/builder/ssr-seed.js.map +1 -1
- package/dist/builder/version.d.ts +3 -0
- package/dist/builder/version.d.ts.map +1 -0
- package/dist/builder/version.js +3 -0
- package/dist/builder/version.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/reader/reader-css.generated.d.ts +1 -1
- package/dist/reader/reader-css.generated.d.ts.map +1 -1
- package/dist/reader/reader-css.generated.js +1 -1
- package/dist/reader/reader-css.generated.js.map +1 -1
- package/dist/reader/render.d.ts.map +1 -1
- package/dist/reader/render.js +71 -34
- package/dist/reader/render.js.map +1 -1
- package/dist/reader/tokens.d.ts +2 -0
- package/dist/reader/tokens.d.ts.map +1 -1
- package/dist/reader/tokens.js +28 -6
- package/dist/reader/tokens.js.map +1 -1
- package/dist/verify/get-verified-delivery.d.ts +32 -0
- package/dist/verify/get-verified-delivery.d.ts.map +1 -1
- package/dist/verify/get-verified-delivery.js +30 -5
- package/dist/verify/get-verified-delivery.js.map +1 -1
- package/dist/verify/key-set.d.ts +67 -0
- package/dist/verify/key-set.d.ts.map +1 -0
- package/dist/verify/key-set.js +190 -0
- package/dist/verify/key-set.js.map +1 -0
- package/package.json +115 -112
- package/src/builder/card-beacon.ts +201 -0
- package/src/builder/card-contract.ts +336 -0
- package/src/builder/card-placement.ts +208 -0
- package/src/builder/card-report.ts +126 -0
- package/src/builder/cert-review-card.tsx +285 -201
- package/src/builder/contract-card.tsx +216 -0
- package/src/builder/delivered-face.ts +18 -4
- package/src/builder/delivery-runtime.ts +46 -4
- package/src/builder/index.tsx +16 -4
- package/src/builder/ssr-seed.ts +37 -7
- package/src/builder/version.ts +2 -0
- package/src/index.ts +9 -0
- package/src/reader/reader-css.generated.ts +1 -1
- package/src/reader/reader.css +297 -2
- package/src/reader/render.ts +87 -38
- package/src/reader/tokens.ts +29 -6
- package/src/verify/get-verified-delivery.ts +54 -5
- package/src/verify/key-set.ts +225 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
3
|
+
* The contract-1 card a Builder block prints (2.0.0): the server's card document, bound by the signed digest
|
|
4
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* CertREV's contract v1 (portal ADR 0001) moves the card to the server: one renderer paints every part of a placement
|
|
7
|
+
* into a CARD DOCUMENT, and every CMS client prints those parts verbatim. Nothing visual, no string a reader sees and
|
|
8
|
+
* no layout is computed here. The Builder Delivery response carries the document as an UNSIGNED top-level sibling,
|
|
9
|
+
* `card`, beside `payload`, `signature`, `renderDef` and `face`. What binds it is the signed
|
|
10
|
+
* `payload.content.faceDigest`, which for a contract-1 Builder placement is
|
|
11
|
+
*
|
|
12
|
+
* { v: 1, defVersion, html, css, contract: 1, doc }
|
|
13
|
+
*
|
|
14
|
+
* where `doc` is the lowercase hex sha256 of the document's RFC 8785 bytes (cert-contract's `canonicalizeJson`) and
|
|
15
|
+
* `html`/`css` bind 1.3.x's delivered face, which this client no longer reads for such a placement.
|
|
16
|
+
*
|
|
17
|
+
* THE SCHEMA IS THE PORTAL'S (`src/lib/cert-delivery/card-document.ts`, `CardDocumentV1`; source read at portal
|
|
18
|
+
* v1-core d5e459b685). This module mirrors only the fields a client reads, read-only, and never redefines them: the
|
|
19
|
+
* digest covers the document's exact bytes, so `parseCardDocument` returns the object it was given, never a copy.
|
|
20
|
+
*
|
|
21
|
+
* Which path a verified delivery takes is decided by the SIGNED binding alone (`acceptedCardBinding`):
|
|
22
|
+
* - no `contract` key in the digest (every brand without CertREV's contract-1 entitlement): the 1.3.1 path, unchanged;
|
|
23
|
+
* - a well-formed contract-1 binding: the card, only when the served document hashes to `doc` and names the same
|
|
24
|
+
* design version, certificate and placement (`cardBinds`); anything else prints nothing and is reported;
|
|
25
|
+
* - a `contract` key that is not a well-formed contract-1 binding (a later contract, a malformed digest): nothing, and
|
|
26
|
+
* reported. A client refuses a contract it does not support (ADR 0001, section 2).
|
|
27
|
+
*
|
|
28
|
+
* React-free. Hashing uses WebCrypto (`crypto.subtle.digest`), as the kernel and the 1.3.0 face gate do, so it runs
|
|
29
|
+
* wherever the card verifies (Node SSR, edge runtimes, a browser in a secure context); the card owns the suspension.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { type CertContent, type CertPayload, canonicalizeJson } from '../contract/kernel.js'
|
|
33
|
+
import { evictOldestSettled, sha256HexUtf8 } from './delivered-face.js'
|
|
34
|
+
|
|
35
|
+
/** The contract this client prints. */
|
|
36
|
+
export const CARD_CONTRACT_VERSION = 1
|
|
37
|
+
|
|
38
|
+
/** The part names a document can hold, by where they go: banner card and memo, the sidebar rail, the floating pill. */
|
|
39
|
+
export const CARD_PART_NAMES = ['top', 'bottom', 'rail', 'pill'] as const
|
|
40
|
+
export type CardPartName = (typeof CARD_PART_NAMES)[number]
|
|
41
|
+
|
|
42
|
+
/** The layouts a document paints. */
|
|
43
|
+
export type CardLayout = 'banner' | 'sidebar' | 'floating'
|
|
44
|
+
const CARD_LAYOUTS: readonly string[] = ['banner', 'sidebar', 'floating']
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The fields of the portal's `CardDocumentV1` this client reads. The document carries more (`renderer`, `placement`,
|
|
48
|
+
* `jsonld`, `lifecycle`, `cache`, `illustrative`); they are part of the digest and printed nowhere by Builder.
|
|
49
|
+
*/
|
|
50
|
+
export interface CardDocumentV1 {
|
|
51
|
+
readonly contract: 1
|
|
52
|
+
readonly kind: 'certrev.card'
|
|
53
|
+
readonly certId: string
|
|
54
|
+
readonly subject: { readonly platform: string; readonly externalId: string }
|
|
55
|
+
readonly defVersion: number
|
|
56
|
+
readonly layout: CardLayout
|
|
57
|
+
/** Each part is one complete `<certrev-card>` host holding a declarative shadow root. */
|
|
58
|
+
readonly parts: Partial<Record<CardPartName, string>>
|
|
59
|
+
/** CertREV's own certificate and expert-profile dialogs: complete hosts, identical on every brand and CMS. */
|
|
60
|
+
readonly dialogs: { readonly expert?: string; readonly certificate?: string }
|
|
61
|
+
readonly assets: {
|
|
62
|
+
/** A complete `<style data-certrev-card-head>` fragment: the pill's fixed box and the fonts. */
|
|
63
|
+
readonly head: string
|
|
64
|
+
/** The hosted runtime (dialogs, revalidate, polyfill), present once CertREV serves it. */
|
|
65
|
+
readonly runtime?: { readonly src: string; readonly integrity: string }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The signed binding of a contract-1 card, as the verified `content.faceDigest` carries it. */
|
|
70
|
+
export interface CardBinding {
|
|
71
|
+
readonly contract: 1
|
|
72
|
+
readonly defVersion: number
|
|
73
|
+
readonly doc: string
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const SHA256_HEX = /^[0-9a-f]{64}$/
|
|
77
|
+
const SRI = /^(?:sha256|sha384|sha512)-[A-Za-z0-9+/]+={0,2}(?:\s+(?:sha256|sha384|sha512)-[A-Za-z0-9+/]+={0,2})*$/
|
|
78
|
+
|
|
79
|
+
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
80
|
+
const isString = (v: unknown): v is string => typeof v === 'string'
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The signed binding, judged:
|
|
84
|
+
* - `null`: the verified content's digest has no `contract` key (or there is no digest): not a contract-1 placement,
|
|
85
|
+
* so the card paints what 1.3.1 paints;
|
|
86
|
+
* - a `CardBinding`: `{ v: 1, contract: 1 }` with a non-negative integer `defVersion` and a 64-char lowercase hex `doc`
|
|
87
|
+
* (`html` and `css` may be there or not; this client does not read them);
|
|
88
|
+
* - `'unsupported'`: a `contract` key with anything else (another contract, a malformed binding). Fails closed.
|
|
89
|
+
*/
|
|
90
|
+
export function acceptedCardBinding(content: CertContent | null | undefined): CardBinding | 'unsupported' | null {
|
|
91
|
+
const digest: unknown = (content as { faceDigest?: unknown } | null | undefined)?.faceDigest
|
|
92
|
+
// Own keys via `Object.keys`, not `Object.hasOwn`: this runs for every verified placement, in browsers 1.3.1 painted in
|
|
93
|
+
// (Safari before 15.4 has no `Object.hasOwn`; break-it B3).
|
|
94
|
+
if (!isRecord(digest) || !Object.keys(digest).includes('contract')) return null
|
|
95
|
+
const { v, contract, defVersion, doc } = digest
|
|
96
|
+
if (v !== 1 || contract !== CARD_CONTRACT_VERSION) return 'unsupported'
|
|
97
|
+
if (typeof defVersion !== 'number' || !Number.isSafeInteger(defVersion) || defVersion < 0) return 'unsupported'
|
|
98
|
+
if (!isString(doc) || !SHA256_HEX.test(doc)) return 'unsupported'
|
|
99
|
+
return { contract: CARD_CONTRACT_VERSION, defVersion, doc }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The runtime's `src` must be CertREV's: an `https:` URL on `certrev.com` or one of its subdomains, with no credentials
|
|
104
|
+
* and no port (the rule the delivery marker's origin follows). Anything else is not a runtime, so no document, even one
|
|
105
|
+
* planted in a page's record, can make the card load a script from elsewhere (break-it B7).
|
|
106
|
+
*/
|
|
107
|
+
function isCertrevRuntimeUrl(value: unknown): boolean {
|
|
108
|
+
if (!isString(value)) return false
|
|
109
|
+
try {
|
|
110
|
+
const url = new URL(value)
|
|
111
|
+
const host = url.hostname.toLowerCase()
|
|
112
|
+
return (
|
|
113
|
+
url.protocol === 'https:' &&
|
|
114
|
+
!url.username &&
|
|
115
|
+
!url.password &&
|
|
116
|
+
!url.port &&
|
|
117
|
+
(host === 'certrev.com' || host.endsWith('.certrev.com'))
|
|
118
|
+
)
|
|
119
|
+
} catch {
|
|
120
|
+
return false
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The served document, shape-checked for what this client reads: the object itself when it is a contract-1 card
|
|
126
|
+
* document, else null. Nothing is copied or normalised (the digest covers the object as served). Parts it does not
|
|
127
|
+
* know are left in place (they are part of the digest) and never printed.
|
|
128
|
+
*/
|
|
129
|
+
export function parseCardDocument(value: unknown): CardDocumentV1 | null {
|
|
130
|
+
if (!isRecord(value)) return null
|
|
131
|
+
const { contract, kind, certId, subject, defVersion, layout, parts, dialogs, assets } = value
|
|
132
|
+
if (contract !== CARD_CONTRACT_VERSION || kind !== 'certrev.card' || !isString(certId)) return null
|
|
133
|
+
if (!isRecord(subject) || !isString(subject.platform) || !isString(subject.externalId)) return null
|
|
134
|
+
if (typeof defVersion !== 'number' || !Number.isSafeInteger(defVersion) || defVersion < 0) return null
|
|
135
|
+
if (!isString(layout) || !CARD_LAYOUTS.includes(layout)) return null
|
|
136
|
+
if (!isRecord(parts)) return null
|
|
137
|
+
for (const name of CARD_PART_NAMES) if (parts[name] !== undefined && !isString(parts[name])) return null
|
|
138
|
+
if (!isRecord(dialogs)) return null
|
|
139
|
+
if (dialogs.expert !== undefined && !isString(dialogs.expert)) return null
|
|
140
|
+
if (dialogs.certificate !== undefined && !isString(dialogs.certificate)) return null
|
|
141
|
+
if (!isRecord(assets) || !isString(assets.head)) return null
|
|
142
|
+
if (assets.runtime !== undefined) {
|
|
143
|
+
const runtime = assets.runtime
|
|
144
|
+
if (
|
|
145
|
+
!isRecord(runtime) ||
|
|
146
|
+
!isCertrevRuntimeUrl(runtime.src) ||
|
|
147
|
+
!isString(runtime.integrity) ||
|
|
148
|
+
!SRI.test(runtime.integrity)
|
|
149
|
+
) {
|
|
150
|
+
return null
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return value as unknown as CardDocumentV1
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Lowercase hex sha256 of a document's RFC 8785 bytes; null when it cannot be canonicalized or hashed. */
|
|
157
|
+
export async function cardDocumentDigest(card: unknown): Promise<string | null> {
|
|
158
|
+
let canonical: string
|
|
159
|
+
try {
|
|
160
|
+
canonical = canonicalizeJson(card)
|
|
161
|
+
} catch {
|
|
162
|
+
return null
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
return await sha256HexUtf8(canonical)
|
|
166
|
+
} catch {
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** One document's digest: the pending hash, and its answer once settled (null never matches). */
|
|
172
|
+
export interface CardDigestMemo {
|
|
173
|
+
readonly pending: Promise<string | null>
|
|
174
|
+
settled?: { readonly digest: string | null }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** How many documents' digests are kept (insertion order; the oldest goes first). A page holds one or a few. */
|
|
178
|
+
const CARD_DIGEST_MEMO_MAX = 32
|
|
179
|
+
const cardDigestMemo = new Map<string, CardDigestMemo>()
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The memoized digest of one document, keyed by its canonical bytes (a seed parsed afresh from the page and the
|
|
183
|
+
* server's cached read of the same document share one entry). A document that cannot be canonicalized settles to null
|
|
184
|
+
* at once, without a key.
|
|
185
|
+
*/
|
|
186
|
+
export function cardDigestFor(card: unknown): CardDigestMemo {
|
|
187
|
+
let key: string
|
|
188
|
+
try {
|
|
189
|
+
key = canonicalizeJson(card)
|
|
190
|
+
} catch {
|
|
191
|
+
const failed: CardDigestMemo = { pending: Promise.resolve(null), settled: { digest: null } }
|
|
192
|
+
return failed
|
|
193
|
+
}
|
|
194
|
+
const known = cardDigestMemo.get(key)
|
|
195
|
+
if (known) return known
|
|
196
|
+
const fresh: CardDigestMemo = {
|
|
197
|
+
pending: sha256HexUtf8(key).then(
|
|
198
|
+
(digest) => {
|
|
199
|
+
fresh.settled = { digest }
|
|
200
|
+
return digest
|
|
201
|
+
},
|
|
202
|
+
() => {
|
|
203
|
+
fresh.settled = { digest: null }
|
|
204
|
+
return null
|
|
205
|
+
},
|
|
206
|
+
),
|
|
207
|
+
}
|
|
208
|
+
evictOldestSettled(cardDigestMemo, CARD_DIGEST_MEMO_MAX)
|
|
209
|
+
cardDigestMemo.set(key, fresh)
|
|
210
|
+
return fresh
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Test seam: forget every document's digest (a new process). */
|
|
214
|
+
export function forgetCardDigestsForTests(): void {
|
|
215
|
+
cardDigestMemo.clear()
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The synchronous half of the check: the document binds this binding, payload and placement in everything but its
|
|
220
|
+
* bytes (contract, design version, certificate, and the Builder placement it was painted for).
|
|
221
|
+
*/
|
|
222
|
+
export function cardMatchesPlacement(
|
|
223
|
+
card: CardDocumentV1,
|
|
224
|
+
binding: CardBinding,
|
|
225
|
+
payload: CertPayload,
|
|
226
|
+
externalId: string,
|
|
227
|
+
): boolean {
|
|
228
|
+
return (
|
|
229
|
+
card.contract === binding.contract &&
|
|
230
|
+
card.defVersion === binding.defVersion &&
|
|
231
|
+
card.certId === payload.certId &&
|
|
232
|
+
card.subject.platform === 'builder' &&
|
|
233
|
+
card.subject.externalId === externalId
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The asynchronous half's verdict: the document's own digest is the signed one. */
|
|
238
|
+
export function cardDigestMatches(digest: string | null, binding: CardBinding): boolean {
|
|
239
|
+
return digest !== null && digest === binding.doc
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Both halves at once (for callers outside React): the served document is the one the verified envelope signs. */
|
|
243
|
+
export async function cardBinds(
|
|
244
|
+
card: CardDocumentV1,
|
|
245
|
+
binding: CardBinding,
|
|
246
|
+
payload: CertPayload,
|
|
247
|
+
externalId: string,
|
|
248
|
+
): Promise<boolean> {
|
|
249
|
+
if (!cardMatchesPlacement(card, binding, payload, externalId)) return false
|
|
250
|
+
return cardDigestMatches(await cardDigestFor(card).pending, binding)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The parts one Builder block prints, in page order. The saved design decides the layout (the block's `mode` is not
|
|
255
|
+
* read). A banner block prints its card (`top`) and its memo (`bottom`); a block whose `part` is `'header'` prints the
|
|
256
|
+
* card only, and one whose `part` is `'memo'` the memo only, so a split banner prints each part once. Outside the
|
|
257
|
+
* banner a `'memo'` block prints nothing and any other block the design's one part (the rail, the pill): a page that
|
|
258
|
+
* kept its split header and memo blocks after the brand chose a sidebar design shows one sidebar card, not two.
|
|
259
|
+
*/
|
|
260
|
+
export function cardPartsFor(card: CardDocumentV1, part: unknown): CardPartName[] {
|
|
261
|
+
let names: CardPartName[]
|
|
262
|
+
if (card.layout === 'banner') {
|
|
263
|
+
names = part === 'header' ? ['top'] : part === 'memo' ? ['bottom'] : ['top', 'bottom']
|
|
264
|
+
} else if (part === 'memo') {
|
|
265
|
+
names = []
|
|
266
|
+
} else {
|
|
267
|
+
names = card.layout === 'sidebar' ? ['rail'] : ['pill']
|
|
268
|
+
}
|
|
269
|
+
return names.filter((name) => typeof card.parts[name] === 'string')
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function escapeAttribute(value: string): string {
|
|
273
|
+
return value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** The runtime's tag, or '' when the document names none. */
|
|
277
|
+
export function cardRuntimeTag(card: CardDocumentV1): string {
|
|
278
|
+
const runtime = card.assets.runtime
|
|
279
|
+
if (!runtime) return ''
|
|
280
|
+
return `<script src="${escapeAttribute(runtime.src)}" integrity="${escapeAttribute(runtime.integrity)}" crossorigin="anonymous" defer></script>`
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Which of the document's parts is its first, in this order: the block holding it prints the page's assets. */
|
|
284
|
+
const CARD_FIRST_PART_ORDER: readonly CardPartName[] = ['top', 'rail', 'pill', 'bottom']
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The document's first part: `top`, else `rail`, else `pill`, from the document's own parts (the lead's ruling on ADR
|
|
288
|
+
* 0001's head-once-per-page, relay 074dbd2c8ef3); `bottom` only for a document with none of those, so its head and
|
|
289
|
+
* dialogs always have a block to print them. Null for a document with no part.
|
|
290
|
+
*/
|
|
291
|
+
export function cardFirstPart(card: CardDocumentV1): CardPartName | null {
|
|
292
|
+
return CARD_FIRST_PART_ORDER.find((name) => typeof card.parts[name] === 'string') ?? null
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Whether the block printing `names` prints the page's head fragment, dialogs and runtime: it holds the first part. */
|
|
296
|
+
export function cardCarriesAssets(card: CardDocumentV1, names: readonly CardPartName[]): boolean {
|
|
297
|
+
const first = cardFirstPart(card)
|
|
298
|
+
return first !== null && names.includes(first)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* 2.0.1: the page's CertREV graph, when the document carries one CertREV's server resolved for this placement
|
|
303
|
+
* (`card.jsonld.graph`: a complete JSON-LD object, every link and page fact filled in, an additive key under contract
|
|
304
|
+
* 1). Printed verbatim as `<script type="application/ld+json" data-certrev-graph ...>`, with every `<` written as
|
|
305
|
+
* `\u003c` so no value can close the element; '' when the document has no graph. The block never builds or edits the
|
|
306
|
+
* graph: what it says, and when it changes, is the server's.
|
|
307
|
+
*/
|
|
308
|
+
export function cardGraphTag(card: CardDocumentV1): string {
|
|
309
|
+
const jsonld = (card as unknown as { jsonld?: unknown }).jsonld
|
|
310
|
+
if (typeof jsonld !== 'object' || jsonld === null) return ''
|
|
311
|
+
const graph = (jsonld as { graph?: unknown }).graph
|
|
312
|
+
if (typeof graph !== 'object' || graph === null || Array.isArray(graph)) return ''
|
|
313
|
+
let json: string
|
|
314
|
+
try {
|
|
315
|
+
json = JSON.stringify(graph)
|
|
316
|
+
} catch {
|
|
317
|
+
return ''
|
|
318
|
+
}
|
|
319
|
+
const id = escapeAttribute(card.subject.externalId)
|
|
320
|
+
return `<script type="application/ld+json" data-certrev-graph="" data-certrev-platform="builder" data-certrev-external-id="${id}">${json.replace(/</g, '\\u003c')}</script>`
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Exactly what a block prints inside its wrapper: its parts in page order and, only in the block holding the document's
|
|
325
|
+
* first part (`cardCarriesAssets`), the head fragment before them and both dialogs (expert, then certificate), the
|
|
326
|
+
* runtime's tag and (2.0.1) the page's graph after. So a split banner (a header block and a memo block) prints the head, the dialogs and the runtime
|
|
327
|
+
* once, in the header block, with no coordination between the blocks and with JavaScript off; the runtime finds a
|
|
328
|
+
* control's dialog anywhere on the page by its placement. Every byte but the runtime tag is the document's own.
|
|
329
|
+
*/
|
|
330
|
+
export function cardHtml(card: CardDocumentV1, names: readonly CardPartName[]): string {
|
|
331
|
+
let parts = ''
|
|
332
|
+
for (const name of names) parts += card.parts[name] ?? ''
|
|
333
|
+
if (!cardCarriesAssets(card, names)) return parts
|
|
334
|
+
const dialogs = (card.dialogs.expert ?? '') + (card.dialogs.certificate ?? '')
|
|
335
|
+
return card.assets.head + parts + dialogs + cardRuntimeTag(card) + cardGraphTag(card)
|
|
336
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 2.0.1: the card document's placement hints (`card.placement`, ADR 0001 §2), applied by the block in the browser, so
|
|
3
|
+
* CertREV can move a banner's card and memo on a Builder page with no new install (Owen: the reviewer up top, the memo
|
|
4
|
+
* at the end).
|
|
5
|
+
*
|
|
6
|
+
* A Builder page renders an entry as `[builder-content-id] > .builder-blocks > .builder-block`, one top-level block per
|
|
7
|
+
* block the editor placed; the CertREV block is one of them. A hint names where a part belongs among those blocks:
|
|
8
|
+
*
|
|
9
|
+
* - `content-start`: before the first block; with `after: 'leading-media'`, after a first block that is only media
|
|
10
|
+
* (an image, a picture, a video or an iframe, and no text);
|
|
11
|
+
* - `after-byline`: after the block that holds the article's byline: an element marked `data-certrev-anchor="byline"`
|
|
12
|
+
* first, else the first `[rel~="author"]`, `[itemprop="author"]`, `.byline`, `[class*="byline"]` or `address`;
|
|
13
|
+
* - `before-references`: before the block that opens the references (a heading or label reading References, Sources,
|
|
14
|
+
* Citations or Works cited, or an element whose id names references);
|
|
15
|
+
* - `content-end`: after the last block;
|
|
16
|
+
* - `pinned`, or any anchor this client does not know: where the editor put it.
|
|
17
|
+
* An anchor that the page does not have falls back to the hint's `else`, and then to where the editor put it.
|
|
18
|
+
*
|
|
19
|
+
* Only a banner's parts move (`top`, `bottom`): a sidebar's rail floats beside the copy from the block the editor
|
|
20
|
+
* placed, and a floating pill is pinned to the viewport. A part moves into a holder (`data-certrev-placed`) inserted
|
|
21
|
+
* among the top-level blocks; the block itself stays where it was, with the card's head, dialogs and runtime.
|
|
22
|
+
*
|
|
23
|
+
* WHEN: in the browser only, after the page has loaded and React has hydrated (two frames after `load`), never during
|
|
24
|
+
* a render or a hydration, and never in Builder's visual editor or preview (`builder.frameEditing`,
|
|
25
|
+
* `builder.preview`, `__builder_editing__` in the address): an editor sees each block where it placed it. With JavaScript off, and until then, every part is where the editor put it. A card React
|
|
26
|
+
* prints again (a new document) or unmounts takes its moved parts with it first, so a part is never printed twice.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { CardDocumentV1, CardPartName } from './card-contract.js'
|
|
30
|
+
|
|
31
|
+
const MOVABLE: readonly CardPartName[] = ['top', 'bottom']
|
|
32
|
+
const PLACED_ATTRIBUTE = 'data-certrev-placed'
|
|
33
|
+
const MEDIA = 'img, picture, video, iframe, svg'
|
|
34
|
+
const BYLINE_MARKED = '[data-certrev-anchor="byline"]'
|
|
35
|
+
const BYLINE = '[rel~="author"], [itemprop="author"], .byline, [class*="byline"], address'
|
|
36
|
+
const REFERENCES_TEXT = /^\s*(references|sources|citations|works cited)\s*:?\s*$/i
|
|
37
|
+
const REFERENCES_LABEL = 'h1, h2, h3, h4, h5, h6, strong, b, p, summary, dt, figcaption'
|
|
38
|
+
|
|
39
|
+
interface Hint {
|
|
40
|
+
readonly anchor?: unknown
|
|
41
|
+
readonly after?: unknown
|
|
42
|
+
readonly else?: unknown
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Where a part goes: before or after one of the top-level blocks, or to the end of the column. */
|
|
46
|
+
type Target = { readonly before: Element } | { readonly after: Element } | { readonly end: Element }
|
|
47
|
+
|
|
48
|
+
function hintFor(card: CardDocumentV1, part: CardPartName): Hint | null {
|
|
49
|
+
const placement = (card as unknown as { placement?: unknown }).placement
|
|
50
|
+
if (typeof placement !== 'object' || placement === null) return null
|
|
51
|
+
const hint = (placement as Record<string, unknown>)[part]
|
|
52
|
+
return typeof hint === 'object' && hint !== null ? (hint as Hint) : null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A block that is only media: an image (or picture, video, iframe, svg) and no text of its own. */
|
|
56
|
+
function mediaOnly(block: Element): boolean {
|
|
57
|
+
return block.querySelector(MEDIA) !== null && (block.textContent ?? '').trim() === ''
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function blockOf(node: Element, blocks: readonly Element[]): Element | null {
|
|
61
|
+
return blocks.find((block) => block === node || block.contains(node)) ?? null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function referencesBlock(blocks: readonly Element[]): Element | null {
|
|
65
|
+
for (const block of blocks) {
|
|
66
|
+
if (/references|sources|citations/i.test(block.id)) return block
|
|
67
|
+
if (block.querySelector('[id*="references" i], [id*="sources" i], [id*="citations" i]')) return block
|
|
68
|
+
for (const label of Array.from(block.querySelectorAll(REFERENCES_LABEL))) {
|
|
69
|
+
if (REFERENCES_TEXT.test(label.textContent ?? '')) return block
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function bylineBlock(root: Element, blocks: readonly Element[], own: Element): Element | null {
|
|
76
|
+
for (const selector of [BYLINE_MARKED, BYLINE]) {
|
|
77
|
+
for (const node of Array.from(root.querySelectorAll(selector))) {
|
|
78
|
+
const block = blockOf(node, blocks)
|
|
79
|
+
if (block && block !== own) return block
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** One anchor, resolved among the top-level blocks other than the card's own; null when the page has no such place. */
|
|
86
|
+
function resolveAnchor(
|
|
87
|
+
anchor: unknown,
|
|
88
|
+
hint: Hint,
|
|
89
|
+
root: Element,
|
|
90
|
+
column: Element,
|
|
91
|
+
blocks: readonly Element[],
|
|
92
|
+
own: Element,
|
|
93
|
+
): Target | null {
|
|
94
|
+
const others = blocks.filter((block) => block !== own)
|
|
95
|
+
switch (anchor) {
|
|
96
|
+
case 'content-start': {
|
|
97
|
+
const first = others[0]
|
|
98
|
+
if (!first) return null
|
|
99
|
+
return hint.after === 'leading-media' && mediaOnly(first) ? { after: first } : { before: first }
|
|
100
|
+
}
|
|
101
|
+
case 'after-byline': {
|
|
102
|
+
const byline = bylineBlock(root, blocks, own)
|
|
103
|
+
return byline ? { after: byline } : null
|
|
104
|
+
}
|
|
105
|
+
case 'before-references': {
|
|
106
|
+
const references = referencesBlock(others)
|
|
107
|
+
return references ? { before: references } : null
|
|
108
|
+
}
|
|
109
|
+
case 'content-end':
|
|
110
|
+
return others.length > 0 ? { end: column } : null
|
|
111
|
+
default:
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** True when the card's own block already sits where `target` would put the part. */
|
|
117
|
+
function alreadyThere(target: Target, own: Element, blocks: readonly Element[]): boolean {
|
|
118
|
+
const index = blocks.indexOf(own)
|
|
119
|
+
if ('before' in target) return blocks[index + 1] === target.before || (index === 0 && blocks[0] === target.before)
|
|
120
|
+
if ('after' in target) return blocks[index - 1] === target.after
|
|
121
|
+
return index === blocks.length - 1
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Apply the document's hints to the parts this block printed; returns the undo. A no-op, and a no-op undo, outside a
|
|
126
|
+
* Builder page.
|
|
127
|
+
*
|
|
128
|
+
* The undo puts each moved part back in the block's wrapper, unless the wrapper already holds that part again (React
|
|
129
|
+
* printed a new document into it, which it does before any effect cleanup runs) or has left the page (the card
|
|
130
|
+
* unmounted): then the moved part is dropped with its holder. Either way no part is ever printed twice.
|
|
131
|
+
*/
|
|
132
|
+
export function applyCardPlacement(wrapper: Element, card: CardDocumentV1, names: readonly CardPartName[]): () => void {
|
|
133
|
+
const root = wrapper.closest('[builder-content-id]')
|
|
134
|
+
const column = root?.querySelector(':scope > .builder-blocks') ?? null
|
|
135
|
+
if (!root || !column) return () => {}
|
|
136
|
+
const blocks = Array.from(column.children).filter((child) => child.classList.contains('builder-block'))
|
|
137
|
+
const own = blockOf(wrapper, blocks)
|
|
138
|
+
if (!own) return () => {}
|
|
139
|
+
const placed: {
|
|
140
|
+
readonly holder: Element
|
|
141
|
+
readonly host: Element
|
|
142
|
+
readonly part: CardPartName
|
|
143
|
+
readonly next: Node | null
|
|
144
|
+
}[] = []
|
|
145
|
+
for (const part of names) {
|
|
146
|
+
if (!MOVABLE.includes(part)) continue
|
|
147
|
+
const hint = hintFor(card, part)
|
|
148
|
+
if (!hint) continue
|
|
149
|
+
const host = wrapper.querySelector(`:scope > certrev-card[data-certrev-part="${part}"]`)
|
|
150
|
+
if (!host) continue
|
|
151
|
+
const target =
|
|
152
|
+
resolveAnchor(hint.anchor, hint, root, column, blocks, own) ??
|
|
153
|
+
(hint.else !== undefined ? resolveAnchor(hint.else, hint, root, column, blocks, own) : null)
|
|
154
|
+
if (!target || alreadyThere(target, own, blocks)) continue
|
|
155
|
+
const next = host.nextSibling
|
|
156
|
+
const holder = (wrapper.ownerDocument ?? document).createElement('div')
|
|
157
|
+
holder.setAttribute(PLACED_ATTRIBUTE, part)
|
|
158
|
+
holder.setAttribute('data-certrev-face', 'card')
|
|
159
|
+
if ('before' in target) column.insertBefore(holder, target.before)
|
|
160
|
+
else if ('after' in target) column.insertBefore(holder, target.after.nextSibling)
|
|
161
|
+
else column.appendChild(holder)
|
|
162
|
+
holder.appendChild(host)
|
|
163
|
+
placed.push({ holder, host, part, next })
|
|
164
|
+
}
|
|
165
|
+
return () => {
|
|
166
|
+
for (const { holder, host, part, next } of placed) {
|
|
167
|
+
const printedAgain = wrapper.querySelector(`:scope > certrev-card[data-certrev-part="${part}"]`) !== null
|
|
168
|
+
if (wrapper.isConnected && !printedAgain) {
|
|
169
|
+
wrapper.insertBefore(host, next && next.parentNode === wrapper ? next : null)
|
|
170
|
+
}
|
|
171
|
+
holder.remove()
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Builder's visual editor and preview load the page with one of these in its address. */
|
|
177
|
+
const EDITOR_PARAMS = ['builder.frameEditing', 'builder.preview', '__builder_editing__']
|
|
178
|
+
|
|
179
|
+
/** True in Builder's visual editor or preview, where blocks stay where the editor placed them. */
|
|
180
|
+
export function inBuilderEditor(search: string = typeof location === 'undefined' ? '' : location.search): boolean {
|
|
181
|
+
const params = new URLSearchParams(search)
|
|
182
|
+
return EDITOR_PARAMS.some((name) => params.has(name))
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Run `apply` once the page has loaded and hydrated (two frames after `load`); returns a cancel that also undoes what
|
|
187
|
+
* `apply` did. Never runs in Builder's editor or preview.
|
|
188
|
+
*/
|
|
189
|
+
export function afterPageSettles(apply: () => () => void): () => void {
|
|
190
|
+
if (inBuilderEditor()) return () => {}
|
|
191
|
+
let undo: (() => void) | null = null
|
|
192
|
+
let cancelled = false
|
|
193
|
+
const run = () => {
|
|
194
|
+
if (cancelled) return
|
|
195
|
+
requestAnimationFrame(() =>
|
|
196
|
+
requestAnimationFrame(() => {
|
|
197
|
+
if (!cancelled) undo = apply()
|
|
198
|
+
}),
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
if (document.readyState === 'complete') run()
|
|
202
|
+
else window.addEventListener('load', run, { once: true })
|
|
203
|
+
return () => {
|
|
204
|
+
cancelled = true
|
|
205
|
+
window.removeEventListener('load', run)
|
|
206
|
+
undo?.()
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Builder card's fallback report (2.0.0; 2.0.1 adds the beacon): one structured event every time a placement does
|
|
3
|
+
* not print a card, so no fallback is silent.
|
|
4
|
+
*
|
|
5
|
+
* - In the browser: `CustomEvent('certrev:card-fallback', { detail })` on `document`, once per placement and reason per
|
|
6
|
+
* page, which a brand (or CertREV's own replica) can listen for; and (2.0.1) the same fallback to CertREV as a beacon
|
|
7
|
+
* (`card-beacon.ts`: no reader data, at most three per page).
|
|
8
|
+
* - On a server: one `console.warn` line, `[certrev] card_fallback {json}`, per render that takes the fallback; and
|
|
9
|
+
* (2.0.1) a beacon, each placement and reason at most once an hour per process.
|
|
10
|
+
*
|
|
11
|
+
* The same reason rides the placement's `'none'` record on the page (`ssr-seed.ts`), so a capture can read it too.
|
|
12
|
+
*
|
|
13
|
+
* Reasons (2.0.1: every one of them prints NOTHING; no fallback ever prints the block's own export-time options):
|
|
14
|
+
* - `card_missing`: a contract-1 placement (the signed binding says `contract: 1`) with no card in the response;
|
|
15
|
+
* - `card_verify_failed`: a card that is malformed, or does not hash to the signed digest, or names another design
|
|
16
|
+
* version, certificate or placement;
|
|
17
|
+
* - `card_unsupported_contract`: a signed binding with a `contract` this client does not print (a newer major);
|
|
18
|
+
* - `card_pin_mismatch`: CertREV's replica pinned a design version the signed card was not painted for;
|
|
19
|
+
* - `unknown_key`: the envelope's kid is neither baked nor in the origin's signed published key set;
|
|
20
|
+
* - `envelope_verify_failed` (2.0.1): the envelope itself did not verify (a bad signature, an algorithm this client does
|
|
21
|
+
* not accept, another platform, content that drifted from what was signed, a payload version it cannot read);
|
|
22
|
+
* - `delivery_unreachable`: CertREV could not be reached and no verified answer was cached inside the stale window
|
|
23
|
+
* (2.0.1: when CertREV answered with an HTTP error, the beacon says `http_5xx` or `http_4xx` and carries `status`);
|
|
24
|
+
* - `no_marker`: a block with no delivery marker (an entry exported before 1.1.0, or a block dropped in by hand);
|
|
25
|
+
* - `marker_invalid`: a delivery marker this client does not accept, or a block with no Builder entry to read for;
|
|
26
|
+
* - `client_read_off`: a block mounted in the browser with no server answer while the brand turned `clientRead` off.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { type BeaconOutcome, sendBrowserBeacon, sendServerBeacon } from './card-beacon.js'
|
|
30
|
+
|
|
31
|
+
export const CARD_FALLBACK_EVENT = 'certrev:card-fallback'
|
|
32
|
+
|
|
33
|
+
export type CardFallbackReason =
|
|
34
|
+
| 'card_missing'
|
|
35
|
+
| 'card_verify_failed'
|
|
36
|
+
| 'card_unsupported_contract'
|
|
37
|
+
| 'card_pin_mismatch'
|
|
38
|
+
| 'unknown_key'
|
|
39
|
+
| 'envelope_verify_failed'
|
|
40
|
+
| 'delivery_unreachable'
|
|
41
|
+
| 'no_marker'
|
|
42
|
+
| 'marker_invalid'
|
|
43
|
+
| 'client_read_off'
|
|
44
|
+
|
|
45
|
+
export const CARD_FALLBACK_REASONS: readonly CardFallbackReason[] = [
|
|
46
|
+
'card_missing',
|
|
47
|
+
'card_verify_failed',
|
|
48
|
+
'card_unsupported_contract',
|
|
49
|
+
'card_pin_mismatch',
|
|
50
|
+
'unknown_key',
|
|
51
|
+
'envelope_verify_failed',
|
|
52
|
+
'delivery_unreachable',
|
|
53
|
+
'no_marker',
|
|
54
|
+
'marker_invalid',
|
|
55
|
+
'client_read_off',
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
export interface CardFallbackDetail {
|
|
59
|
+
readonly reason: CardFallbackReason
|
|
60
|
+
readonly platform: 'builder'
|
|
61
|
+
readonly externalId: string
|
|
62
|
+
/** The certificate, when the envelope verified far enough to name it. */
|
|
63
|
+
readonly certId?: string
|
|
64
|
+
/** 2.0.1: with `delivery_unreachable`, the HTTP status CertREV answered with, when it answered. */
|
|
65
|
+
readonly status?: number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The beacon's outcome for each reason. */
|
|
69
|
+
const OUTCOMES: Readonly<Record<CardFallbackReason, BeaconOutcome>> = {
|
|
70
|
+
card_missing: 'other',
|
|
71
|
+
card_verify_failed: 'verify_failed',
|
|
72
|
+
card_unsupported_contract: 'unsupported',
|
|
73
|
+
card_pin_mismatch: 'other',
|
|
74
|
+
unknown_key: 'verify_failed',
|
|
75
|
+
envelope_verify_failed: 'verify_failed',
|
|
76
|
+
delivery_unreachable: 'unreachable',
|
|
77
|
+
no_marker: 'other',
|
|
78
|
+
marker_invalid: 'other',
|
|
79
|
+
client_read_off: 'other',
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Placement and reason pairs already reported on this page (browser only), and the document address they belong to. */
|
|
83
|
+
const reportedOnPage = new Set<string>()
|
|
84
|
+
let reportedPage: string | null = null
|
|
85
|
+
|
|
86
|
+
/** The beacon event for one fallback: an HTTP error says which, with its status. */
|
|
87
|
+
function fallbackEvent(detail: CardFallbackDetail) {
|
|
88
|
+
const status = detail.status
|
|
89
|
+
const outcome: BeaconOutcome =
|
|
90
|
+
status !== undefined && status >= 500
|
|
91
|
+
? 'http_5xx'
|
|
92
|
+
: status !== undefined && status >= 400
|
|
93
|
+
? 'http_4xx'
|
|
94
|
+
: OUTCOMES[detail.reason]
|
|
95
|
+
return { kind: 'fallback' as const, reason: detail.reason, outcome, ...(status === undefined ? {} : { status }) }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Report one fallback in the browser: the event and the beacon, once per placement and reason per page. `origin` is the
|
|
100
|
+
* delivery origin the block reads (absent for a block with no accepted marker: CertREV's production origin).
|
|
101
|
+
*/
|
|
102
|
+
export function reportCardFallbackInBrowser(detail: CardFallbackDetail, origin?: string): void {
|
|
103
|
+
if (typeof document === 'undefined' || typeof CustomEvent !== 'function') return
|
|
104
|
+
const page = typeof location !== 'undefined' ? `${location.pathname}${location.search}` : ''
|
|
105
|
+
if (page !== reportedPage) {
|
|
106
|
+
reportedPage = page
|
|
107
|
+
reportedOnPage.clear()
|
|
108
|
+
}
|
|
109
|
+
const key = `${detail.externalId}|${detail.reason}`
|
|
110
|
+
if (reportedOnPage.has(key)) return
|
|
111
|
+
reportedOnPage.add(key)
|
|
112
|
+
document.dispatchEvent(new CustomEvent(CARD_FALLBACK_EVENT, { detail }))
|
|
113
|
+
sendBrowserBeacon(origin, detail.externalId, fallbackEvent(detail))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Report one fallback on a server: one structured console line, and the beacon (at most once an hour per process). */
|
|
117
|
+
export function reportCardFallbackOnServer(detail: CardFallbackDetail, origin?: string): void {
|
|
118
|
+
console.warn(`[certrev] card_fallback ${JSON.stringify(detail)}`)
|
|
119
|
+
sendServerBeacon(origin, detail.externalId, fallbackEvent(detail))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Test seam: forget what this page reported (a new page). */
|
|
123
|
+
export function forgetCardReportsForTests(): void {
|
|
124
|
+
reportedOnPage.clear()
|
|
125
|
+
reportedPage = null
|
|
126
|
+
}
|