@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
@@ -32,9 +32,27 @@ import {
32
32
  readVerifiedDelivery,
33
33
  type VerifiedDelivery,
34
34
  } from '../verify/get-verified-delivery.js'
35
+ import {
36
+ acceptedCardBinding,
37
+ type CardBinding,
38
+ type CardDocumentV1,
39
+ type CardPartName,
40
+ cardDigestFor,
41
+ cardDigestMatches,
42
+ cardMatchesPlacement,
43
+ cardPartsFor,
44
+ parseCardDocument,
45
+ } from './card-contract.js'
35
46
  import { CARD_READER_CSS } from './card-reader-css.js'
47
+ import {
48
+ type CardFallbackDetail,
49
+ type CardFallbackReason,
50
+ reportCardFallbackInBrowser,
51
+ reportCardFallbackOnServer,
52
+ } from './card-report.js'
36
53
  import type { BuilderCertChromeData, CertChromeRenderDef } from './cert-chrome-data.js'
37
54
  import { BUILDER_CERT_CHROME_KEYS } from './cert-chrome-data.js'
55
+ import { ContractCardFace } from './contract-card.js'
38
56
  import {
39
57
  type DeliveredFaceHashes,
40
58
  deliveredFaceDigest,
@@ -43,7 +61,7 @@ import {
43
61
  faceHashesFor,
44
62
  showsWholeCard,
45
63
  } from './delivered-face.js'
46
- import { cardDeliveryRuntime } from './delivery-runtime.js'
64
+ import { cardDeliveryRuntime, cardResolveKidFor } from './delivery-runtime.js'
47
65
  import { envelopeCardHtml, envelopeCardInput, isReaderFaceDef } from './envelope-face.js'
48
66
  import {
49
67
  type FallbackFace,
@@ -100,11 +118,13 @@ export interface CertReviewCardProps extends Partial<BuilderCertChromeData> {
100
118
  readonly context?: { readonly certrevDesignPin?: unknown } | null
101
119
  } | null
102
120
  /**
103
- * 1.2.0: opt in to a Delivery read FROM THE BROWSER for a card the server did not render (a client-side navigation,
104
- * Builder's editor). Off by default: the card then never makes a request from the browser, so a brand's
105
- * Content-Security-Policy needs no CertREV origin, and such a card paints the block's own options face. Turning it
106
- * on needs the Delivery API's origin in the page's `connect-src`. Not a Builder input: a brand sets it in code, on
107
- * a wrapper of this component.
121
+ * A Delivery read FROM THE BROWSER for a card the server did not render (a client-side navigation, Builder's editor).
122
+ * 2.0.0: ON by default (absent or `true`): it is the only way CertREV's server-rendered card exists after an in-app
123
+ * navigation, and it needs the Delivery API's origin (`https://portal.certrev.com`) in the page's `connect-src`, one
124
+ * of the four CSP sources 2.0 asks for. A read the policy blocks is a read that did not reach CertREV: the block's
125
+ * own options face, as 1.3.1 painted it (and a reported `delivery_unreachable`). `false` restores 1.2.0 to 1.3.1's
126
+ * behaviour: no request from the browser, and such a card paints the block's options face. Not a Builder input: a
127
+ * brand sets it in code, on a wrapper of this component.
108
128
  */
109
129
  clientRead?: boolean
110
130
  }
@@ -308,9 +328,13 @@ function EnvelopeFace(props: {
308
328
 
309
329
  type PhotoPerson = 'expert' | 'author'
310
330
 
311
- /** The reader face paints the author's avatar small (`.certrev-r-face.sm`); every other avatar is the expert's. */
331
+ /**
332
+ * Whose avatar a broken photo is. The banner's two bylines take the same face (1.3.1; the author's was the small
333
+ * `.certrev-r-face.sm` before), so the person is read from the byline: the author's carries the plain "Written by"
334
+ * role, the reviewer's "Reviewed by" (`.on`), and the sidebar's and the memo's reviewer has no role at all.
335
+ */
312
336
  function photoPerson(img: HTMLImageElement): PhotoPerson {
313
- return img.parentElement?.classList.contains('sm') ? 'author' : 'expert'
337
+ return img.closest('.certrev-r-by')?.querySelector('.certrev-r-role:not(.on)') ? 'author' : 'expert'
314
338
  }
315
339
 
316
340
  interface EnvelopeCardProps {
@@ -379,11 +403,14 @@ function deliveredFaceFor(
379
403
  renderDef: CertChromeRenderDef | null,
380
404
  face: DeliveredFace | undefined,
381
405
  props: EnvelopeCardProps,
406
+ /** 2.0.0: the hashes the read computed (`VerifiedDelivery.faceHashes`): then nothing suspends here. */
407
+ hashed?: DeliveredFaceHashes | null,
382
408
  ): DeliveredFace | undefined {
383
409
  if (!face || props.design || !showsWholeCard(props.part)) return undefined
384
410
  const digest = deliveredFaceDigest(payload.content, renderDef, face)
385
411
  if (!digest) return undefined
386
- return deliveredFaceMatches(suspendOnFaceHashes(face), digest) ? face : undefined
412
+ const hashes = hashed !== undefined ? hashed : suspendOnFaceHashes(face)
413
+ return deliveredFaceMatches(hashes, digest) ? face : undefined
387
414
  }
388
415
 
389
416
  /**
@@ -475,9 +502,110 @@ function NoFace(props: { readonly record: string }) {
475
502
  return <div hidden {...{ [SEED_ATTRIBUTE]: props.record }} />
476
503
  }
477
504
 
478
- /** What a card with no verified envelope paints: the options face when CertREV was not reached, else nothing. */
479
- function settleWithoutEnvelope(props: EnvelopeCardProps, unreachable: boolean): React.ReactElement {
480
- const html = unreachable ? fallbackFace(props) : null
505
+ /**
506
+ * 2.0.0: reports one fallback (`card-report.ts`) and renders nothing (no element, no marker: it changes no byte of the
507
+ * page). On a server it reports as it renders; in a browser, once it has mounted.
508
+ */
509
+ function FallbackReport(props: { readonly detail: CardFallbackDetail; readonly server: boolean }) {
510
+ const { detail, server } = props
511
+ if (server) reportCardFallbackOnServer(detail)
512
+ React.useEffect(() => {
513
+ reportCardFallbackInBrowser(detail)
514
+ }, [detail])
515
+ return null
516
+ }
517
+
518
+ /** A face with its fallback report beside it. */
519
+ function withReport(
520
+ face: React.ReactElement | null,
521
+ reason: CardFallbackReason,
522
+ props: EnvelopeCardProps,
523
+ server: boolean,
524
+ certId?: string,
525
+ ): React.ReactElement {
526
+ const detail: CardFallbackDetail = {
527
+ reason,
528
+ platform: 'builder',
529
+ externalId: props.externalId,
530
+ ...(certId === undefined ? {} : { certId }),
531
+ }
532
+ return (
533
+ <>
534
+ {face}
535
+ <FallbackReport detail={detail} server={server} />
536
+ </>
537
+ )
538
+ }
539
+
540
+ /**
541
+ * 2.0.0: what a contract-1 (or unsupported-contract) placement prints, decided by the SIGNED binding (`card-contract.ts`):
542
+ * the card when the served document hashes to the signed digest and names this design version, certificate and
543
+ * placement; else nothing, and the reason. Suspends once while WebCrypto hashes a document it has not seen.
544
+ */
545
+ type ContractOutcome =
546
+ | { readonly kind: 'card'; readonly card: CardDocumentV1; readonly names: CardPartName[] }
547
+ | { readonly kind: 'none'; readonly reason?: CardFallbackReason }
548
+
549
+ function contractOutcome(
550
+ binding: CardBinding | 'unsupported',
551
+ rawCard: unknown,
552
+ payload: CertPayload,
553
+ props: EnvelopeCardProps,
554
+ /** The digest the read computed (`VerifiedDelivery.cardDigest`): then nothing suspends here. */
555
+ digested?: string | null,
556
+ ): ContractOutcome {
557
+ if (binding === 'unsupported') return { kind: 'none', reason: 'card_unsupported_contract' }
558
+ // CertREV's replica pin: the signed card was painted for one design version; another pin gets nothing.
559
+ if (props.design && props.design.version !== binding.defVersion) return { kind: 'none', reason: 'card_pin_mismatch' }
560
+ if (rawCard === undefined || rawCard === null) return { kind: 'none', reason: 'card_missing' }
561
+ const card = parseCardDocument(rawCard)
562
+ if (!card || !cardMatchesPlacement(card, binding, payload, props.externalId)) {
563
+ return { kind: 'none', reason: 'card_verify_failed' }
564
+ }
565
+ let digest: string | null
566
+ if (digested !== undefined) {
567
+ digest = digested
568
+ } else {
569
+ // A page's seed: the one suspension of this render (a render that read waits on nothing but its read).
570
+ const memo = cardDigestFor(card)
571
+ digest = memo.settled ? memo.settled.digest : suspendUntil(memo.pending)
572
+ }
573
+ if (!cardDigestMatches(digest, binding)) return { kind: 'none', reason: 'card_verify_failed' }
574
+ // A block with nothing of this design to print (a memo block of a memo-less card, or of a sidebar design) prints
575
+ // nothing, by design: not a fallback.
576
+ const names = cardPartsFor(card, props.part)
577
+ return names.length === 0 ? { kind: 'none' } : { kind: 'card', card, names }
578
+ }
579
+
580
+ /** The `'none'` record of a contract-1 placement, with its reason when it is a fallback. */
581
+ function contractNone(
582
+ props: EnvelopeCardProps,
583
+ reason: CardFallbackReason | undefined,
584
+ server: boolean,
585
+ certId: string,
586
+ ): React.ReactElement {
587
+ const record = serializeFallback({
588
+ baseUrl: props.baseUrl,
589
+ externalId: props.externalId,
590
+ ...(props.design ? { design: props.design } : {}),
591
+ face: 'none',
592
+ ...(reason ? { reason } : {}),
593
+ })
594
+ const face = <NoFace record={record} />
595
+ return reason ? withReport(face, reason, props, server, certId) : face
596
+ }
597
+
598
+ /**
599
+ * What a card with no verified envelope paints: the options face when CertREV was not reached, else nothing. 2.0.0:
600
+ * the same faces and records as 1.3.1, and two of them are now reported: the options face painted because CertREV
601
+ * was not reached (`delivery_unreachable`), and nothing painted because the envelope's kid is unknown (`unknown_key`).
602
+ */
603
+ function settleWithoutEnvelope(
604
+ props: EnvelopeCardProps,
605
+ verdict: { readonly unreachable: boolean; readonly reason?: string },
606
+ server: boolean,
607
+ ): React.ReactElement {
608
+ const html = verdict.unreachable ? fallbackFace(props) : null
481
609
  const face: FallbackFace = html === null ? 'none' : 'options'
482
610
  const record = serializeFallback({
483
611
  baseUrl: props.baseUrl,
@@ -485,7 +613,18 @@ function settleWithoutEnvelope(props: EnvelopeCardProps, unreachable: boolean):
485
613
  ...(props.design ? { design: props.design } : {}),
486
614
  face,
487
615
  })
488
- return html === null ? <NoFace record={record} /> : <OptionsFace block={props.block} html={html} record={record} />
616
+ if (html !== null) {
617
+ return withReport(
618
+ <OptionsFace block={props.block} html={html} record={record} />,
619
+ 'delivery_unreachable',
620
+ props,
621
+ server,
622
+ )
623
+ }
624
+ const none = <NoFace record={record} />
625
+ // A block with no options to paint when CertREV was not reached is reported too (break-it B5).
626
+ if (verdict.unreachable) return withReport(none, 'delivery_unreachable', props, server)
627
+ return verdict.reason === 'unknown_key' ? withReport(none, 'unknown_key', props, server) : none
489
628
  }
490
629
 
491
630
  /**
@@ -506,9 +645,24 @@ function paintRecord(
506
645
  return null
507
646
  }
508
647
  }
648
+ const { seed } = record
649
+ // 2.0.0: a seed whose signed binding names a contract prints the card it carries, only after the browser has
650
+ // checked it against that binding itself; otherwise nothing. Never a 1.3.1 face for such a placement.
651
+ const binding = acceptedCardBinding(seed.payload.content)
652
+ if (binding !== null) {
653
+ const outcome = contractOutcome(binding, seed.card, seed.payload, props)
654
+ if (outcome.kind === 'card')
655
+ return <ContractCardFace card={outcome.card} names={outcome.names} seed={record.raw} />
656
+ // Hydrating over the card the server printed, which the browser could not confirm (no WebCrypto): adopt the
657
+ // server's element; the render right after hydration prints nothing in its place.
658
+ if (!judge && outcome.reason === 'card_verify_failed' && seed.card !== undefined)
659
+ return <ServerFaceUntilHydrated />
660
+ // Nothing of this design for this block (a memo block of a sidebar card): nothing, and no record of its own.
661
+ if (outcome.reason === undefined) return null
662
+ return contractNone(props, outcome.reason, false, seed.payload.certId)
663
+ }
509
664
  // 1.3.0: a seed that names a delivered face repaints it only after the browser hashes it against the seed
510
665
  // payload's own digest; otherwise the seed paints what 1.2.1 paints for it.
511
- const { seed } = record
512
666
  const delivered = deliveredFaceFor(seed.payload, seed.renderDef, seed.face, props)
513
667
  if (delivered && seed.renderDef) {
514
668
  return <DeliveredEnvelopeFace face={delivered} defVersion={seed.renderDef.v} seed={record.raw} />
@@ -518,11 +672,31 @@ function paintRecord(
518
672
  return paintEnvelope(seed.payload, seed.renderDef, props, record.raw)
519
673
  }
520
674
  const html = record.fallback.face === 'options' ? fallbackFace(props) : null
521
- return html === null ? (
522
- <NoFace record={record.raw} />
523
- ) : (
524
- <OptionsFace block={props.block} html={html} record={record.raw} />
525
- )
675
+ if (html !== null) {
676
+ return withReport(
677
+ <OptionsFace block={props.block} html={html} record={record.raw} />,
678
+ 'delivery_unreachable',
679
+ props,
680
+ false,
681
+ )
682
+ }
683
+ const none = <NoFace record={record.raw} />
684
+ // 2.0.0: a contract-1 placement's record names why it printed nothing; the browser reports it too.
685
+ const reason = record.fallback.reason
686
+ return isFallbackReason(reason) ? withReport(none, reason, props, false) : none
687
+ }
688
+
689
+ const FALLBACK_REASONS: readonly string[] = [
690
+ 'card_missing',
691
+ 'card_verify_failed',
692
+ 'card_unsupported_contract',
693
+ 'card_pin_mismatch',
694
+ 'unknown_key',
695
+ 'delivery_unreachable',
696
+ ]
697
+
698
+ function isFallbackReason(value: unknown): value is CardFallbackReason {
699
+ return typeof value === 'string' && FALLBACK_REASONS.includes(value)
526
700
  }
527
701
 
528
702
  /** What a card the client mounts with no record and no `clientRead` paints: the options face, or nothing. */
@@ -549,8 +723,9 @@ function StreamedRecordCard(props: EnvelopeCardProps) {
549
723
  pendingRecord,
550
724
  )
551
725
  if (record === 'pending') return null
552
- if (record === 'done') return clientMountFace(props)
553
726
  const context = { platform: 'builder', externalId: props.externalId, now: new Date(rt.now()) }
727
+ // 2.0.0: the page loaded with no record for this placement: with `clientRead` (the default), the one read.
728
+ if (record === 'done') return props.clientRead ? readAndPaint(props, context, true) : clientMountFace(props)
554
729
  return paintRecord(record, props, context, true)
555
730
  }
556
731
 
@@ -631,8 +806,9 @@ function EnvelopeCertReviewCard(props: EnvelopeCardProps) {
631
806
  // 1.2.1, latched at mount: a client mount with no record and no `clientRead` while the page is still loading waits
632
807
  // for a record the server may still be streaming, for the card's whole life (so it never swaps components when the
633
808
  // record lands).
809
+ // 2.0.0: whatever `clientRead` says: a card that read instead of waiting would miss the record the server streams.
634
810
  const [awaitsStream] = React.useState(
635
- () => inBrowser && !hydrating && fromPage === undefined && !props.clientRead && document.readyState === 'loading',
811
+ () => inBrowser && !hydrating && fromPage === undefined && document.readyState === 'loading',
636
812
  )
637
813
  if (awaitsStream) return <StreamedRecordCard {...props} />
638
814
  if (inBrowser) {
@@ -640,6 +816,19 @@ function EnvelopeCertReviewCard(props: EnvelopeCardProps) {
640
816
  if (hydratedMount) return null
641
817
  if (!props.clientRead) return clientMountFace(props)
642
818
  }
819
+ return readAndPaint(props, context, inBrowser)
820
+ }
821
+
822
+ /**
823
+ * The Delivery read and its paint: a server render's, or a browser's (`clientRead`). Suspends on the read, and (1.3.0)
824
+ * on a delivered face's hashes or (2.0.0) on a card document's digest.
825
+ */
826
+ function readAndPaint(
827
+ props: EnvelopeCardProps,
828
+ context: Parameters<typeof renderVerdict>[1],
829
+ inBrowser: boolean,
830
+ ): React.ReactElement | null {
831
+ const rt = cardDeliveryRuntime()
643
832
  const { design } = props
644
833
  const read = {
645
834
  baseUrl: props.baseUrl,
@@ -648,7 +837,7 @@ function EnvelopeCertReviewCard(props: EnvelopeCardProps) {
648
837
  context,
649
838
  cache: rt.cache,
650
839
  design,
651
- resolveKid: rt.resolveKid,
840
+ resolveKid: cardResolveKidFor(rt, props.baseUrl),
652
841
  fetchImpl: rt.fetchImpl(),
653
842
  timeoutMs: rt.timeoutMs,
654
843
  }
@@ -656,7 +845,13 @@ function EnvelopeCertReviewCard(props: EnvelopeCardProps) {
656
845
  ? `${props.baseUrl}|${props.externalId}|design=${design.version}|${design.brand ?? ''}`
657
846
  : `${props.baseUrl}|${props.externalId}`
658
847
  const settled = readVerifiedDelivery(read) ?? suspendOn(key, () => getVerifiedDelivery(read))
659
- if (settled.verdict.decision !== 'render') return settleWithoutEnvelope(props, settled.unreachable === true)
848
+ if (settled.verdict.decision !== 'render') {
849
+ return settleWithoutEnvelope(
850
+ props,
851
+ { unreachable: settled.unreachable === true, reason: settled.verdict.reason },
852
+ !inBrowser,
853
+ )
854
+ }
660
855
  // The face is painted from the seed's payload (what the def hides is already out of it), so the server's HTML and
661
856
  // the browser's, which paints from the seed, are the same bytes and neither holds a hidden field.
662
857
  const seed = seedFor({
@@ -667,9 +862,28 @@ function EnvelopeCertReviewCard(props: EnvelopeCardProps) {
667
862
  signature: settled.signature,
668
863
  renderDef: settled.renderDef,
669
864
  })
865
+ // 2.0.0: a placement whose signed binding names a contract prints CertREV's card, or nothing; never a face of its own.
866
+ const binding = acceptedCardBinding(seed.payload.content)
867
+ if (binding !== null) {
868
+ const outcome = contractOutcome(binding, settled.card, seed.payload, props, settled.cardDigest)
869
+ if (outcome.kind === 'card') {
870
+ return (
871
+ <ContractCardFace
872
+ card={outcome.card}
873
+ names={outcome.names}
874
+ seed={serializeSeed({ ...seed, card: outcome.card as unknown as Readonly<Record<string, unknown>> })}
875
+ />
876
+ )
877
+ }
878
+ // Nothing of this design for this block, by design: nothing at all, and NO record. A record here would sit under the
879
+ // same placement as the block that printed the card (a split header and memo pair) and, read last on hydration,
880
+ // would take that block's card down (break-it B2).
881
+ if (outcome.reason === undefined) return null
882
+ return contractNone(props, outcome.reason, !inBrowser, seed.payload.certId)
883
+ }
670
884
  // 1.3.0: the server-rendered face the Delivery API served beside the envelope, when its bytes hash to the digest the
671
885
  // verified payload signs; the seed then carries it, so the browser can repaint (and re-check) the same face.
672
- const delivered = deliveredFaceFor(seed.payload, seed.renderDef, settled.face, props)
886
+ const delivered = deliveredFaceFor(seed.payload, seed.renderDef, settled.face, props, settled.faceHashes)
673
887
  if (delivered && seed.renderDef) {
674
888
  return (
675
889
  <DeliveredEnvelopeFace
@@ -705,7 +919,7 @@ export function CertReviewCard(props: CertReviewCardProps) {
705
919
  part={props.part}
706
920
  design={design}
707
921
  block={props}
708
- clientRead={props.clientRead === true}
922
+ clientRead={props.clientRead !== false}
709
923
  />
710
924
  </Suspense>
711
925
  )
@@ -0,0 +1,159 @@
1
+ /**
2
+ * The contract-1 card face (2.0.0): one Builder block printing CertREV's server-rendered card verbatim.
3
+ *
4
+ * The block's wrapper holds, as its `dangerouslySetInnerHTML`, exactly `cardHtml(card, names)`: the block's part hosts
5
+ * in page order and, in the block holding the document's first part, the head fragment, both CertREV dialog hosts and
6
+ * the runtime's tag (`card-contract.ts`).
7
+ * Each host is a `<certrev-card>` with a declarative shadow root (`<template shadowrootmode="open">`), which the
8
+ * browser's HTML parser attaches as it reads the server's page: the card is complete with JavaScript off, and no page
9
+ * rule reaches inside it. React never reconciles the hosts (they are not React children), so the hosted runtime may
10
+ * remove a revoked placement's hosts without React noticing.
11
+ *
12
+ * What the parser does not do, the card does once it has mounted (`useCardRoots`):
13
+ * - ATTACH ROOTS the parser never saw. A card React inserts (an in-app navigation, Builder's editor, React's own
14
+ * re-render after a hydration error) gets its HTML through `innerHTML`, which leaves a declarative template inert.
15
+ * The wrapper is re-parsed with `setHTMLUnsafe`, which attaches declarative roots; a browser without it (or without
16
+ * declarative shadow DOM at all) gets each root attached from its template (`attachShadow` with the template's mode
17
+ * and options, then the template's content). A host that already has its root is left alone, so a hydrated card,
18
+ * whose roots the parser attached, is not touched.
19
+ * - LOAD THE RUNTIME a React-inserted `<script>` never runs: once per page, into `document.head`, with its SRI
20
+ * integrity and `crossorigin="anonymous"`, unless the runtime is already running (its global guard) or already
21
+ * injected. Only the block that prints the runtime's tag loads it (`cardCarriesAssets`). The server's HTML carries
22
+ * the same tag in that block's wrapper, which the parser runs; the runtime's init is idempotent, so it starts once.
23
+ * Both run in a layout effect, before the browser paints the inserted card, so a client-mounted card never shows an
24
+ * empty frame.
25
+ *
26
+ * ONE BUILDER RULE, for the sidebar card only. The card's rail is a right float that the article's copy flows around
27
+ * (the Appearance preview's layout, and every other CMS's). On a Builder page the block sits in Builder's own wrapper,
28
+ * a flex item of the flex column that holds the entry's blocks, and a flex item contains its floats: the block spans
29
+ * the column at the rail's height and no copy sits beside it. `BUILDER_CARD_FLOW_CSS` lets the float out of Builder's
30
+ * wrappers, as 1.2.0-canary.6 did for the reader face: in a column that holds a printed rail, above the card's own
31
+ * 820px breakpoint (below it the rail stacks as a block), the column lays its blocks out as blocks and still contains
32
+ * the float, and the rail's block and the card wrapper become plain blocks. It reaches nothing inside the card (the
33
+ * shadow root is out of its reach) and nothing on a page without a rail, or in a browser without `:has()`.
34
+ */
35
+
36
+ import * as React from 'react'
37
+ import { type CardDocumentV1, type CardPartName, cardCarriesAssets, cardHtml } from './card-contract.js'
38
+ import { SEED_ATTRIBUTE } from './ssr-seed.js'
39
+
40
+ /** The global the hosted runtime sets once it has started (`Symbol.for('certrev.card-runtime')` on `document`). */
41
+ const RUNTIME_GUARD = Symbol.for('certrev.card-runtime')
42
+ /** Marks the runtime tag this package injected into `document.head`. */
43
+ const INJECTED_RUNTIME_ATTRIBUTE = 'data-certrev-card-runtime'
44
+
45
+ /** A layout effect in a browser; nothing on a server (where React would only warn about it). */
46
+ const useBrowserLayoutEffect = typeof document === 'undefined' ? () => {} : React.useLayoutEffect
47
+
48
+ /** Whether this host still holds an unattached declarative root. */
49
+ function pendingTemplate(host: Element): HTMLTemplateElement | null {
50
+ if ((host as HTMLElement).shadowRoot) return null
51
+ for (const child of Array.from(host.children)) {
52
+ if (child instanceof HTMLTemplateElement && child.hasAttribute('shadowrootmode')) return child
53
+ }
54
+ return null
55
+ }
56
+
57
+ /** Attach one host's root from its template, as the parser would have: its mode, its options, its content. */
58
+ function attachFromTemplate(host: HTMLElement, template: HTMLTemplateElement): void {
59
+ const mode = template.getAttribute('shadowrootmode') === 'closed' ? 'closed' : 'open'
60
+ const init: ShadowRootInit & { clonable?: boolean; serializable?: boolean } = {
61
+ mode,
62
+ delegatesFocus: template.hasAttribute('shadowrootdelegatesfocus'),
63
+ ...(template.hasAttribute('shadowrootclonable') ? { clonable: true } : {}),
64
+ ...(template.hasAttribute('shadowrootserializable') ? { serializable: true } : {}),
65
+ }
66
+ const root = host.attachShadow(init)
67
+ root.appendChild(template.content)
68
+ template.remove()
69
+ }
70
+
71
+ /**
72
+ * Give every host in `wrapper` its declarative root: a no-op where the parser attached them all; else the wrapper is
73
+ * re-parsed from `html` with `setHTMLUnsafe` where the browser has it, or each pending root is attached from its
74
+ * template. Returns how many hosts were pending (for tests and the replica's evidence).
75
+ */
76
+ export function attachCardRoots(wrapper: Element, html: string): number {
77
+ const pending = Array.from(wrapper.querySelectorAll('certrev-card')).filter((host) => pendingTemplate(host) !== null)
78
+ if (pending.length === 0) return 0
79
+ const parse = (wrapper as Element & { setHTMLUnsafe?: (html: string) => void }).setHTMLUnsafe
80
+ if (typeof parse === 'function') {
81
+ parse.call(wrapper, html)
82
+ // A browser whose setHTMLUnsafe predates declarative roots leaves templates behind: attach those by hand.
83
+ for (const host of Array.from(wrapper.querySelectorAll('certrev-card'))) {
84
+ const template = pendingTemplate(host)
85
+ if (template) attachFromTemplate(host as HTMLElement, template)
86
+ }
87
+ return pending.length
88
+ }
89
+ for (const host of pending) {
90
+ const template = pendingTemplate(host)
91
+ if (template) attachFromTemplate(host as HTMLElement, template)
92
+ }
93
+ return pending.length
94
+ }
95
+
96
+ /** Load the document's runtime once per page: unless it already runs, or this package already injected it. */
97
+ export function injectCardRuntime(card: CardDocumentV1): void {
98
+ const runtime = card.assets.runtime
99
+ if (!runtime || typeof document === 'undefined' || !document.head) return
100
+ if ((document as unknown as Record<symbol, unknown>)[RUNTIME_GUARD]) return
101
+ for (const script of Array.from(document.head.querySelectorAll(`script[${INJECTED_RUNTIME_ATTRIBUTE}]`))) {
102
+ if (script.getAttribute('src') === runtime.src) return
103
+ }
104
+ const script = document.createElement('script')
105
+ // Attributes, not IDL properties: the markup is then exactly the server's tag, wherever a property is not reflected.
106
+ script.setAttribute('src', runtime.src)
107
+ script.setAttribute('integrity', runtime.integrity)
108
+ script.setAttribute('crossorigin', 'anonymous')
109
+ script.defer = true
110
+ script.setAttribute(INJECTED_RUNTIME_ATTRIBUTE, '')
111
+ document.head.appendChild(script)
112
+ }
113
+
114
+ /** Builder's wrappers around a printed rail let its float out (the header comment). */
115
+ export const BUILDER_CARD_FLOW_CSS =
116
+ '@media not all and (max-width: 820px){' +
117
+ '.builder-blocks:has(> .builder-block > [data-certrev-face="card"] > certrev-card[data-certrev-part="rail"]){display:flow-root !important}' +
118
+ '.builder-blocks > .builder-block:has(> [data-certrev-face="card"] > certrev-card[data-certrev-part="rail"]){display:block !important}' +
119
+ '.builder-blocks > .builder-block > [data-certrev-face="card"]:has(> certrev-card[data-certrev-part="rail"]){display:block !important}' +
120
+ '}'
121
+
122
+ export function ContractCardFace(props: {
123
+ readonly card: CardDocumentV1
124
+ readonly names: readonly CardPartName[]
125
+ /** The record that names this card (`ssr-seed.ts`): the browser repaints it from here with no read. */
126
+ readonly seed: string
127
+ }) {
128
+ const ref = React.useRef<HTMLDivElement>(null)
129
+ const html = cardHtml(props.card, props.names)
130
+ const { card } = props
131
+ const carriesAssets = cardCarriesAssets(card, props.names)
132
+ useBrowserLayoutEffect(() => {
133
+ const wrapper = ref.current
134
+ if (!wrapper) return
135
+ attachCardRoots(wrapper, html)
136
+ if (carriesAssets) injectCardRuntime(card)
137
+ }, [html, card, carriesAssets])
138
+ const wrapper = (
139
+ <div
140
+ ref={ref}
141
+ data-certrev-cert-chrome=""
142
+ data-certrev-delivery=""
143
+ data-certrev-face="card"
144
+ data-certrev-contract={String(props.card.contract)}
145
+ data-certrev-def-version={props.card.defVersion}
146
+ {...{ [SEED_ATTRIBUTE]: props.seed }}
147
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: CertREV's card document, printed only after it hashes to the digest the verified envelope signs
148
+ dangerouslySetInnerHTML={{ __html: html }}
149
+ />
150
+ )
151
+ if (!props.names.includes('rail')) return wrapper
152
+ return (
153
+ <>
154
+ {wrapper}
155
+ {/* biome-ignore lint/security/noDangerouslySetInnerHtml: a package constant; React would escape the `>` and `"` of its selectors */}
156
+ <style data-certrev-builder-flow="" dangerouslySetInnerHTML={{ __html: BUILDER_CARD_FLOW_CSS }} />
157
+ </>
158
+ )
159
+ }
@@ -103,6 +103,22 @@ export function deliveredFaceHtml(face: DeliveredFace): string {
103
103
  return `<style>${face.css}</style>${face.html}`
104
104
  }
105
105
 
106
+ /**
107
+ * Make room in a memo of pending-then-settled work: drop the oldest SETTLED entry once the memo is full, never a pending
108
+ * one. A render suspends on a pending entry and, when React retries it, must find that entry again; evicting it would make
109
+ * every retry start (and suspend on) a new one, so a page with more documents than the memo holds would never finish
110
+ * (break-it B1). With nothing settled to drop, the memo grows past its bound until entries settle.
111
+ */
112
+ export function evictOldestSettled<V extends { settled?: unknown }>(memo: Map<string, V>, max: number): void {
113
+ if (memo.size < max) return
114
+ for (const [key, entry] of memo) {
115
+ if (entry.settled !== undefined) {
116
+ memo.delete(key)
117
+ return
118
+ }
119
+ }
120
+ }
121
+
106
122
  /** One face's hashing: the pending digest, and its answer once settled. */
107
123
  export interface FaceHashMemo {
108
124
  readonly pending: Promise<DeliveredFaceHashes | null>
@@ -128,10 +144,8 @@ export function faceHashesFor(face: DeliveredFace): FaceHashMemo {
128
144
  return hashes
129
145
  }),
130
146
  }
131
- if (faceHashMemo.size >= FACE_HASH_MEMO_MAX) {
132
- const oldest = faceHashMemo.keys().next().value
133
- if (oldest !== undefined) faceHashMemo.delete(oldest)
134
- }
147
+ // 2.0.0: never a pending entry (break-it B1: more faces than the memo holds made every retry suspend again).
148
+ evictOldestSettled(faceHashMemo, FACE_HASH_MEMO_MAX)
135
149
  faceHashMemo.set(key, fresh)
136
150
  return fresh
137
151
  }
@@ -13,11 +13,19 @@
13
13
  * read on a brand's server then misses at most once per placement, and a warm one never waits.
14
14
  * A revocation still lands on the first render after its refresh does. The tests' runtime gets
15
15
  * the same cache shape, so they exercise what production runs.
16
+ *
17
+ * 2.0.0: the trust root is a key SET (`verify/key-set.ts`). `resolveKid` is still the baked set (today
18
+ * `cert-issuer-1`), and the card verifies with `resolveKidFor(baseUrl)`: a baked kid resolves as before, any other kid
19
+ * makes one read of the delivery origin's published key set, accepted only when a baked kid signs it. A rotation then
20
+ * needs no reinstall. The card's (2.0) contract-1 document digests are memoized with the delivered faces' hashes.
16
21
  */
17
22
 
18
23
  import type { ResolvePublicKeyByKid } from '../contract/kernel.js'
19
24
  import { TtlCache } from '../verify/cache.js'
20
25
  import type { CachedDelivery } from '../verify/get-verified-delivery.js'
26
+ import { forgetKeySetsForTests, keySetResolver } from '../verify/key-set.js'
27
+ import { forgetCardDigestsForTests } from './card-contract.js'
28
+ import { forgetCardReportsForTests } from './card-report.js'
21
29
  import { forgetFaceHashesForTests } from './delivered-face.js'
22
30
  import { CERT_ISSUER_KID, CERT_ISSUER_PUBLIC_KEY_PEM } from './issuer-key.js'
23
31
  import { forgetPageSeedsForTests } from './ssr-seed.js'
@@ -28,7 +36,13 @@ export const CARD_DELIVERY_TIMEOUT_MS = 8_000
28
36
  export const CARD_DELIVERY_STALE_MS = 300_000
29
37
 
30
38
  export interface CardDeliveryRuntime {
39
+ /** The baked kids: the package's own trust root (and the only keys that may sign a published key set). */
31
40
  readonly resolveKid: ResolvePublicKeyByKid
41
+ /**
42
+ * 2.0.0: what the card verifies a read from `baseUrl` with: the baked kids, then that origin's signed published set.
43
+ * Absent: derived from `resolveKid`, `fetchImpl` and `timeoutMs` (`keySetResolver`).
44
+ */
45
+ readonly resolveKidFor?: (baseUrl: string) => ResolvePublicKeyByKid
32
46
  readonly fetchImpl: () => typeof fetch
33
47
  readonly cache: TtlCache<CachedDelivery>
34
48
  readonly now: () => number
@@ -66,13 +80,28 @@ export function cardDeliveryRuntime(): CardDeliveryRuntime {
66
80
  return runtime
67
81
  }
68
82
 
83
+ /** 2.0.0: the resolver for a read from `baseUrl`: the runtime's own, else the baked kids plus the origin's key set. */
84
+ export function cardResolveKidFor(rt: CardDeliveryRuntime, baseUrl: string): ResolvePublicKeyByKid {
85
+ if (rt.resolveKidFor) return rt.resolveKidFor(baseUrl)
86
+ return keySetResolver({
87
+ baseUrl,
88
+ bakedResolveKid: rt.resolveKid,
89
+ fetchImpl: rt.fetchImpl(),
90
+ ...(rt.timeoutMs !== undefined ? { timeoutMs: rt.timeoutMs } : {}),
91
+ })
92
+ }
93
+
69
94
  /**
70
95
  * Test seam: replace parts of the runtime (a fresh cache unless one is given) and forget the page's envelope seeds
71
- * (a fresh runtime is a fresh page) and (1.3.0) every delivered face's memoized hashes. Returns a restore.
96
+ * (a fresh runtime is a fresh page), (1.3.0) every delivered face's memoized hashes and (2.0.0) every card
97
+ * document's digest and every published key set. Returns a restore.
72
98
  */
73
99
  export function setCardDeliveryRuntimeForTests(overrides: Partial<CardDeliveryRuntime>): () => void {
74
100
  forgetPageSeedsForTests()
75
101
  forgetFaceHashesForTests()
102
+ forgetCardDigestsForTests()
103
+ forgetKeySetsForTests()
104
+ forgetCardReportsForTests()
76
105
  const previous = runtime
77
106
  const now = overrides.now ?? previous.now
78
107
  runtime = {
@@ -155,6 +155,17 @@ export const certRevAnchorComponent: CertRevBuilderRegistration = {
155
155
  // 1.2.0-canary.5: the design pin, for CertREV's own canary replica (`<Content context={{ certrevDesignPin }}>`).
156
156
  export { type DesignPin, designPinFromUrl, parseDesignPin } from '../verify/get-verified-delivery.js'
157
157
 
158
+ // 2.0.0: the contract-1 card (CertREV's server-rendered card document), its fallback report and the Builder flow rule.
159
+ export {
160
+ acceptedCardBinding,
161
+ type CardBinding,
162
+ type CardDocumentV1,
163
+ type CardPartName,
164
+ cardDocumentDigest,
165
+ cardPartsFor,
166
+ parseCardDocument,
167
+ } from './card-contract.js'
168
+ export { CARD_FALLBACK_EVENT, type CardFallbackDetail, type CardFallbackReason } from './card-report.js'
158
169
  // ── The Builder.io PUSH kit (CertReviewCard) + the single-sourced wire type ──
159
170
  export {
160
171
  BUILDER_CERT_CHROME_KEYS,
@@ -176,6 +187,7 @@ export {
176
187
  PLACEMENT_INPUTS,
177
188
  WIRE_INPUTS,
178
189
  } from './cert-review-card.js'
190
+ export { BUILDER_CARD_FLOW_CSS } from './contract-card.js'
179
191
  export {
180
192
  CERT_FACE_FIELDS,
181
193
  credentialLabel,