@certrev/cert-block 1.2.0-canary.7 → 1.2.1-canary.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 (33) hide show
  1. package/CHANGELOG.md +155 -63
  2. package/README.md +130 -21
  3. package/dist/builder/card-reader-css.d.ts +14 -0
  4. package/dist/builder/card-reader-css.d.ts.map +1 -0
  5. package/dist/builder/card-reader-css.js +17 -0
  6. package/dist/builder/card-reader-css.js.map +1 -0
  7. package/dist/builder/cert-review-card.d.ts +8 -0
  8. package/dist/builder/cert-review-card.d.ts.map +1 -1
  9. package/dist/builder/cert-review-card.js +271 -41
  10. package/dist/builder/cert-review-card.js.map +1 -1
  11. package/dist/builder/delivery-runtime.d.ts +15 -1
  12. package/dist/builder/delivery-runtime.d.ts.map +1 -1
  13. package/dist/builder/delivery-runtime.js +31 -3
  14. package/dist/builder/delivery-runtime.js.map +1 -1
  15. package/dist/builder/ssr-seed.d.ts +168 -0
  16. package/dist/builder/ssr-seed.d.ts.map +1 -0
  17. package/dist/builder/ssr-seed.js +421 -0
  18. package/dist/builder/ssr-seed.js.map +1 -0
  19. package/dist/verify/cache.d.ts +39 -2
  20. package/dist/verify/cache.d.ts.map +1 -1
  21. package/dist/verify/cache.js +61 -12
  22. package/dist/verify/cache.js.map +1 -1
  23. package/dist/verify/get-verified-delivery.d.ts +28 -3
  24. package/dist/verify/get-verified-delivery.d.ts.map +1 -1
  25. package/dist/verify/get-verified-delivery.js +46 -9
  26. package/dist/verify/get-verified-delivery.js.map +1 -1
  27. package/package.json +112 -112
  28. package/src/builder/card-reader-css.ts +19 -0
  29. package/src/builder/cert-review-card.tsx +359 -56
  30. package/src/builder/delivery-runtime.ts +33 -3
  31. package/src/builder/ssr-seed.ts +496 -0
  32. package/src/verify/cache.ts +87 -11
  33. package/src/verify/get-verified-delivery.ts +77 -11
@@ -23,18 +23,33 @@ import { Suspense } from 'react'
23
23
  import { memoToggleFor, toggleMemo } from '../components/memo-toggle.js'
24
24
  import { type CertBlockMode, renderCertBlock } from '../components/render-cert-block.js'
25
25
  import { CERT_STRINGS_VERSION, type ResolvedBlockTheme } from '../components/render-def.js'
26
- import { READER_CSS } from '../reader/reader-css.generated.js'
26
+ import { type CertPayload, renderVerdict } from '../contract/kernel.js'
27
27
  import {
28
28
  type DesignPin,
29
29
  getVerifiedDelivery,
30
30
  parseDesignPin,
31
- peekVerifiedDelivery,
31
+ readVerifiedDelivery,
32
32
  type VerifiedDelivery,
33
33
  } from '../verify/get-verified-delivery.js'
34
- import type { BuilderCertChromeData } from './cert-chrome-data.js'
34
+ import { CARD_READER_CSS } from './card-reader-css.js'
35
+ import type { BuilderCertChromeData, CertChromeRenderDef } from './cert-chrome-data.js'
35
36
  import { BUILDER_CERT_CHROME_KEYS } from './cert-chrome-data.js'
36
37
  import { cardDeliveryRuntime } from './delivery-runtime.js'
37
38
  import { envelopeCardHtml, envelopeCardInput, isReaderFaceDef } from './envelope-face.js'
39
+ import {
40
+ type FallbackFace,
41
+ type PageRecord,
42
+ pageHoldsServerHtml,
43
+ pageRecordFor,
44
+ SEED_ATTRIBUTE,
45
+ seedFor,
46
+ serializeFallback,
47
+ serializeSeed,
48
+ streamedRecordFor,
49
+ subscribeStreamedRecords,
50
+ watchStreamedRecords,
51
+ withoutPhotos,
52
+ } from './ssr-seed.js'
38
53
 
39
54
  /**
40
55
  * The canonical Builder block name. A brand's registration + the portal
@@ -75,6 +90,14 @@ export interface CertReviewCardProps extends Partial<BuilderCertChromeData> {
75
90
  readonly content?: { readonly id?: unknown } | null
76
91
  readonly context?: { readonly certrevDesignPin?: unknown } | null
77
92
  } | null
93
+ /**
94
+ * 1.2.0: opt in to a Delivery read FROM THE BROWSER for a card the server did not render (a client-side navigation,
95
+ * Builder's editor). Off by default: the card then never makes a request from the browser, so a brand's
96
+ * Content-Security-Policy needs no CertREV origin, and such a card paints the block's own options face. Turning it
97
+ * on needs the Delivery API's origin in the page's `connect-src`. Not a Builder input: a brand sets it in code, on
98
+ * a wrapper of this component.
99
+ */
100
+ clientRead?: boolean
78
101
  }
79
102
 
80
103
  /**
@@ -139,7 +162,7 @@ export function acceptedDeliveryMarker(value: unknown): { readonly baseUrl: stri
139
162
  /**
140
163
  * Suspend on a Delivery read. React 19 gets `use` with ONE promise per placement while it is in
141
164
  * flight; React 18 (no `use`) gets the promise thrown, the Suspense protocol it supports. Either
142
- * way the retry finds the settled read in the cache through `peekVerifiedDelivery`, so it never
165
+ * way the retry finds the settled read in the cache through `readVerifiedDelivery`, so it never
143
166
  * suspends on a second promise.
144
167
  */
145
168
  const inflightReads = new Map<string, Promise<VerifiedDelivery>>()
@@ -202,8 +225,13 @@ function EnvelopeFace(props: {
202
225
  readonly html: string
203
226
  readonly defVersion: number | undefined
204
227
  readonly reader?: boolean
228
+ /** The envelope seed (`ssr-seed.ts`): what a browser hydrating this face paints from, with no read of its own. */
229
+ readonly seed: string
230
+ /** 1.2.0, reader faces: told which person's photo the browser could not load (`ReaderEnvelopeFace`). */
231
+ readonly onBrokenPhoto?: (person: PhotoPerson) => void
205
232
  }) {
206
233
  const ref = React.useRef<HTMLDivElement>(null)
234
+ const { onBrokenPhoto } = props
207
235
  React.useEffect(() => {
208
236
  const el = ref.current
209
237
  if (!el) return
@@ -214,6 +242,23 @@ function EnvelopeFace(props: {
214
242
  el.addEventListener('click', onClick)
215
243
  return () => el.removeEventListener('click', onClick)
216
244
  }, [])
245
+ React.useEffect(() => {
246
+ const el = ref.current
247
+ if (!el || !onBrokenPhoto) return
248
+ // A photo that failed before this effect ran (during hydration) has no error event left to hear.
249
+ for (const img of Array.from(el.querySelectorAll('.certrev-r-face > img'))) {
250
+ if (img instanceof HTMLImageElement && img.complete && img.naturalWidth === 0) onBrokenPhoto(photoPerson(img))
251
+ }
252
+ // An image's `error` does not bubble, so it is heard in the capture phase.
253
+ const onError = (e: Event) => {
254
+ const img = e.target
255
+ if (img instanceof HTMLImageElement && img.parentElement?.classList.contains('certrev-r-face')) {
256
+ onBrokenPhoto(photoPerson(img))
257
+ }
258
+ }
259
+ el.addEventListener('error', onError, true)
260
+ return () => el.removeEventListener('error', onError, true)
261
+ }, [onBrokenPhoto])
217
262
  return withSidebarFlow(
218
263
  props.reader,
219
264
  <div
@@ -224,6 +269,7 @@ function EnvelopeFace(props: {
224
269
  data-certrev-delivery=""
225
270
  data-certrev-strings-version={CERT_STRINGS_VERSION}
226
271
  data-certrev-def-version={props.defVersion}
272
+ {...{ [SEED_ATTRIBUTE]: props.seed }}
227
273
  {...(props.reader ? { 'data-certrev-face': 'reader', style: { display: 'flow-root' } } : {})}
228
274
  // biome-ignore lint/security/noDangerouslySetInnerHtml: renderCertBlock is the escaping-safe SSR string renderer
229
275
  dangerouslySetInnerHTML={{ __html: props.html }}
@@ -231,17 +277,290 @@ function EnvelopeFace(props: {
231
277
  )
232
278
  }
233
279
 
234
- function EnvelopeCertReviewCard(props: {
280
+ type PhotoPerson = 'expert' | 'author'
281
+
282
+ /** The reader face paints the author's avatar small (`.certrev-r-face.sm`); every other avatar is the expert's. */
283
+ function photoPerson(img: HTMLImageElement): PhotoPerson {
284
+ return img.parentElement?.classList.contains('sm') ? 'author' : 'expert'
285
+ }
286
+
287
+ interface EnvelopeCardProps {
235
288
  readonly baseUrl: string
236
289
  readonly externalId: string
237
290
  readonly mode: unknown
238
291
  readonly part: unknown
239
292
  readonly design?: DesignPin
293
+ /** The block's own options: the face painted when CertREV could not be reached, or with no server render. */
294
+ readonly block: CertReviewCardProps
295
+ readonly clientRead: boolean
296
+ }
297
+
298
+ /**
299
+ * 1.2.0-canary.5: a pinned read paints only the design it pinned, a reader def at exactly that version. A def served
300
+ * for anything else (none, another face, another version) paints nothing, never the live design in its place.
301
+ */
302
+ function pinnedDefHolds(design: DesignPin | undefined, renderDef: CertChromeRenderDef | null): boolean {
303
+ return !design || (isReaderFaceDef(renderDef) && renderDef?.v === design.version)
304
+ }
305
+
306
+ /**
307
+ * 1.2.0: the reader face, repainted with the reviewer's (or the author's) initials when the browser cannot load their
308
+ * photo: a brand's CSP `img-src` that does not name the photo's host, a 404. The first paint is the server's, photo
309
+ * included, so hydration matches; a photo that fails is dropped from the payload this face paints
310
+ * (`withoutPhotos`), and the face's own avatar rule paints the initials in its place. The seed is not touched.
311
+ */
312
+ function ReaderEnvelopeFace(props: {
313
+ readonly payload: CertPayload
314
+ readonly renderDef: CertChromeRenderDef
315
+ readonly card: EnvelopeCardProps
316
+ readonly seed: string
240
317
  }) {
318
+ const [broken, setBroken] = React.useState<readonly PhotoPerson[]>([])
319
+ const onBrokenPhoto = React.useCallback(
320
+ (person: PhotoPerson) => setBroken((was) => (was.includes(person) ? was : [...was, person])),
321
+ [],
322
+ )
323
+ const payload = broken.length > 0 ? withoutPhotos(props.payload, broken) : props.payload
324
+ const reader = envelopeCardHtml(payload.content, props.renderDef, {
325
+ mode: props.card.mode,
326
+ part: props.card.part,
327
+ certId: payload.certId,
328
+ })
329
+ if (!reader) return null
330
+ return (
331
+ <EnvelopeFace
332
+ reader
333
+ // The stylesheet rides inside the face, once per card: the portal's reader CSS less its remote font
334
+ // faces (card-reader-css.ts), so the card asks the page's CSP for nothing.
335
+ html={`<style data-certrev-reader-css>${CARD_READER_CSS}</style>${reader}`}
336
+ defVersion={props.renderDef.v}
337
+ seed={props.seed}
338
+ onBrokenPhoto={onBrokenPhoto}
339
+ />
340
+ )
341
+ }
342
+
343
+ /** Paint a verified payload with its def; null when the def and content give no face. */
344
+ function paintEnvelope(
345
+ payload: CertPayload,
346
+ renderDef: CertChromeRenderDef | null,
347
+ props: EnvelopeCardProps,
348
+ seed: string,
349
+ ): React.ReactElement | null {
350
+ if (!pinnedDefHolds(props.design, renderDef)) return null
351
+ const defVersion = renderDef ? renderDef.v : undefined
352
+ // The reader face is opt-in by the def's own `face` and is chosen HERE and nowhere else on this path; every other
353
+ // def (no face, an unknown face) falls through to the legacy lines below, untouched since 1.1.1.
354
+ if (isReaderFaceDef(renderDef) && renderDef) {
355
+ // Without a face there is nothing to repaint; checked here so the null stays this function's answer.
356
+ if (!envelopeCardHtml(payload.content, renderDef, { mode: props.mode, part: props.part, certId: payload.certId }))
357
+ return null
358
+ return <ReaderEnvelopeFace payload={payload} renderDef={renderDef} card={props} seed={seed} />
359
+ }
360
+ const input = envelopeCardInput(payload.content, renderDef, { mode: props.mode, part: props.part })
361
+ if (!input) return null
362
+ return <EnvelopeFace html={renderCertBlock(input)} defVersion={defVersion} seed={seed} />
363
+ }
364
+
365
+ const FALLBACK_LAYOUTS: readonly string[] = ['banner', 'sidebar', 'floating']
366
+
367
+ /**
368
+ * The options face a block with the delivery marker paints in place of the envelope (1.2.0), or null. Its placement is
369
+ * the block's own `mode`, else its def's layout, as the envelope face's is, else the banner bridge. A pinned card (the
370
+ * canary replica) paints it too, so the replica shows what a brand's page shows when CertREV is not reached; its
371
+ * record names the pin and says `'options'`, so a capture can never take it for the pinned design.
372
+ */
373
+ function fallbackFace(props: EnvelopeCardProps): string | null {
374
+ const { block } = props
375
+ const layout = block.renderDef?.layout
376
+ const mode =
377
+ typeof block.mode === 'string' && FALLBACK_LAYOUTS.includes(block.mode)
378
+ ? block.mode
379
+ : typeof layout === 'string' && FALLBACK_LAYOUTS.includes(layout)
380
+ ? (layout as CertBlockMode)
381
+ : undefined
382
+ return optionsFaceHtml(block, mode)
383
+ }
384
+
385
+ /** The block's options face, stamped with a fallback record when the server wrote one (`ssr-seed.ts`). */
386
+ function OptionsFace(props: { readonly block: CertReviewCardProps; readonly html: string; readonly record?: string }) {
387
+ return (
388
+ <div
389
+ data-certrev-cert-chrome=""
390
+ // The two provenance stamps the crawl monitor reads — emitted ONLY when the delivery
391
+ // carried the right type, so a mistyped option leaves the attribute off instead of
392
+ // stamping "[object Object]" as a version.
393
+ data-certrev-strings-version={optionalText(props.block.stringsVersion)}
394
+ data-certrev-def-version={typeof props.block.renderDef?.v === 'number' ? props.block.renderDef.v : undefined}
395
+ {...(props.record === undefined ? {} : { [SEED_ATTRIBUTE]: props.record })}
396
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: renderCertBlock is the escaping-safe SSR string renderer
397
+ dangerouslySetInnerHTML={{ __html: props.html }}
398
+ />
399
+ )
400
+ }
401
+
402
+ /** Where the server painted no face, an empty hidden element carries the record that says so. */
403
+ function NoFace(props: { readonly record: string }) {
404
+ return <div hidden {...{ [SEED_ATTRIBUTE]: props.record }} />
405
+ }
406
+
407
+ /** What a card with no verified envelope paints: the options face when CertREV was not reached, else nothing. */
408
+ function settleWithoutEnvelope(props: EnvelopeCardProps, unreachable: boolean): React.ReactElement {
409
+ const html = unreachable ? fallbackFace(props) : null
410
+ const face: FallbackFace = html === null ? 'none' : 'options'
411
+ const record = serializeFallback({
412
+ baseUrl: props.baseUrl,
413
+ externalId: props.externalId,
414
+ ...(props.design ? { design: props.design } : {}),
415
+ face,
416
+ })
417
+ return html === null ? <NoFace record={record} /> : <OptionsFace block={props.block} html={html} record={record} />
418
+ }
419
+
420
+ /**
421
+ * Paint what the page's record for this placement says: a seed its envelope (judged at the browser's clock first when
422
+ * `judge`), an `'options'` record the options face, a `'none'` record nothing.
423
+ */
424
+ function paintRecord(
425
+ record: PageRecord,
426
+ props: EnvelopeCardProps,
427
+ context: Parameters<typeof renderVerdict>[1],
428
+ judge: boolean,
429
+ ): React.ReactElement | null {
430
+ if ('seed' in record) {
431
+ if (judge) {
432
+ try {
433
+ if (renderVerdict(record.seed.payload, context).decision !== 'render') return null
434
+ } catch {
435
+ return null
436
+ }
437
+ }
438
+ return paintEnvelope(record.seed.payload, record.seed.renderDef, props, record.raw)
439
+ }
440
+ const html = record.fallback.face === 'options' ? fallbackFace(props) : null
441
+ return html === null ? (
442
+ <NoFace record={record.raw} />
443
+ ) : (
444
+ <OptionsFace block={props.block} html={html} record={record.raw} />
445
+ )
446
+ }
447
+
448
+ /** What a card the client mounts with no record and no `clientRead` paints: the options face, or nothing. */
449
+ function clientMountFace(props: EnvelopeCardProps): React.ReactElement | null {
450
+ const html = fallbackFace(props)
451
+ return html === null ? null : <OptionsFace block={props.block} html={html} />
452
+ }
453
+
454
+ const pendingRecord = () => 'pending' as const
455
+
456
+ /**
457
+ * 1.2.1: a card the client mounted while the page was still loading, with no record for its placement and no
458
+ * `clientRead`: the server may still be streaming the record (`ssr-seed.ts`, "Records that stream in after the card
459
+ * renders"). Nothing until the record arrives, then what it says, a seed judged at the browser's clock as for any card
460
+ * past hydration; once the page has loaded with no record, what a client mount paints. It reads nothing.
461
+ */
462
+ function StreamedRecordCard(props: EnvelopeCardProps) {
241
463
  const rt = cardDeliveryRuntime()
242
- const { design } = props
464
+ // In render, not in an effect: the watch must hold the server's `<html>` before the commit that may remove it.
465
+ watchStreamedRecords()
466
+ const record = React.useSyncExternalStore(
467
+ subscribeStreamedRecords,
468
+ () => streamedRecordFor(props.baseUrl, props.externalId, props.design),
469
+ pendingRecord,
470
+ )
471
+ if (record === 'pending') return null
472
+ if (record === 'done') return clientMountFace(props)
473
+ const context = { platform: 'builder', externalId: props.externalId, now: new Date(rt.now()) }
474
+ return paintRecord(record, props, context, true)
475
+ }
476
+
477
+ const subscribeNever = () => () => {}
478
+
479
+ /**
480
+ * True on a server and while a browser hydrates server HTML; false once the client has committed, and from the first
481
+ * render of a card mounted on the client. React re-renders a hydrated card when this flips, so the card can paint
482
+ * the server's tree first (no mismatch) and judge it at the browser's clock right after.
483
+ *
484
+ * It is true in BOTH of the first two cases and React gives a component no public way to tell them apart, so the card
485
+ * does not ask React: see `EnvelopeCertReviewCard`.
486
+ */
487
+ function useHydrating(): boolean {
488
+ return React.useSyncExternalStore(
489
+ subscribeNever,
490
+ () => false,
491
+ () => true,
492
+ )
493
+ }
494
+
495
+ /**
496
+ * 1.2.0: the card never reads the Delivery API from the browser unless the brand opts in (`clientRead`), so a
497
+ * storefront whose CSP `connect-src` does not name CertREV (e.l.f.'s live policy) never sees a request or a violation
498
+ * from it, and a page never loses its card for want of one.
499
+ *
500
+ * - Server: read (up to `CARD_DELIVERY_TIMEOUT_MS`, a fresh or stale-while-revalidating cache first), verify and paint
501
+ * as through 1.1.1, and write a record of what it painted (`ssr-seed.ts`):
502
+ * - a verified envelope: its face and the envelope seed (less the fields the def hides, and with no signature if
503
+ * it hid any);
504
+ * - CertREV not reached (a timeout, a network error, a 5xx, a body that is not JSON): the block's own options face,
505
+ * as 0.5.x painted it, and an `'options'` record (a pinned card too: the record names its pin);
506
+ * - a definitive no (revoked, expired, unverifiable, for another entry, not found): nothing, and a `'none'` record
507
+ * on an empty hidden element.
508
+ * - Browser, the page carries this placement's record: paint what it says. A seed paints its envelope; while
509
+ * hydrating the server's verdict stands (the tree is the server's, byte for byte), and once hydrated the lifecycle
510
+ * and subject are judged at the browser's clock, so a card that expired since the server render is taken down. An
511
+ * `'options'` record paints the options face, a `'none'` record nothing. This holds when React renders the card
512
+ * again on the client after a hydration error elsewhere on the page: the record is still in the document (or in
513
+ * the page's memory of it), so the browser repaints the server's answer and reads nothing.
514
+ * - Browser, hydrating with no record: server HTML from before 1.2.0 painted nothing here, so nothing.
515
+ * - Browser, a card mounted on the client with no record (a client-side navigation to an article this page never
516
+ * server-rendered, Builder's editor): the block's options face, pinned or not. With `clientRead`, instead, the
517
+ * one read, settled exactly as on the server.
518
+ * - Browser, the same while the document is still loading (1.2.1; React rendered the card on the client before the
519
+ * server's streamed record arrived, `ssr-seed.ts`): nothing until the record arrives, then what it says; the
520
+ * options face, or nothing, if the page loads without one (`StreamedRecordCard`). `clientRead` reads as above.
521
+ *
522
+ * WHICH OF THE TWO FIRST CASES THIS RENDER IS (server or browser) is decided from what the render can observe, with
523
+ * no switch a test could set. A server render and a hydration are both "hydrating" to React and both see a `document`
524
+ * under jsdom, so `typeof document` alone calls a jsdom server render a browser, which is what 1.2.0-canary.5 got
525
+ * wrong (its SSR painted `<!--$--><!--/$-->`). What differs between them is the one thing a hydration has and a server
526
+ * render cannot: server-rendered HTML IN the document, in front of it. So:
527
+ * 1. no `document`: a server (every real server render: Node, Workers, Oxygen, Bun);
528
+ * 2. not hydrating: a browser (a client mount, or a card past its hydration);
529
+ * 3. hydrating with a `document`: a browser only when the live document holds this placement's record, or any React
530
+ * Suspense boundary marker (`pageHoldsServerHtml`; the card's own Suspense leaves one). Otherwise it is a server
531
+ * render running beside a `document` (jsdom, a DOM shim): it reads and records as a server does, and never consults
532
+ * a record or the page's memory, so a long-lived shim process cannot paint one page's record into another's render.
533
+ * The one case this cannot see is a server render that runs while the document still holds server-rendered React HTML
534
+ * (a test or shim that hydrates a page and then renders another into the same document without clearing it): that
535
+ * render is taken for a hydration. Clear the document between pages in such a harness.
536
+ */
537
+ function EnvelopeCertReviewCard(props: EnvelopeCardProps) {
538
+ const rt = cardDeliveryRuntime()
539
+ const hydrating = useHydrating()
540
+ // Latched at mount: this instance hydrated server HTML (as opposed to a client mount).
541
+ const [hydratedMount] = React.useState(hydrating)
243
542
  // The clock is read per render, so the lifecycle is judged NOW, not when the envelope was fetched.
244
543
  const context = { platform: 'builder', externalId: props.externalId, now: new Date(rt.now()) }
544
+ // Hydrating: the live document decides (the memory of an earlier page must not). Past hydration: the memory is the
545
+ // record the hydration used, which the document still carries as well.
546
+ const fromPage =
547
+ typeof document === 'undefined'
548
+ ? undefined
549
+ : pageRecordFor(props.baseUrl, props.externalId, props.design, { live: hydrating })
550
+ const inBrowser = typeof document !== 'undefined' && (!hydrating || fromPage !== undefined || pageHoldsServerHtml())
551
+ // 1.2.1, latched at mount: a client mount with no record and no `clientRead` while the page is still loading waits
552
+ // for a record the server may still be streaming, for the card's whole life (so it never swaps components when the
553
+ // record lands).
554
+ const [awaitsStream] = React.useState(
555
+ () => inBrowser && !hydrating && fromPage === undefined && !props.clientRead && document.readyState === 'loading',
556
+ )
557
+ if (awaitsStream) return <StreamedRecordCard {...props} />
558
+ if (inBrowser) {
559
+ if (fromPage) return paintRecord(fromPage, props, context, !hydrating)
560
+ if (hydratedMount) return null
561
+ if (!props.clientRead) return clientMountFace(props)
562
+ }
563
+ const { design } = props
245
564
  const read = {
246
565
  baseUrl: props.baseUrl,
247
566
  platform: 'builder',
@@ -249,44 +568,26 @@ function EnvelopeCertReviewCard(props: {
249
568
  context,
250
569
  cache: rt.cache,
251
570
  design,
571
+ resolveKid: rt.resolveKid,
572
+ fetchImpl: rt.fetchImpl(),
573
+ timeoutMs: rt.timeoutMs,
252
574
  }
253
575
  const key = design
254
576
  ? `${props.baseUrl}|${props.externalId}|design=${design.version}|${design.brand ?? ''}`
255
577
  : `${props.baseUrl}|${props.externalId}`
256
- const settled =
257
- peekVerifiedDelivery(read) ??
258
- suspendOn(key, () =>
259
- getVerifiedDelivery({ ...read, resolveKid: rt.resolveKid, fetchImpl: rt.fetchImpl(), timeoutMs: rt.timeoutMs }),
260
- )
261
- // Revoked, expired, unverifiable, for another entry, or unreachable: nothing at all.
262
- if (settled.verdict.decision !== 'render') return null
263
- // A pinned read paints only the design it pinned: a reader def at exactly that version. A def the API served for
264
- // anything else (none, another face, another version) paints nothing, never the live design in its place.
265
- if (design && !(isReaderFaceDef(settled.renderDef) && settled.renderDef?.v === design.version)) return null
266
- // The reader face is opt-in by the def's own `face` and is chosen HERE and nowhere else on this path; every other
267
- // def (no face, an unknown face) falls through to the legacy lines below, untouched since 1.1.1.
268
- if (isReaderFaceDef(settled.renderDef)) {
269
- const reader = envelopeCardHtml(settled.verdict.payload.content, settled.renderDef, {
270
- mode: props.mode,
271
- part: props.part,
272
- certId: settled.verdict.payload.certId,
273
- })
274
- if (!reader) return null
275
- return (
276
- <EnvelopeFace
277
- reader
278
- // The stylesheet rides inside the face, once per card; it is the portal's reader CSS byte for byte.
279
- html={`<style data-certrev-reader-css>${READER_CSS}</style>${reader}`}
280
- defVersion={settled.renderDef ? settled.renderDef.v : undefined}
281
- />
282
- )
283
- }
284
- const input = envelopeCardInput(settled.verdict.payload.content, settled.renderDef, {
285
- mode: props.mode,
286
- part: props.part,
578
+ const settled = readVerifiedDelivery(read) ?? suspendOn(key, () => getVerifiedDelivery(read))
579
+ if (settled.verdict.decision !== 'render') return settleWithoutEnvelope(props, settled.unreachable === true)
580
+ // The face is painted from the seed's payload (what the def hides is already out of it), so the server's HTML and
581
+ // the browser's, which paints from the seed, are the same bytes and neither holds a hidden field.
582
+ const seed = seedFor({
583
+ baseUrl: props.baseUrl,
584
+ externalId: props.externalId,
585
+ design,
586
+ payload: settled.verdict.payload,
587
+ signature: settled.signature,
588
+ renderDef: settled.renderDef,
287
589
  })
288
- if (!input) return null
289
- return <EnvelopeFace html={renderCertBlock(input)} defVersion={settled.renderDef ? settled.renderDef.v : undefined} />
590
+ return paintEnvelope(seed.payload, seed.renderDef, props, serializeSeed(seed))
290
591
  }
291
592
 
292
593
  export function CertReviewCard(props: CertReviewCardProps) {
@@ -295,6 +596,8 @@ export function CertReviewCard(props: CertReviewCardProps) {
295
596
  // takes the options path below, unchanged from 1.0.3.
296
597
  // 1.2.0-canary.5: a design pin (CertREV's canary replica only) fails closed. One that cannot be read renders
297
598
  // nothing, and so does one on a block without the delivery marker: the options face is never the pinned design.
599
+ // 1.2.0: where the envelope cannot be had (CertREV not reached, or no server render and no `clientRead`), a block
600
+ // with the marker paints this options face too, pinned or not: see `EnvelopeCertReviewCard`.
298
601
  const design = parseDesignPin(props.builderContext?.context?.certrevDesignPin)
299
602
  if (design === 'invalid') return null
300
603
  if (props.delivery != null) {
@@ -309,15 +612,28 @@ export function CertReviewCard(props: CertReviewCardProps) {
309
612
  mode={props.mode}
310
613
  part={props.part}
311
614
  design={design}
615
+ block={props}
616
+ clientRead={props.clientRead === true}
312
617
  />
313
618
  </Suspense>
314
619
  )
315
620
  }
316
621
  if (design) return null
317
622
 
623
+ const html = optionsFaceHtml(props, 'banner')
624
+ if (html === null) return null // no chrome pre-cert (or an unreadable delivery)
625
+ return <OptionsFace block={props} html={html} />
626
+ }
627
+
628
+ /**
629
+ * The options face's HTML (1.0.3's, unchanged), or null when the block lacks a reviewer or a certificate link. `bridge`
630
+ * is the placement a block with no `mode` gets: `'banner'` for a block without the delivery marker (the bridge below);
631
+ * the def's layout for a marked block's fallback (`fallbackFace`).
632
+ */
633
+ function optionsFaceHtml(props: CertReviewCardProps, bridge: CertBlockMode | undefined): string | null {
318
634
  const reviewerName = text(props.reviewerName)
319
635
  const verifyUrl = text(props.verifyUrl)
320
- if (!reviewerName || !verifyUrl) return null // no chrome pre-cert (or an unreadable delivery)
636
+ if (!reviewerName || !verifyUrl) return null
321
637
 
322
638
  // Name-only face when the pair is unreadable, or when the brand set showCredentials:false.
323
639
  const showCred = props.display?.showCredentials !== false
@@ -336,7 +652,7 @@ export function CertReviewCard(props: CertReviewCardProps) {
336
652
  }
337
653
  : undefined
338
654
 
339
- const html = renderCertBlock({
655
+ return renderCertBlock({
340
656
  // COMPATIBILITY BRIDGE, NOT THE DESIGN. Delete this fallback once the exporter emits an
341
657
  // explicit `mode` and existing entries have been backfilled; until then it must stay.
342
658
  //
@@ -358,7 +674,7 @@ export function CertReviewCard(props: CertReviewCardProps) {
358
674
  // still fixed: the helper text and the wire type's doc comment no longer name a default,
359
675
  // and this line names one only for input the renderer can no longer distinguish from a
360
676
  // deliberate choice. The exporter writing `mode` is what retires it.
361
- mode: props.mode ?? 'banner',
677
+ mode: props.mode ?? bridge ?? 'banner',
362
678
  theme,
363
679
  // First-class banner memo split — renderCertBlock composes the requested
364
680
  // part directly; no string-splitting. Ignored outside banner mode, and an unrecognised
@@ -385,19 +701,6 @@ export function CertReviewCard(props: CertReviewCardProps) {
385
701
  compensationCue: props.compensationCue === null ? null : undefined,
386
702
  },
387
703
  })
388
-
389
- return (
390
- <div
391
- data-certrev-cert-chrome=""
392
- // The two provenance stamps the crawl monitor reads — emitted ONLY when the delivery
393
- // carried the right type, so a mistyped option leaves the attribute off instead of
394
- // stamping "[object Object]" as a version.
395
- data-certrev-strings-version={optionalText(props.stringsVersion)}
396
- data-certrev-def-version={typeof props.renderDef?.v === 'number' ? props.renderDef.v : undefined}
397
- // biome-ignore lint/security/noDangerouslySetInnerHtml: renderCertBlock is the escaping-safe SSR string renderer
398
- dangerouslySetInnerHTML={{ __html: html }}
399
- />
400
- )
401
704
  }
402
705
 
403
706
  // ─────────────────────────────────────────────────────────────────────────────
@@ -470,7 +773,7 @@ const WIRE_INPUT_HELPER_TEXT: Partial<Record<(typeof BUILDER_CERT_CHROME_KEYS)[n
470
773
  scopeLine:
471
774
  'System-populated by CertREV. The FTC scope line renders byte-verbatim from a locked constant: editing this text changes nothing on the page.',
472
775
  delivery:
473
- 'System-populated by CertREV. When set, the card shows the certificate CertREV serves for this entry, verified, and ignores the other fields; if CertREV revokes it or it expires, the card shows nothing. Do not edit.',
776
+ 'System-populated by CertREV. When set, the card shows the certificate CertREV serves for this entry, verified, in place of the other fields; if CertREV revokes it or it expires, the card shows nothing. If CertREV cannot be reached, or the page was not rendered on the server, the card shows the other fields. Do not edit.',
474
777
  }
475
778
 
476
779
  /**
@@ -6,12 +6,25 @@
6
6
  * swap the trust root through the published API: the card verifies against the baked
7
7
  * `cert-issuer-1` key, the same key the storefront badge bundle ships. The package's own tests
8
8
  * reach the setter by relative import to run the real kernel against fixture-signed envelopes.
9
+ *
10
+ * 1.2.0: the card's server read waits up to `CARD_DELIVERY_TIMEOUT_MS` (8s, against the
11
+ * verifier's 3s default), and its cache serves a certificate up to `CARD_DELIVERY_STALE_MS` past
12
+ * its TTL while one background refresh runs, or while CertREV cannot be reached. A cold or slow
13
+ * read on a brand's server then misses at most once per placement, and a warm one never waits.
14
+ * A revocation still lands on the first render after its refresh does. The tests' runtime gets
15
+ * the same cache shape, so they exercise what production runs.
9
16
  */
10
17
 
11
18
  import type { ResolvePublicKeyByKid } from '../contract/kernel.js'
12
19
  import { TtlCache } from '../verify/cache.js'
13
20
  import type { CachedDelivery } from '../verify/get-verified-delivery.js'
14
21
  import { CERT_ISSUER_KID, CERT_ISSUER_PUBLIC_KEY_PEM } from './issuer-key.js'
22
+ import { forgetPageSeedsForTests } from './ssr-seed.js'
23
+
24
+ /** The card's server read deadline. A brand's server waits this long once per cold placement. */
25
+ export const CARD_DELIVERY_TIMEOUT_MS = 8_000
26
+ /** How long past its 60s TTL a certificate may still paint while it is refreshed or CertREV is down. */
27
+ export const CARD_DELIVERY_STALE_MS = 300_000
15
28
 
16
29
  export interface CardDeliveryRuntime {
17
30
  readonly resolveKid: ResolvePublicKeyByKid
@@ -24,12 +37,25 @@ export interface CardDeliveryRuntime {
24
37
  const bakedResolveKid: ResolvePublicKeyByKid = (kid) =>
25
38
  kid === CERT_ISSUER_KID ? { format: 'pem', pem: CERT_ISSUER_PUBLIC_KEY_PEM } : null
26
39
 
40
+ function newCardCache(now?: () => number): TtlCache<CachedDelivery> {
41
+ return new TtlCache<CachedDelivery>({
42
+ ttlMs: 60_000,
43
+ negativeTtlMs: 5_000,
44
+ staleMs: CARD_DELIVERY_STALE_MS,
45
+ // A refresh the host runtime abandoned (workerd cancels a finished request's I/O) never
46
+ // settles; after the deadline plus a margin, the next render starts a new one.
47
+ inflightMaxMs: CARD_DELIVERY_TIMEOUT_MS + 2_000,
48
+ ...(now ? { now } : {}),
49
+ })
50
+ }
51
+
27
52
  function defaultRuntime(): CardDeliveryRuntime {
28
53
  return {
29
54
  resolveKid: bakedResolveKid,
30
55
  fetchImpl: () => globalThis.fetch,
31
- cache: new TtlCache<CachedDelivery>({ ttlMs: 60_000, negativeTtlMs: 5_000 }),
56
+ cache: newCardCache(),
32
57
  now: Date.now,
58
+ timeoutMs: CARD_DELIVERY_TIMEOUT_MS,
33
59
  }
34
60
  }
35
61
 
@@ -39,13 +65,17 @@ export function cardDeliveryRuntime(): CardDeliveryRuntime {
39
65
  return runtime
40
66
  }
41
67
 
42
- /** Test seam: replace parts of the runtime (a fresh cache unless one is given). Returns a restore. */
68
+ /**
69
+ * Test seam: replace parts of the runtime (a fresh cache unless one is given) and forget the page's envelope seeds
70
+ * (a fresh runtime is a fresh page). Returns a restore.
71
+ */
43
72
  export function setCardDeliveryRuntimeForTests(overrides: Partial<CardDeliveryRuntime>): () => void {
73
+ forgetPageSeedsForTests()
44
74
  const previous = runtime
45
75
  const now = overrides.now ?? previous.now
46
76
  runtime = {
47
77
  ...previous,
48
- cache: new TtlCache<CachedDelivery>({ ttlMs: 60_000, negativeTtlMs: 5_000, now }),
78
+ cache: newCardCache(now),
49
79
  ...overrides,
50
80
  }
51
81
  return () => {