@certrev/cert-block 1.3.0 → 2.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.
Files changed (73) hide show
  1. package/CHANGELOG.md +167 -0
  2. package/README.md +125 -9
  3. package/dist/builder/card-contract.d.ts +143 -0
  4. package/dist/builder/card-contract.d.ts.map +1 -0
  5. package/dist/builder/card-contract.js +262 -0
  6. package/dist/builder/card-contract.js.map +1 -0
  7. package/dist/builder/card-report.d.ts +37 -0
  8. package/dist/builder/card-report.d.ts.map +1 -0
  9. package/dist/builder/card-report.js +43 -0
  10. package/dist/builder/card-report.js.map +1 -0
  11. package/dist/builder/cert-review-card.d.ts +7 -5
  12. package/dist/builder/cert-review-card.d.ts.map +1 -1
  13. package/dist/builder/cert-review-card.js +163 -19
  14. package/dist/builder/cert-review-card.js.map +1 -1
  15. package/dist/builder/contract-card.d.ts +53 -0
  16. package/dist/builder/contract-card.d.ts.map +1 -0
  17. package/dist/builder/contract-card.js +141 -0
  18. package/dist/builder/contract-card.js.map +1 -0
  19. package/dist/builder/delivered-face.d.ts +9 -0
  20. package/dist/builder/delivered-face.d.ts.map +1 -1
  21. package/dist/builder/delivered-face.js +18 -5
  22. package/dist/builder/delivered-face.js.map +1 -1
  23. package/dist/builder/delivery-runtime.d.ts +15 -1
  24. package/dist/builder/delivery-runtime.d.ts.map +1 -1
  25. package/dist/builder/delivery-runtime.js +24 -1
  26. package/dist/builder/delivery-runtime.js.map +1 -1
  27. package/dist/builder/index.d.ts +3 -0
  28. package/dist/builder/index.d.ts.map +1 -1
  29. package/dist/builder/index.js +4 -0
  30. package/dist/builder/index.js.map +1 -1
  31. package/dist/builder/ssr-seed.d.ts +18 -0
  32. package/dist/builder/ssr-seed.d.ts.map +1 -1
  33. package/dist/builder/ssr-seed.js +25 -3
  34. package/dist/builder/ssr-seed.js.map +1 -1
  35. package/dist/index.d.ts +1 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +2 -0
  38. package/dist/index.js.map +1 -1
  39. package/dist/reader/reader-css.generated.d.ts +1 -1
  40. package/dist/reader/reader-css.generated.d.ts.map +1 -1
  41. package/dist/reader/reader-css.generated.js +1 -1
  42. package/dist/reader/reader-css.generated.js.map +1 -1
  43. package/dist/reader/render.d.ts.map +1 -1
  44. package/dist/reader/render.js +71 -34
  45. package/dist/reader/render.js.map +1 -1
  46. package/dist/reader/tokens.d.ts +2 -0
  47. package/dist/reader/tokens.d.ts.map +1 -1
  48. package/dist/reader/tokens.js +28 -6
  49. package/dist/reader/tokens.js.map +1 -1
  50. package/dist/verify/get-verified-delivery.d.ts +28 -0
  51. package/dist/verify/get-verified-delivery.d.ts.map +1 -1
  52. package/dist/verify/get-verified-delivery.js +21 -2
  53. package/dist/verify/get-verified-delivery.js.map +1 -1
  54. package/dist/verify/key-set.d.ts +67 -0
  55. package/dist/verify/key-set.d.ts.map +1 -0
  56. package/dist/verify/key-set.js +190 -0
  57. package/dist/verify/key-set.js.map +1 -0
  58. package/package.json +115 -112
  59. package/src/builder/card-contract.ts +314 -0
  60. package/src/builder/card-report.ts +61 -0
  61. package/src/builder/cert-review-card.tsx +239 -25
  62. package/src/builder/contract-card.tsx +159 -0
  63. package/src/builder/delivered-face.ts +18 -4
  64. package/src/builder/delivery-runtime.ts +30 -1
  65. package/src/builder/index.tsx +12 -0
  66. package/src/builder/ssr-seed.ts +32 -3
  67. package/src/index.ts +9 -0
  68. package/src/reader/reader-css.generated.ts +1 -1
  69. package/src/reader/reader.css +297 -2
  70. package/src/reader/render.ts +87 -38
  71. package/src/reader/tokens.ts +29 -6
  72. package/src/verify/get-verified-delivery.ts +41 -2
  73. package/src/verify/key-set.ts +225 -0
@@ -0,0 +1,314 @@
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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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
+ * Exactly what a block prints inside its wrapper: its parts in page order and, only in the block holding the document's
303
+ * first part (`cardCarriesAssets`), the head fragment before them and both dialogs (expert, then certificate) and the
304
+ * runtime's tag after. So a split banner (a header block and a memo block) prints the head, the dialogs and the runtime
305
+ * once, in the header block, with no coordination between the blocks and with JavaScript off; the runtime finds a
306
+ * control's dialog anywhere on the page by its placement. Every byte but the runtime tag is the document's own.
307
+ */
308
+ export function cardHtml(card: CardDocumentV1, names: readonly CardPartName[]): string {
309
+ let parts = ''
310
+ for (const name of names) parts += card.parts[name] ?? ''
311
+ if (!cardCarriesAssets(card, names)) return parts
312
+ const dialogs = (card.dialogs.expert ?? '') + (card.dialogs.certificate ?? '')
313
+ return card.assets.head + parts + dialogs + cardRuntimeTag(card)
314
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The Builder card's fallback report (2.0.0): one structured event every time a placement does not print the card
3
+ * CertREV served, 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. A brand (or CertREV's own replica) listens for it; the card itself sends nothing anywhere: no beacon, no
7
+ * request, no storage.
8
+ * - On a server: one `console.warn` line, `[certrev] card_fallback {json}`, per render that takes the fallback.
9
+ *
10
+ * The same reason rides the placement's `'none'` record on the page (`ssr-seed.ts`), so a capture can read it too.
11
+ *
12
+ * Reasons:
13
+ * - `card_missing`: a contract-1 placement (the signed binding says `contract: 1`) with no card in the response;
14
+ * - `card_verify_failed`: a card that is malformed, or does not hash to the signed digest, or names another design
15
+ * version, certificate or placement;
16
+ * - `card_unsupported_contract`: a signed binding with a `contract` this client does not print;
17
+ * - `card_pin_mismatch`: CertREV's replica pinned a design version the signed card was not painted for;
18
+ * - `unknown_key`: the envelope's kid is neither baked nor in the origin's signed published key set;
19
+ * - `delivery_unreachable`: CertREV could not be reached, so the block's own options face was painted (1.2.0's
20
+ * deliberate fallback, unchanged, now reported).
21
+ */
22
+
23
+ export const CARD_FALLBACK_EVENT = 'certrev:card-fallback'
24
+
25
+ export type CardFallbackReason =
26
+ | 'card_missing'
27
+ | 'card_verify_failed'
28
+ | 'card_unsupported_contract'
29
+ | 'card_pin_mismatch'
30
+ | 'unknown_key'
31
+ | 'delivery_unreachable'
32
+
33
+ export interface CardFallbackDetail {
34
+ readonly reason: CardFallbackReason
35
+ readonly platform: 'builder'
36
+ readonly externalId: string
37
+ /** The certificate, when the envelope verified far enough to name it. */
38
+ readonly certId?: string
39
+ }
40
+
41
+ /** Placement and reason pairs already reported on this page (browser only). */
42
+ const reportedOnPage = new Set<string>()
43
+
44
+ /** Report one fallback in the browser: the event, once per placement and reason per page. */
45
+ export function reportCardFallbackInBrowser(detail: CardFallbackDetail): void {
46
+ if (typeof document === 'undefined' || typeof CustomEvent !== 'function') return
47
+ const key = `${detail.externalId}|${detail.reason}`
48
+ if (reportedOnPage.has(key)) return
49
+ reportedOnPage.add(key)
50
+ document.dispatchEvent(new CustomEvent(CARD_FALLBACK_EVENT, { detail }))
51
+ }
52
+
53
+ /** Report one fallback on a server: one structured console line. */
54
+ export function reportCardFallbackOnServer(detail: CardFallbackDetail): void {
55
+ console.warn(`[certrev] card_fallback ${JSON.stringify(detail)}`)
56
+ }
57
+
58
+ /** Test seam: forget what this page reported (a new page). */
59
+ export function forgetCardReportsForTests(): void {
60
+ reportedOnPage.clear()
61
+ }