@certrev/cert-block 2.0.0 → 2.0.2

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 (61) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/README.md +50 -10
  3. package/dist/builder/card-beacon.d.ts +73 -0
  4. package/dist/builder/card-beacon.d.ts.map +1 -0
  5. package/dist/builder/card-beacon.js +178 -0
  6. package/dist/builder/card-beacon.js.map +1 -0
  7. package/dist/builder/card-contract.d.ts +10 -2
  8. package/dist/builder/card-contract.d.ts.map +1 -1
  9. package/dist/builder/card-contract.js +27 -3
  10. package/dist/builder/card-contract.js.map +1 -1
  11. package/dist/builder/card-placement.d.ts +45 -0
  12. package/dist/builder/card-placement.d.ts.map +1 -0
  13. package/dist/builder/card-placement.js +193 -0
  14. package/dist/builder/card-placement.js.map +1 -0
  15. package/dist/builder/card-report.d.ts +26 -14
  16. package/dist/builder/card-report.d.ts.map +1 -1
  17. package/dist/builder/card-report.js +68 -14
  18. package/dist/builder/card-report.js.map +1 -1
  19. package/dist/builder/cert-review-card.d.ts +6 -4
  20. package/dist/builder/cert-review-card.d.ts.map +1 -1
  21. package/dist/builder/cert-review-card.js +172 -199
  22. package/dist/builder/cert-review-card.js.map +1 -1
  23. package/dist/builder/contract-card.d.ts +18 -1
  24. package/dist/builder/contract-card.d.ts.map +1 -1
  25. package/dist/builder/contract-card.js +60 -10
  26. package/dist/builder/contract-card.js.map +1 -1
  27. package/dist/builder/delivery-runtime.d.ts +38 -4
  28. package/dist/builder/delivery-runtime.d.ts.map +1 -1
  29. package/dist/builder/delivery-runtime.js +43 -5
  30. package/dist/builder/delivery-runtime.js.map +1 -1
  31. package/dist/builder/index.d.ts +4 -4
  32. package/dist/builder/index.js +4 -4
  33. package/dist/builder/ssr-seed.d.ts +5 -4
  34. package/dist/builder/ssr-seed.d.ts.map +1 -1
  35. package/dist/builder/ssr-seed.js +4 -3
  36. package/dist/builder/ssr-seed.js.map +1 -1
  37. package/dist/builder/version.d.ts +3 -0
  38. package/dist/builder/version.d.ts.map +1 -0
  39. package/dist/builder/version.js +3 -0
  40. package/dist/builder/version.js.map +1 -0
  41. package/dist/verify/cache.d.ts +24 -0
  42. package/dist/verify/cache.d.ts.map +1 -1
  43. package/dist/verify/cache.js +78 -6
  44. package/dist/verify/cache.js.map +1 -1
  45. package/dist/verify/get-verified-delivery.d.ts +18 -0
  46. package/dist/verify/get-verified-delivery.d.ts.map +1 -1
  47. package/dist/verify/get-verified-delivery.js +30 -3
  48. package/dist/verify/get-verified-delivery.js.map +1 -1
  49. package/package.json +1 -1
  50. package/src/builder/card-beacon.ts +201 -0
  51. package/src/builder/card-contract.ts +25 -3
  52. package/src/builder/card-placement.ts +208 -0
  53. package/src/builder/card-report.ts +79 -14
  54. package/src/builder/cert-review-card.tsx +208 -238
  55. package/src/builder/contract-card.tsx +65 -8
  56. package/src/builder/delivery-runtime.ts +45 -5
  57. package/src/builder/index.tsx +4 -4
  58. package/src/builder/ssr-seed.ts +5 -4
  59. package/src/builder/version.ts +2 -0
  60. package/src/verify/cache.ts +78 -6
  61. package/src/verify/get-verified-delivery.ts +40 -3
@@ -0,0 +1,201 @@
1
+ /**
2
+ * The Builder card's beacon (2.0.1): what the card tells CertREV about itself, so a card that does not print on a brand's
3
+ * page is seen by CertREV and not only by the brand's console.
4
+ *
5
+ * One `POST <delivery origin>/api/cert/v1/beacon` per event, a `text/plain` JSON body (a CORS simple request, so no
6
+ * preflight; the origin is the delivery origin the page's `connect-src` already allows, so no new CSP source):
7
+ *
8
+ * { v: 1, client: 'cert-block', clientVersion, contract, platform: 'builder', externalId, family,
9
+ * events: [{ kind: 'fallback' | 'health', reason, outcome, status? }] }
10
+ *
11
+ * What it never carries: cookies (`credentials: 'omit'`), the page's address (`referrerPolicy: 'no-referrer'`) or text, the reader's user agent (only a
12
+ * coarse family: chromium, webkit, gecko, other, or server), or anything the reader typed. `externalId` is the Builder
13
+ * entry the block renders in, which the brand's page already sends to CertREV when it reads the card.
14
+ *
15
+ * How much: in a browser, every fallback (each placement and reason once per page, `card-report.ts`) and one health
16
+ * event per page in a hundred, at most three beacons per page (a page is a document address: an in-app navigation to
17
+ * another article starts a new budget); on a server, each placement, reason and origin at most
18
+ * once an hour per process, with a short deadline, never awaited. A beacon that fails is dropped: it never retries,
19
+ * never stores anything and never changes what the card prints.
20
+ */
21
+
22
+ import { CARD_CONTRACT_VERSION } from './card-contract.js'
23
+ import { CERT_BLOCK_VERSION } from './version.js'
24
+
25
+ export const BEACON_PATH = '/api/cert/v1/beacon'
26
+ /** Where a block with no accepted delivery marker reports: CertREV's production delivery origin. */
27
+ export const DEFAULT_BEACON_ORIGIN = 'https://portal.certrev.com'
28
+ /** The most beacons one page sends. */
29
+ export const BEACON_MAX_PER_PAGE = 3
30
+ /** The share of page views that send one health event. */
31
+ export const BEACON_HEALTH_SAMPLE = 0.01
32
+ /** A server sends one beacon per placement, reason and origin at most this often. */
33
+ export const BEACON_SERVER_INTERVAL_MS = 3_600_000
34
+ const SERVER_MAX_KEYS = 500
35
+ const SERVER_TIMEOUT_MS = 1_500
36
+
37
+ export type BeaconOutcome = 'ok' | 'unreachable' | 'http_4xx' | 'http_5xx' | 'verify_failed' | 'unsupported' | 'other'
38
+ export type BeaconFamily = 'chromium' | 'webkit' | 'gecko' | 'server' | 'other'
39
+
40
+ export interface BeaconEvent {
41
+ readonly kind: 'fallback' | 'health'
42
+ readonly reason: string
43
+ readonly outcome: BeaconOutcome
44
+ /** The HTTP status CertREV answered with, when a read failed on one (outcome `http_4xx` or `http_5xx`). */
45
+ readonly status?: number
46
+ }
47
+
48
+ export interface CardBeacon {
49
+ readonly v: 1
50
+ readonly client: 'cert-block'
51
+ readonly clientVersion: string
52
+ readonly contract: number
53
+ readonly platform: 'builder'
54
+ readonly externalId?: string
55
+ readonly family: BeaconFamily
56
+ readonly events: readonly BeaconEvent[]
57
+ }
58
+
59
+ /** How a beacon leaves: `url`, the JSON body, and where it is sent from. */
60
+ export type BeaconTransport = (url: string, body: string, where: 'browser' | 'server') => void
61
+
62
+ /** The transport the card ships with: `fetch`, never awaited, never throwing (exported for CertREV's replica harness). */
63
+ export const fetchBeaconTransport: BeaconTransport = (url, body, where) => {
64
+ try {
65
+ const send = globalThis.fetch
66
+ if (typeof send !== 'function') return
67
+ const init: RequestInit = {
68
+ method: 'POST',
69
+ body,
70
+ headers: { 'content-type': 'text/plain;charset=UTF-8' },
71
+ credentials: 'omit',
72
+ // Never the reader's page address, whatever the storefront's Referrer-Policy.
73
+ referrerPolicy: 'no-referrer',
74
+ ...(where === 'browser'
75
+ ? // Opaque and kept alive: no CORS read of the answer, and it outlives a navigation away.
76
+ { mode: 'no-cors' as const, keepalive: true }
77
+ : typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
78
+ ? { signal: AbortSignal.timeout(SERVER_TIMEOUT_MS) }
79
+ : {}),
80
+ }
81
+ void send(url, init).catch(() => {})
82
+ } catch {
83
+ // A beacon never throws into a render.
84
+ }
85
+ }
86
+
87
+ let transport: BeaconTransport = fetchBeaconTransport
88
+ let sentOnPage = 0
89
+ let healthDrawn: boolean | null = null
90
+ /** The document address the page budget belongs to. */
91
+ let budgetPage: string | null = null
92
+
93
+ /** Start a new page budget when the document's address changed (an in-app navigation). */
94
+ function samePageBudget(): void {
95
+ const page = typeof location !== 'undefined' ? `${location.pathname}${location.search}` : ''
96
+ if (page === budgetPage) return
97
+ budgetPage = page
98
+ sentOnPage = 0
99
+ healthDrawn = null
100
+ }
101
+ const sentOnServer = new Map<string, number>()
102
+ let random: () => number = Math.random
103
+ let clock: () => number = Date.now
104
+
105
+ /** The coarse browser family, from the user agent; nothing else of it is kept. */
106
+ export function browserFamily(userAgent: string): BeaconFamily {
107
+ if (/Firefox\//.test(userAgent)) return 'gecko'
108
+ if (/(Chrome|Chromium|CriOS|Edg)\//.test(userAgent)) return 'chromium'
109
+ if (/AppleWebKit\//.test(userAgent)) return 'webkit'
110
+ return 'other'
111
+ }
112
+
113
+ /** The delivery origin a beacon goes to: an `https:` origin on certrev.com, else CertREV's production origin. */
114
+ function beaconOrigin(baseUrl: string | undefined): string {
115
+ if (!baseUrl) return DEFAULT_BEACON_ORIGIN
116
+ try {
117
+ const url = new URL(baseUrl)
118
+ const host = url.hostname
119
+ if (url.protocol === 'https:' && (host === 'certrev.com' || host.endsWith('.certrev.com'))) return url.origin
120
+ } catch {
121
+ // Fall through.
122
+ }
123
+ return DEFAULT_BEACON_ORIGIN
124
+ }
125
+
126
+ export function beaconBody(externalId: string, events: readonly BeaconEvent[], family: BeaconFamily): CardBeacon {
127
+ return {
128
+ v: 1,
129
+ client: 'cert-block',
130
+ clientVersion: CERT_BLOCK_VERSION,
131
+ contract: CARD_CONTRACT_VERSION,
132
+ platform: 'builder',
133
+ ...(externalId ? { externalId } : {}),
134
+ family,
135
+ events,
136
+ }
137
+ }
138
+
139
+ /** Send one event from the browser, within the page's budget. */
140
+ export function sendBrowserBeacon(baseUrl: string | undefined, externalId: string, event: BeaconEvent): void {
141
+ if (typeof document === 'undefined') return
142
+ samePageBudget()
143
+ if (sentOnPage >= BEACON_MAX_PER_PAGE) return
144
+ sentOnPage++
145
+ const userAgent = typeof navigator !== 'undefined' ? String(navigator.userAgent ?? '') : ''
146
+ const body = beaconBody(externalId, [event], browserFamily(userAgent))
147
+ transport(`${beaconOrigin(baseUrl)}${BEACON_PATH}`, JSON.stringify(body), 'browser')
148
+ }
149
+
150
+ /** Send one health event from the browser on one page view in a hundred (drawn once per page). */
151
+ export function maybeSendBrowserHealth(baseUrl: string | undefined, externalId: string): void {
152
+ if (typeof document === 'undefined') return
153
+ samePageBudget()
154
+ if (healthDrawn === null) healthDrawn = random() < BEACON_HEALTH_SAMPLE
155
+ if (!healthDrawn) return
156
+ healthDrawn = false
157
+ sendBrowserBeacon(baseUrl, externalId, { kind: 'health', reason: 'card_printed', outcome: 'ok' })
158
+ }
159
+
160
+ /** Send one event from a server: each placement, reason and origin at most once an hour per process. */
161
+ export function sendServerBeacon(baseUrl: string | undefined, externalId: string, event: BeaconEvent): void {
162
+ const origin = beaconOrigin(baseUrl)
163
+ const key = `${origin}|${externalId}|${event.kind}|${event.reason}`
164
+ const now = clock()
165
+ const last = sentOnServer.get(key)
166
+ if (last !== undefined && now - last < BEACON_SERVER_INTERVAL_MS) return
167
+ sentOnServer.delete(key)
168
+ if (sentOnServer.size >= SERVER_MAX_KEYS) {
169
+ const oldest = sentOnServer.keys().next().value
170
+ if (oldest !== undefined) sentOnServer.delete(oldest)
171
+ }
172
+ sentOnServer.set(key, now)
173
+ transport(`${origin}${BEACON_PATH}`, JSON.stringify(beaconBody(externalId, [event], 'server')), 'server')
174
+ }
175
+
176
+ /**
177
+ * Test seam: replace the transport (and the sampling draw and clock) and forget the page's and the process's budget.
178
+ * Returns a restore.
179
+ */
180
+ export function setCardBeaconForTests(
181
+ overrides: { transport?: BeaconTransport; random?: () => number; now?: () => number } = {},
182
+ ): () => void {
183
+ const previous = { transport, random, clock }
184
+ transport = overrides.transport ?? (() => {})
185
+ random = overrides.random ?? (() => 1)
186
+ clock = overrides.now ?? Date.now
187
+ forgetCardBeaconsForTests()
188
+ return () => {
189
+ transport = previous.transport
190
+ random = previous.random
191
+ clock = previous.clock
192
+ }
193
+ }
194
+
195
+ /** Test seam: a new page and a new process. */
196
+ export function forgetCardBeaconsForTests(): void {
197
+ sentOnPage = 0
198
+ healthDrawn = null
199
+ budgetPage = null
200
+ sentOnServer.clear()
201
+ }
@@ -298,10 +298,32 @@ export function cardCarriesAssets(card: CardDocumentV1, names: readonly CardPart
298
298
  return first !== null && names.includes(first)
299
299
  }
300
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
+
301
323
  /**
302
324
  * 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
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
305
327
  * once, in the header block, with no coordination between the blocks and with JavaScript off; the runtime finds a
306
328
  * control's dialog anywhere on the page by its placement. Every byte but the runtime tag is the document's own.
307
329
  */
@@ -310,5 +332,5 @@ export function cardHtml(card: CardDocumentV1, names: readonly CardPartName[]):
310
332
  for (const name of names) parts += card.parts[name] ?? ''
311
333
  if (!cardCarriesAssets(card, names)) return parts
312
334
  const dialogs = (card.dialogs.expert ?? '') + (card.dialogs.certificate ?? '')
313
- return card.assets.head + parts + dialogs + cardRuntimeTag(card)
335
+ return card.assets.head + parts + dialogs + cardRuntimeTag(card) + cardGraphTag(card)
314
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
+ }
@@ -1,25 +1,33 @@
1
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.
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
4
  *
5
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.
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.
9
10
  *
10
11
  * The same reason rides the placement's `'none'` record on the page (`ssr-seed.ts`), so a capture can read it too.
11
12
  *
12
- * Reasons:
13
+ * Reasons (2.0.1: every one of them prints NOTHING; no fallback ever prints the block's own export-time options):
13
14
  * - `card_missing`: a contract-1 placement (the signed binding says `contract: 1`) with no card in the response;
14
15
  * - `card_verify_failed`: a card that is malformed, or does not hash to the signed digest, or names another design
15
16
  * version, certificate or placement;
16
- * - `card_unsupported_contract`: a signed binding with a `contract` this client does not print;
17
+ * - `card_unsupported_contract`: a signed binding with a `contract` this client does not print (a newer major);
17
18
  * - `card_pin_mismatch`: CertREV's replica pinned a design version the signed card was not painted for;
18
19
  * - `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).
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.
21
27
  */
22
28
 
29
+ import { type BeaconOutcome, sendBrowserBeacon, sendServerBeacon } from './card-beacon.js'
30
+
23
31
  export const CARD_FALLBACK_EVENT = 'certrev:card-fallback'
24
32
 
25
33
  export type CardFallbackReason =
@@ -28,7 +36,24 @@ export type CardFallbackReason =
28
36
  | 'card_unsupported_contract'
29
37
  | 'card_pin_mismatch'
30
38
  | 'unknown_key'
39
+ | 'envelope_verify_failed'
31
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
+ ]
32
57
 
33
58
  export interface CardFallbackDetail {
34
59
  readonly reason: CardFallbackReason
@@ -36,26 +61,66 @@ export interface CardFallbackDetail {
36
61
  readonly externalId: string
37
62
  /** The certificate, when the envelope verified far enough to name it. */
38
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',
39
80
  }
40
81
 
41
- /** Placement and reason pairs already reported on this page (browser only). */
82
+ /** Placement and reason pairs already reported on this page (browser only), and the document address they belong to. */
42
83
  const reportedOnPage = new Set<string>()
84
+ let reportedPage: string | null = null
43
85
 
44
- /** Report one fallback in the browser: the event, once per placement and reason per page. */
45
- export function reportCardFallbackInBrowser(detail: CardFallbackDetail): void {
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 {
46
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
+ }
47
109
  const key = `${detail.externalId}|${detail.reason}`
48
110
  if (reportedOnPage.has(key)) return
49
111
  reportedOnPage.add(key)
50
112
  document.dispatchEvent(new CustomEvent(CARD_FALLBACK_EVENT, { detail }))
113
+ sendBrowserBeacon(origin, detail.externalId, fallbackEvent(detail))
51
114
  }
52
115
 
53
- /** Report one fallback on a server: one structured console line. */
54
- export function reportCardFallbackOnServer(detail: CardFallbackDetail): void {
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 {
55
118
  console.warn(`[certrev] card_fallback ${JSON.stringify(detail)}`)
119
+ sendServerBeacon(origin, detail.externalId, fallbackEvent(detail))
56
120
  }
57
121
 
58
122
  /** Test seam: forget what this page reported (a new page). */
59
123
  export function forgetCardReportsForTests(): void {
60
124
  reportedOnPage.clear()
125
+ reportedPage = null
61
126
  }