@certrev/cert-block 2.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +45 -8
  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 +4 -4
  20. package/dist/builder/cert-review-card.d.ts.map +1 -1
  21. package/dist/builder/cert-review-card.js +77 -189
  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 +13 -3
  28. package/dist/builder/delivery-runtime.d.ts.map +1 -1
  29. package/dist/builder/delivery-runtime.js +17 -4
  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/get-verified-delivery.d.ts +4 -0
  42. package/dist/verify/get-verified-delivery.d.ts.map +1 -1
  43. package/dist/verify/get-verified-delivery.js +9 -3
  44. package/dist/verify/get-verified-delivery.js.map +1 -1
  45. package/package.json +1 -1
  46. package/src/builder/card-beacon.ts +201 -0
  47. package/src/builder/card-contract.ts +25 -3
  48. package/src/builder/card-placement.ts +208 -0
  49. package/src/builder/card-report.ts +79 -14
  50. package/src/builder/cert-review-card.tsx +100 -230
  51. package/src/builder/contract-card.tsx +65 -8
  52. package/src/builder/delivery-runtime.ts +17 -4
  53. package/src/builder/index.tsx +4 -4
  54. package/src/builder/ssr-seed.ts +5 -4
  55. package/src/builder/version.ts +2 -0
  56. package/src/verify/get-verified-delivery.ts +13 -3
@@ -22,7 +22,7 @@ import * as React from 'react'
22
22
  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
- import { CERT_STRINGS_VERSION, type ResolvedBlockTheme } from '../components/render-def.js'
25
+ import { CERT_STRINGS_VERSION } from '../components/render-def.js'
26
26
  import { type CertPayload, renderVerdict } from '../contract/kernel.js'
27
27
  import {
28
28
  type DeliveredFace,
@@ -45,6 +45,7 @@ import {
45
45
  } from './card-contract.js'
46
46
  import { CARD_READER_CSS } from './card-reader-css.js'
47
47
  import {
48
+ CARD_FALLBACK_REASONS,
48
49
  type CardFallbackDetail,
49
50
  type CardFallbackReason,
50
51
  reportCardFallbackInBrowser,
@@ -64,7 +65,6 @@ import {
64
65
  import { cardDeliveryRuntime, cardResolveKidFor } from './delivery-runtime.js'
65
66
  import { envelopeCardHtml, envelopeCardInput, isReaderFaceDef } from './envelope-face.js'
66
67
  import {
67
- type FallbackFace,
68
68
  type PageRecord,
69
69
  pageHoldsServerHtml,
70
70
  pageRecordFor,
@@ -121,10 +121,10 @@ export interface CertReviewCardProps extends Partial<BuilderCertChromeData> {
121
121
  * A Delivery read FROM THE BROWSER for a card the server did not render (a client-side navigation, Builder's editor).
122
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
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.
124
+ * of the four CSP sources 2.0 asks for. A read the policy blocks is a read that did not reach CertREV: nothing, and a
125
+ * reported `delivery_unreachable` (2.0.1; 2.0.0 painted the block's options face). `false`: no request from the
126
+ * browser, and such a card prints nothing and reports `client_read_off`. Not a Builder input: a brand sets it in code,
127
+ * on a wrapper of this component.
128
128
  */
129
129
  clientRead?: boolean
130
130
  }
@@ -143,28 +143,6 @@ function text(value: unknown): string {
143
143
  return typeof value === 'string' ? value : ''
144
144
  }
145
145
 
146
- /** The same rule where ABSENT and EMPTY are different renders (`authorTitle`, `bio`). */
147
- function optionalText(value: unknown): string | undefined {
148
- return text(value) || undefined
149
- }
150
-
151
- /**
152
- * The fused credential pair — accepted ONLY when both halves are present AND both are strings.
153
- *
154
- * The R1 gate ("a credential never displays without its dated verification") is THIS FUNCTION,
155
- * not the type: the pair arrives as untyped CMS JSON, and the old truthiness check keyed the
156
- * whole pair off the OBJECT, so `{credential:'MD'}` forwarded `verifiedAt: undefined` into the
157
- * renderer and crashed. Anything malformed — a half pair, a bare string, an array, a number
158
- * where a date belongs — degrades to the name-only face, which is the honest render of a
159
- * delivery whose verification cannot be read.
160
- */
161
- function fusedCredential(pair: BuilderCertChromeData['credentialVerification'] | undefined) {
162
- if (!pair || typeof pair !== 'object') return null
163
- const credential = text(pair.credential)
164
- const verifiedAt = text(pair.verifiedAt)
165
- return credential && verifiedAt ? { credential, verifiedAt } : null
166
- }
167
-
168
146
  /**
169
147
  * The delivery marker, accepted only as `{ v: 1, baseUrl }` with `baseUrl` an `https:` ORIGIN on
170
148
  * `certrev.com` or one of its subdomains (no path, query, credentials or port). Anything else is
@@ -460,43 +438,6 @@ function paintEnvelope(
460
438
  return <EnvelopeFace html={renderCertBlock(input)} defVersion={defVersion} seed={seed} />
461
439
  }
462
440
 
463
- const FALLBACK_LAYOUTS: readonly string[] = ['banner', 'sidebar', 'floating']
464
-
465
- /**
466
- * The options face a block with the delivery marker paints in place of the envelope (1.2.0), or null. Its placement is
467
- * the block's own `mode`, else its def's layout, as the envelope face's is, else the banner bridge. A pinned card (the
468
- * canary replica) paints it too, so the replica shows what a brand's page shows when CertREV is not reached; its
469
- * record names the pin and says `'options'`, so a capture can never take it for the pinned design.
470
- */
471
- function fallbackFace(props: EnvelopeCardProps): string | null {
472
- const { block } = props
473
- const layout = block.renderDef?.layout
474
- const mode =
475
- typeof block.mode === 'string' && FALLBACK_LAYOUTS.includes(block.mode)
476
- ? block.mode
477
- : typeof layout === 'string' && FALLBACK_LAYOUTS.includes(layout)
478
- ? (layout as CertBlockMode)
479
- : undefined
480
- return optionsFaceHtml(block, mode)
481
- }
482
-
483
- /** The block's options face, stamped with a fallback record when the server wrote one (`ssr-seed.ts`). */
484
- function OptionsFace(props: { readonly block: CertReviewCardProps; readonly html: string; readonly record?: string }) {
485
- return (
486
- <div
487
- data-certrev-cert-chrome=""
488
- // The two provenance stamps the crawl monitor reads — emitted ONLY when the delivery
489
- // carried the right type, so a mistyped option leaves the attribute off instead of
490
- // stamping "[object Object]" as a version.
491
- data-certrev-strings-version={optionalText(props.block.stringsVersion)}
492
- data-certrev-def-version={typeof props.block.renderDef?.v === 'number' ? props.block.renderDef.v : undefined}
493
- {...(props.record === undefined ? {} : { [SEED_ATTRIBUTE]: props.record })}
494
- // biome-ignore lint/security/noDangerouslySetInnerHtml: renderCertBlock is the escaping-safe SSR string renderer
495
- dangerouslySetInnerHTML={{ __html: props.html }}
496
- />
497
- )
498
- }
499
-
500
441
  /** Where the server painted no face, an empty hidden element carries the record that says so. */
501
442
  function NoFace(props: { readonly record: string }) {
502
443
  return <div hidden {...{ [SEED_ATTRIBUTE]: props.record }} />
@@ -504,14 +445,19 @@ function NoFace(props: { readonly record: string }) {
504
445
 
505
446
  /**
506
447
  * 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.
448
+ * page). On a server it reports as it renders; in a browser, once it has mounted. 2.0.1: the report is also a beacon to
449
+ * `origin`, the delivery origin the block reads (none: CertREV's production origin).
508
450
  */
509
- function FallbackReport(props: { readonly detail: CardFallbackDetail; readonly server: boolean }) {
510
- const { detail, server } = props
511
- if (server) reportCardFallbackOnServer(detail)
451
+ function FallbackReport(props: {
452
+ readonly detail: CardFallbackDetail
453
+ readonly server: boolean
454
+ readonly origin?: string
455
+ }) {
456
+ const { detail, server, origin } = props
457
+ if (server) reportCardFallbackOnServer(detail, origin)
512
458
  React.useEffect(() => {
513
- reportCardFallbackInBrowser(detail)
514
- }, [detail])
459
+ reportCardFallbackInBrowser(detail, origin)
460
+ }, [detail, origin])
515
461
  return null
516
462
  }
517
463
 
@@ -522,17 +468,19 @@ function withReport(
522
468
  props: EnvelopeCardProps,
523
469
  server: boolean,
524
470
  certId?: string,
471
+ status?: number,
525
472
  ): React.ReactElement {
526
473
  const detail: CardFallbackDetail = {
527
474
  reason,
528
475
  platform: 'builder',
529
476
  externalId: props.externalId,
530
477
  ...(certId === undefined ? {} : { certId }),
478
+ ...(status === undefined ? {} : { status }),
531
479
  }
532
480
  return (
533
481
  <>
534
482
  {face}
535
- <FallbackReport detail={detail} server={server} />
483
+ <FallbackReport detail={detail} server={server} origin={props.baseUrl} />
536
484
  </>
537
485
  )
538
486
  }
@@ -596,40 +544,49 @@ function contractNone(
596
544
  }
597
545
 
598
546
  /**
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`).
547
+ * What a card with no verified envelope paints: nothing, and a `'none'` record. 2.0.1: CertREV not reached prints
548
+ * nothing too (it painted the block's export-time options through 2.0.0), and the record names `delivery_unreachable`
549
+ * so the browser reports it as well (with CertREV's HTTP status when it answered with one); an unknown kid is reported as
550
+ * `unknown_key`, and (2.0.1) an envelope that does not verify as `envelope_verify_failed`. A definitive no (revoked,
551
+ * expired, for another entry, not found) prints nothing, by design, unreported.
602
552
  */
603
553
  function settleWithoutEnvelope(
604
554
  props: EnvelopeCardProps,
605
- verdict: { readonly unreachable: boolean; readonly reason?: string },
555
+ verdict: { readonly unreachable: boolean; readonly reason?: string; readonly status?: number },
606
556
  server: boolean,
607
557
  ): React.ReactElement {
608
- const html = verdict.unreachable ? fallbackFace(props) : null
609
- const face: FallbackFace = html === null ? 'none' : 'options'
558
+ const reason: CardFallbackReason | undefined = verdict.unreachable
559
+ ? 'delivery_unreachable'
560
+ : verdict.reason === 'unknown_key'
561
+ ? 'unknown_key'
562
+ : verdict.reason !== undefined && ENVELOPE_VERIFY_FAILURES.has(verdict.reason)
563
+ ? 'envelope_verify_failed'
564
+ : undefined
610
565
  const record = serializeFallback({
611
566
  baseUrl: props.baseUrl,
612
567
  externalId: props.externalId,
613
568
  ...(props.design ? { design: props.design } : {}),
614
- face,
569
+ face: 'none',
570
+ ...(reason ? { reason } : {}),
615
571
  })
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
572
  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
573
+ return reason
574
+ ? withReport(none, reason, props, server, undefined, verdict.unreachable ? verdict.status : undefined)
575
+ : none
628
576
  }
629
577
 
578
+ /** 2.0.1: the kernel's suppress reasons that mean the envelope itself did not verify (reported, never silent). */
579
+ const ENVELOPE_VERIFY_FAILURES: ReadonlySet<string> = new Set([
580
+ 'invalid_signature',
581
+ 'unsupported_alg',
582
+ 'platform_mismatch',
583
+ 'content_drift',
584
+ 'unsupported_contract_version',
585
+ ])
586
+
630
587
  /**
631
588
  * Paint what the page's record for this placement says: a seed its envelope (judged at the browser's clock first when
632
- * `judge`), an `'options'` record the options face, a `'none'` record nothing.
589
+ * `judge`), a `'none'` record nothing, and (2.0.1) a 2.0.0 server's `'options'` record nothing too.
633
590
  */
634
591
  function paintRecord(
635
592
  record: PageRecord,
@@ -652,7 +609,7 @@ function paintRecord(
652
609
  if (binding !== null) {
653
610
  const outcome = contractOutcome(binding, seed.card, seed.payload, props)
654
611
  if (outcome.kind === 'card')
655
- return <ContractCardFace card={outcome.card} names={outcome.names} seed={record.raw} />
612
+ return <ContractCardFace card={outcome.card} names={outcome.names} seed={record.raw} origin={props.baseUrl} />
656
613
  // Hydrating over the card the server printed, which the browser could not confirm (no WebCrypto): adopt the
657
614
  // server's element; the render right after hydration prints nothing in its place.
658
615
  if (!judge && outcome.reason === 'card_verify_failed' && seed.card !== undefined)
@@ -671,38 +628,21 @@ function paintRecord(
671
628
  if (seed.face && !judge) return <ServerFaceUntilHydrated />
672
629
  return paintEnvelope(seed.payload, seed.renderDef, props, record.raw)
673
630
  }
674
- const html = record.fallback.face === 'options' ? fallbackFace(props) : null
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
631
  const none = <NoFace record={record.raw} />
632
+ // 2.0.1: an `'options'` record (a page a 2.0.0 server rendered while CertREV was not reached) prints nothing too.
633
+ if (record.fallback.face === 'options') return withReport(none, 'delivery_unreachable', props, false)
684
634
  // 2.0.0: a contract-1 placement's record names why it printed nothing; the browser reports it too.
685
635
  const reason = record.fallback.reason
686
636
  return isFallbackReason(reason) ? withReport(none, reason, props, false) : none
687
637
  }
688
638
 
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
639
  function isFallbackReason(value: unknown): value is CardFallbackReason {
699
- return typeof value === 'string' && FALLBACK_REASONS.includes(value)
640
+ return typeof value === 'string' && (CARD_FALLBACK_REASONS as readonly string[]).includes(value)
700
641
  }
701
642
 
702
- /** What a card the client mounts with no record and no `clientRead` paints: the options face, or nothing. */
703
- function clientMountFace(props: EnvelopeCardProps): React.ReactElement | null {
704
- const html = fallbackFace(props)
705
- return html === null ? null : <OptionsFace block={props.block} html={html} />
643
+ /** 2.0.1: a card the client mounts with no record and `clientRead` off prints nothing (2.0.0: the options face). */
644
+ function clientMountFace(props: EnvelopeCardProps): React.ReactElement {
645
+ return withReport(null, 'client_read_off', props, false)
706
646
  }
707
647
 
708
648
  const pendingRecord = () => 'pending' as const
@@ -756,23 +696,24 @@ function useHydrating(): boolean {
756
696
  * as through 1.1.1, and write a record of what it painted (`ssr-seed.ts`):
757
697
  * - a verified envelope: its face and the envelope seed (less the fields the def hides, and with no signature if
758
698
  * it hid any);
759
- * - CertREV not reached (a timeout, a network error, a 5xx, a body that is not JSON): the block's own options face,
760
- * as 0.5.x painted it, and an `'options'` record (a pinned card too: the record names its pin);
699
+ * - CertREV not reached (a timeout, a network error, a 5xx, a body that is not JSON) with no verified answer inside
700
+ * the stale window: nothing, and a `'none'` record naming `delivery_unreachable` (2.0.1; through 2.0.0 the
701
+ * block's own options face and an `'options'` record);
761
702
  * - a definitive no (revoked, expired, unverifiable, for another entry, not found): nothing, and a `'none'` record
762
703
  * on an empty hidden element.
763
704
  * - Browser, the page carries this placement's record: paint what it says. A seed paints its envelope; while
764
705
  * hydrating the server's verdict stands (the tree is the server's, byte for byte), and once hydrated the lifecycle
765
- * and subject are judged at the browser's clock, so a card that expired since the server render is taken down. An
766
- * `'options'` record paints the options face, a `'none'` record nothing. This holds when React renders the card
706
+ * and subject are judged at the browser's clock, so a card that expired since the server render is taken down. A
707
+ * `'none'` record, and (2.0.1) a 2.0.0 server's `'options'` record, print nothing. This holds when React renders the card
767
708
  * again on the client after a hydration error elsewhere on the page: the record is still in the document (or in
768
709
  * the page's memory of it), so the browser repaints the server's answer and reads nothing.
769
710
  * - Browser, hydrating with no record: server HTML from before 1.2.0 painted nothing here, so nothing.
770
711
  * - Browser, a card mounted on the client with no record (a client-side navigation to an article this page never
771
- * server-rendered, Builder's editor): the block's options face, pinned or not. With `clientRead`, instead, the
772
- * one read, settled exactly as on the server.
712
+ * server-rendered, Builder's editor): with `clientRead` (the default), the one read, settled exactly as on the
713
+ * server; with `clientRead={false}`, nothing (`client_read_off`; 2.0.0: the block's options face).
773
714
  * - Browser, the same while the document is still loading (1.2.1; React rendered the card on the client before the
774
- * server's streamed record arrived, `ssr-seed.ts`): nothing until the record arrives, then what it says; the
775
- * options face, or nothing, if the page loads without one (`StreamedRecordCard`). `clientRead` reads as above.
715
+ * server's streamed record arrived, `ssr-seed.ts`): nothing until the record arrives, then what it says; if the page
716
+ * loads without one, what a client mount does (`StreamedRecordCard`). `clientRead` reads as above.
776
717
  *
777
718
  * WHICH OF THE TWO FIRST CASES THIS RENDER IS (server or browser) is decided from what the render can observe, with
778
719
  * no switch a test could set. A server render and a hydration are both "hydrating" to React and both see a `document`
@@ -848,7 +789,11 @@ function readAndPaint(
848
789
  if (settled.verdict.decision !== 'render') {
849
790
  return settleWithoutEnvelope(
850
791
  props,
851
- { unreachable: settled.unreachable === true, reason: settled.verdict.reason },
792
+ {
793
+ unreachable: settled.unreachable === true,
794
+ reason: settled.verdict.reason,
795
+ ...(settled.status === undefined ? {} : { status: settled.status }),
796
+ },
852
797
  !inBrowser,
853
798
  )
854
799
  }
@@ -872,6 +817,7 @@ function readAndPaint(
872
817
  card={outcome.card}
873
818
  names={outcome.names}
874
819
  seed={serializeSeed({ ...seed, card: outcome.card as unknown as Readonly<Record<string, unknown>> })}
820
+ origin={props.baseUrl}
875
821
  />
876
822
  )
877
823
  }
@@ -897,116 +843,40 @@ function readAndPaint(
897
843
  }
898
844
 
899
845
  export function CertReviewCard(props: CertReviewCardProps) {
900
- // 1.1.0: a block that carries the delivery marker paints from the signed envelope and the
901
- // brand's def, never from the options beside it. No marker (every entry exported before 1.1.0)
902
- // takes the options path below, unchanged from 1.0.3.
903
- // 1.2.0-canary.5: a design pin (CertREV's canary replica only) fails closed. One that cannot be read renders
904
- // nothing, and so does one on a block without the delivery marker: the options face is never the pinned design.
905
- // 1.2.0: where the envelope cannot be had (CertREV not reached, or no server render and no `clientRead`), a block
906
- // with the marker paints this options face too, pinned or not: see `EnvelopeCertReviewCard`.
846
+ // 1.1.0: a block that carries the delivery marker paints from the signed envelope and the brand's def, never from
847
+ // the options beside it. 2.0.1: a block WITHOUT the marker (an entry exported before 1.1.0, a block dropped in by
848
+ // hand) prints nothing at all and reports `no_marker`; through 2.0.0 it painted its export-time options (a reviewer
849
+ // name, a credential, a memo, a verify link nobody had signed). A marker this client does not accept, or a block
850
+ // with no Builder entry to read for, prints nothing and reports `marker_invalid`.
851
+ // 1.2.0-canary.5: a design pin (CertREV's canary replica only) that cannot be read renders nothing.
907
852
  const design = parseDesignPin(props.builderContext?.context?.certrevDesignPin)
908
853
  if (design === 'invalid') return null
909
- if (props.delivery != null) {
910
- const marker = acceptedDeliveryMarker(props.delivery)
911
- const entryId = text(props.builderContext?.content?.id)
912
- if (!marker || !entryId) return null
913
- return (
914
- <Suspense fallback={null}>
915
- <EnvelopeCertReviewCard
916
- baseUrl={marker.baseUrl}
917
- externalId={entryId}
918
- mode={props.mode}
919
- part={props.part}
920
- design={design}
921
- block={props}
922
- clientRead={props.clientRead !== false}
923
- />
924
- </Suspense>
925
- )
926
- }
927
- if (design) return null
928
-
929
- const html = optionsFaceHtml(props, 'banner')
930
- if (html === null) return null // no chrome pre-cert (or an unreadable delivery)
931
- return <OptionsFace block={props} html={html} />
854
+ const entryId = text(props.builderContext?.content?.id)
855
+ if (props.delivery == null) return <BlockReport reason="no_marker" externalId={entryId} />
856
+ const marker = acceptedDeliveryMarker(props.delivery)
857
+ if (!marker || !entryId) return <BlockReport reason="marker_invalid" externalId={entryId} />
858
+ return (
859
+ <Suspense fallback={null}>
860
+ <EnvelopeCertReviewCard
861
+ baseUrl={marker.baseUrl}
862
+ externalId={entryId}
863
+ mode={props.mode}
864
+ part={props.part}
865
+ design={design}
866
+ block={props}
867
+ clientRead={props.clientRead !== false}
868
+ />
869
+ </Suspense>
870
+ )
932
871
  }
933
872
 
934
- /**
935
- * The options face's HTML (1.0.3's, unchanged), or null when the block lacks a reviewer or a certificate link. `bridge`
936
- * is the placement a block with no `mode` gets: `'banner'` for a block without the delivery marker (the bridge below);
937
- * the def's layout for a marked block's fallback (`fallbackFace`).
938
- */
939
- function optionsFaceHtml(props: CertReviewCardProps, bridge: CertBlockMode | undefined): string | null {
940
- const reviewerName = text(props.reviewerName)
941
- const verifyUrl = text(props.verifyUrl)
942
- if (!reviewerName || !verifyUrl) return null
943
-
944
- // Name-only face when the pair is unreadable, or when the brand set showCredentials:false.
945
- const showCred = props.display?.showCredentials !== false
946
- const withCred = showCred ? fusedCredential(props.credentialVerification) : null
947
-
948
- const tokens = props.renderDef?.tokens
949
- const theme: Partial<ResolvedBlockTheme> | undefined = tokens
950
- ? {
951
- accentColor: optionalText(tokens.accentColor),
952
- surface: optionalText(tokens.surface),
953
- cornerRadius: optionalText(tokens.cornerRadius),
954
- fontSlot: optionalText(tokens.fontSlot) as ResolvedBlockTheme['fontSlot'] | undefined,
955
- // v2 brand-ink (post-demo): the bar + free-content ink follow the def.
956
- barInk: optionalText(tokens.barInk),
957
- inkColor: optionalText(tokens.inkColor),
958
- }
959
- : undefined
960
-
961
- return renderCertBlock({
962
- // COMPATIBILITY BRIDGE, NOT THE DESIGN. Delete this fallback once the exporter emits an
963
- // explicit `mode` and existing entries have been backfilled; until then it must stay.
964
- //
965
- // `renderCertBlock` owns the ONE placement default (absent or unrecognised ⇒ sidebar) and
966
- // that decision STANDS: it is what a direct caller gets, and `sidebar` is the registered
967
- // input's `defaultValue`, so every block created in the editor from now on carries an
968
- // explicit `mode` and never reaches this line.
969
- //
970
- // The problem is the entries that already exist. `mode` is a PLACEMENT input, not one of
971
- // the 20 wire keys (`BUILDER_CERT_CHROME_KEYS`), and the exporter emits only the wire
972
- // projection (`options: { ...certData }`), so every entry exported to date has NO `mode`
973
- // key at all. Through 0.5.5 this line read `?? 'banner'` and those entries rendered the
974
- // banner face. Removing it in 1.0.0 did not give them the renderer's default in the
975
- // abstract; it silently repainted live pages from banner to sidebar, with no entry edit
976
- // and no version pin to protect them, because their install line is unpinned.
977
- //
978
- // So this is a bridge for pre-existing entries, NOT a reversal of the sidebar default.
979
- // The three-copies-of-a-default problem that motivated removing it is still real and is
980
- // still fixed: the helper text and the wire type's doc comment no longer name a default,
981
- // and this line names one only for input the renderer can no longer distinguish from a
982
- // deliberate choice. The exporter writing `mode` is what retires it.
983
- mode: props.mode ?? bridge ?? 'banner',
984
- theme,
985
- // First-class banner memo split — renderCertBlock composes the requested
986
- // part directly; no string-splitting. Ignored outside banner mode, and an unrecognised
987
- // value is normalised to 'full' there, same as `mode` — not here.
988
- ...(props.part ? { part: props.part } : {}),
989
- facts: {
990
- authorName: optionalText(props.authorName) ?? 'Editorial',
991
- authorTitle: optionalText(props.authorTitle),
992
- reviewerName,
993
- credential: withCred?.credential ?? '',
994
- credentialVerifiedAt: withCred?.verifiedAt ?? '',
995
- certifiedAt: text(props.certifiedAt),
996
- memo: text(props.memo),
997
- bio: optionalText(props.bio),
998
- profileUrl: text(props.reviewerProfileUrl),
999
- certificateUrl: verifyUrl,
1000
- // The compliance disclosures are CONSTANTS the renderer owns — a delivered cue is
1001
- // IGNORED (an editor cannot retype the FTC copy into something friendlier), and the
1002
- // scope line is not forwarded at all. The ONE thing honored is an explicit `null`:
1003
- // the pro-bono reviewer has no compensation to disclose, so the cue is omitted
1004
- // rather than replaced by the compensated constant (the modal already gets this
1005
- // right — a card claiming "Compensated expert" over a volunteer is an FTC-facing
1006
- // falsehood, not a cosmetic default).
1007
- compensationCue: props.compensationCue === null ? null : undefined,
1008
- },
1009
- })
873
+ /** 2.0.1: a block with nothing to read reports why, to CertREV's production origin (it has no delivery origin). */
874
+ function BlockReport(props: { readonly reason: CardFallbackReason; readonly externalId: string }) {
875
+ const detail = React.useMemo<CardFallbackDetail>(
876
+ () => ({ reason: props.reason, platform: 'builder', externalId: props.externalId }),
877
+ [props.reason, props.externalId],
878
+ )
879
+ return <FallbackReport detail={detail} server={typeof document === 'undefined'} />
1010
880
  }
1011
881
 
1012
882
  // ─────────────────────────────────────────────────────────────────────────────
@@ -21,7 +21,18 @@
21
21
  * injected. Only the block that prints the runtime's tag loads it (`cardCarriesAssets`). The server's HTML carries
22
22
  * the same tag in that block's wrapper, which the parser runs; the runtime's init is idempotent, so it starts once.
23
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.
24
+ * empty frame; the roots are checked after every commit of the block, whatever React wrote. React writes the card only
25
+ * when its HTML changes: the `dangerouslySetInnerHTML` object is kept per string, since React 19 rewrites `innerHTML`
26
+ * for every new object (2.0.0 on React 19 rebuilt the card on each re-render, and the hosted runtime re-attached it).
27
+ *
28
+ * NO CONSOLE NOISE (2.0.1). In a browser that parses declarative roots, the HTML React itself writes names each
29
+ * template's mode `data-certrev-shadowrootmode` instead of `shadowrootmode` (`clientCardHtml`): Chrome warns ("Found
30
+ * declarative shadowrootmode attribute on a template, but declarative Shadow DOM is not being parsed") for every such
31
+ * template `innerHTML` meets, and the layout effect re-parses the card's exact bytes anyway. The wrapper carries
32
+ * `suppressHydrationWarning` (React never prints it): the server's wrapper holds attached roots where React expects
33
+ * templates, which React's development build reported as a hydration mismatch on every page. React does not patch a
34
+ * hydrated element's HTML, so the server's card stays as the parser built it. A server, and a DOM without declarative
35
+ * roots (jsdom, an old browser), print the card's bytes unchanged.
25
36
  *
26
37
  * ONE BUILDER RULE, for the sidebar card only. The card's rail is a right float that the article's copy flows around
27
38
  * (the Appearance preview's layout, and every other CMS's). On a Builder page the block sits in Builder's own wrapper,
@@ -34,7 +45,9 @@
34
45
  */
35
46
 
36
47
  import * as React from 'react'
48
+ import { maybeSendBrowserHealth } from './card-beacon.js'
37
49
  import { type CardDocumentV1, type CardPartName, cardCarriesAssets, cardHtml } from './card-contract.js'
50
+ import { afterPageSettles, applyCardPlacement } from './card-placement.js'
38
51
  import { SEED_ATTRIBUTE } from './ssr-seed.js'
39
52
 
40
53
  /** The global the hosted runtime sets once it has started (`Symbol.for('certrev.card-runtime')` on `document`). */
@@ -45,18 +58,36 @@ const INJECTED_RUNTIME_ATTRIBUTE = 'data-certrev-card-runtime'
45
58
  /** A layout effect in a browser; nothing on a server (where React would only warn about it). */
46
59
  const useBrowserLayoutEffect = typeof document === 'undefined' ? () => {} : React.useLayoutEffect
47
60
 
61
+ /** What a client render prints in place of a template's `shadowrootmode` (the header comment). */
62
+ export const INERT_MODE_ATTRIBUTE = 'data-certrev-shadowrootmode'
63
+
64
+ /** Whether this DOM parses declarative shadow roots (a browser does; a server and jsdom do not). */
65
+ function parsesDeclarativeRoots(): boolean {
66
+ return typeof HTMLTemplateElement === 'function' && 'shadowRootMode' in HTMLTemplateElement.prototype
67
+ }
68
+
69
+ /** The card's HTML as React writes it in such a browser: each template's mode under `INERT_MODE_ATTRIBUTE`. */
70
+ export function clientCardHtml(html: string): string {
71
+ return html.replace(/<template\b([^>]*?)\sshadowrootmode=/gi, `<template$1 ${INERT_MODE_ATTRIBUTE}=`)
72
+ }
73
+
74
+ /** The template's declared mode, under either name. */
75
+ function declaredMode(template: Element): string | null {
76
+ return template.getAttribute('shadowrootmode') ?? template.getAttribute(INERT_MODE_ATTRIBUTE)
77
+ }
78
+
48
79
  /** Whether this host still holds an unattached declarative root. */
49
80
  function pendingTemplate(host: Element): HTMLTemplateElement | null {
50
81
  if ((host as HTMLElement).shadowRoot) return null
51
82
  for (const child of Array.from(host.children)) {
52
- if (child instanceof HTMLTemplateElement && child.hasAttribute('shadowrootmode')) return child
83
+ if (child instanceof HTMLTemplateElement && declaredMode(child) !== null) return child
53
84
  }
54
85
  return null
55
86
  }
56
87
 
57
88
  /** Attach one host's root from its template, as the parser would have: its mode, its options, its content. */
58
89
  function attachFromTemplate(host: HTMLElement, template: HTMLTemplateElement): void {
59
- const mode = template.getAttribute('shadowrootmode') === 'closed' ? 'closed' : 'open'
90
+ const mode = declaredMode(template) === 'closed' ? 'closed' : 'open'
60
91
  const init: ShadowRootInit & { clonable?: boolean; serializable?: boolean } = {
61
92
  mode,
62
93
  delegatesFocus: template.hasAttribute('shadowrootdelegatesfocus'),
@@ -119,22 +150,47 @@ export const BUILDER_CARD_FLOW_CSS =
119
150
  '.builder-blocks > .builder-block > [data-certrev-face="card"]:has(> certrev-card[data-certrev-part="rail"]){display:block !important}' +
120
151
  '}'
121
152
 
153
+ /** One object for the flow rule's element, so React 19 never rewrites it on a re-render. */
154
+ const BUILDER_CARD_FLOW_HTML = { __html: BUILDER_CARD_FLOW_CSS }
155
+
122
156
  export function ContractCardFace(props: {
123
157
  readonly card: CardDocumentV1
124
158
  readonly names: readonly CardPartName[]
125
159
  /** The record that names this card (`ssr-seed.ts`): the browser repaints it from here with no read. */
126
160
  readonly seed: string
161
+ /** 2.0.1: the delivery origin the block read, where its sampled health beacon goes (`card-beacon.ts`). */
162
+ readonly origin?: string
127
163
  }) {
128
164
  const ref = React.useRef<HTMLDivElement>(null)
129
165
  const html = cardHtml(props.card, props.names)
166
+ const printed = typeof document !== 'undefined' && parsesDeclarativeRoots() ? clientCardHtml(html) : html
167
+ // One object per printed string: React 19 rewrites `innerHTML` whenever this prop is a new object (React 18 compared
168
+ // the strings), which would replace the attached card on every re-render.
169
+ const inner = React.useMemo(() => ({ __html: printed }), [printed])
130
170
  const { card } = props
131
171
  const carriesAssets = cardCarriesAssets(card, props.names)
172
+ // After every commit of this block (a no-op while every root is attached): whatever React wrote, the card has its roots
173
+ // before the browser paints.
174
+ useBrowserLayoutEffect(() => {
175
+ if (ref.current) attachCardRoots(ref.current, html)
176
+ })
132
177
  useBrowserLayoutEffect(() => {
178
+ if (ref.current && carriesAssets) injectCardRuntime(card)
179
+ }, [card, carriesAssets])
180
+ const { origin } = props
181
+ React.useEffect(() => {
182
+ maybeSendBrowserHealth(origin, card.subject.externalId)
183
+ }, [origin, card])
184
+ // 2.0.1: the document's placement hints, once the page has settled (`card-placement.ts`). Keyed on the printed HTML,
185
+ // which the document and the parts decide, so a re-render that prints the same card moves nothing again.
186
+ const placing = React.useRef({ card, names: props.names })
187
+ placing.current = { card, names: props.names }
188
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `html` is the trigger on purpose; the document and parts are read from the ref
189
+ React.useEffect(() => {
133
190
  const wrapper = ref.current
134
191
  if (!wrapper) return
135
- attachCardRoots(wrapper, html)
136
- if (carriesAssets) injectCardRuntime(card)
137
- }, [html, card, carriesAssets])
192
+ return afterPageSettles(() => applyCardPlacement(wrapper, placing.current.card, placing.current.names))
193
+ }, [html])
138
194
  const wrapper = (
139
195
  <div
140
196
  ref={ref}
@@ -144,8 +200,9 @@ export function ContractCardFace(props: {
144
200
  data-certrev-contract={String(props.card.contract)}
145
201
  data-certrev-def-version={props.card.defVersion}
146
202
  {...{ [SEED_ATTRIBUTE]: props.seed }}
203
+ suppressHydrationWarning
147
204
  // 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 }}
205
+ dangerouslySetInnerHTML={inner}
149
206
  />
150
207
  )
151
208
  if (!props.names.includes('rail')) return wrapper
@@ -153,7 +210,7 @@ export function ContractCardFace(props: {
153
210
  <>
154
211
  {wrapper}
155
212
  {/* 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 }} />
213
+ <style data-certrev-builder-flow="" dangerouslySetInnerHTML={BUILDER_CARD_FLOW_HTML} />
157
214
  </>
158
215
  )
159
216
  }