@certrev/cert-block 1.3.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 (88) hide show
  1. package/CHANGELOG.md +235 -0
  2. package/README.md +162 -9
  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 +151 -0
  8. package/dist/builder/card-contract.d.ts.map +1 -0
  9. package/dist/builder/card-contract.js +286 -0
  10. package/dist/builder/card-contract.js.map +1 -0
  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 +49 -0
  16. package/dist/builder/card-report.d.ts.map +1 -0
  17. package/dist/builder/card-report.js +97 -0
  18. package/dist/builder/card-report.js.map +1 -0
  19. package/dist/builder/cert-review-card.d.ts +7 -5
  20. package/dist/builder/cert-review-card.d.ts.map +1 -1
  21. package/dist/builder/cert-review-card.js +202 -170
  22. package/dist/builder/cert-review-card.js.map +1 -1
  23. package/dist/builder/contract-card.d.ts +70 -0
  24. package/dist/builder/contract-card.d.ts.map +1 -0
  25. package/dist/builder/contract-card.js +191 -0
  26. package/dist/builder/contract-card.js.map +1 -0
  27. package/dist/builder/delivered-face.d.ts +9 -0
  28. package/dist/builder/delivered-face.d.ts.map +1 -1
  29. package/dist/builder/delivered-face.js +18 -5
  30. package/dist/builder/delivered-face.js.map +1 -1
  31. package/dist/builder/delivery-runtime.d.ts +27 -3
  32. package/dist/builder/delivery-runtime.d.ts.map +1 -1
  33. package/dist/builder/delivery-runtime.js +40 -4
  34. package/dist/builder/delivery-runtime.js.map +1 -1
  35. package/dist/builder/index.d.ts +7 -4
  36. package/dist/builder/index.d.ts.map +1 -1
  37. package/dist/builder/index.js +8 -4
  38. package/dist/builder/index.js.map +1 -1
  39. package/dist/builder/ssr-seed.d.ts +23 -4
  40. package/dist/builder/ssr-seed.d.ts.map +1 -1
  41. package/dist/builder/ssr-seed.js +29 -6
  42. package/dist/builder/ssr-seed.js.map +1 -1
  43. package/dist/builder/version.d.ts +3 -0
  44. package/dist/builder/version.d.ts.map +1 -0
  45. package/dist/builder/version.js +3 -0
  46. package/dist/builder/version.js.map +1 -0
  47. package/dist/index.d.ts +1 -0
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +2 -0
  50. package/dist/index.js.map +1 -1
  51. package/dist/reader/reader-css.generated.d.ts +1 -1
  52. package/dist/reader/reader-css.generated.d.ts.map +1 -1
  53. package/dist/reader/reader-css.generated.js +1 -1
  54. package/dist/reader/reader-css.generated.js.map +1 -1
  55. package/dist/reader/render.d.ts.map +1 -1
  56. package/dist/reader/render.js +71 -34
  57. package/dist/reader/render.js.map +1 -1
  58. package/dist/reader/tokens.d.ts +2 -0
  59. package/dist/reader/tokens.d.ts.map +1 -1
  60. package/dist/reader/tokens.js +28 -6
  61. package/dist/reader/tokens.js.map +1 -1
  62. package/dist/verify/get-verified-delivery.d.ts +32 -0
  63. package/dist/verify/get-verified-delivery.d.ts.map +1 -1
  64. package/dist/verify/get-verified-delivery.js +30 -5
  65. package/dist/verify/get-verified-delivery.js.map +1 -1
  66. package/dist/verify/key-set.d.ts +67 -0
  67. package/dist/verify/key-set.d.ts.map +1 -0
  68. package/dist/verify/key-set.js +190 -0
  69. package/dist/verify/key-set.js.map +1 -0
  70. package/package.json +115 -112
  71. package/src/builder/card-beacon.ts +201 -0
  72. package/src/builder/card-contract.ts +336 -0
  73. package/src/builder/card-placement.ts +208 -0
  74. package/src/builder/card-report.ts +126 -0
  75. package/src/builder/cert-review-card.tsx +285 -201
  76. package/src/builder/contract-card.tsx +216 -0
  77. package/src/builder/delivered-face.ts +18 -4
  78. package/src/builder/delivery-runtime.ts +46 -4
  79. package/src/builder/index.tsx +16 -4
  80. package/src/builder/ssr-seed.ts +37 -7
  81. package/src/builder/version.ts +2 -0
  82. package/src/index.ts +9 -0
  83. package/src/reader/reader-css.generated.ts +1 -1
  84. package/src/reader/reader.css +297 -2
  85. package/src/reader/render.ts +87 -38
  86. package/src/reader/tokens.ts +29 -6
  87. package/src/verify/get-verified-delivery.ts +54 -5
  88. package/src/verify/key-set.ts +225 -0
@@ -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,
@@ -32,9 +32,28 @@ 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
+ CARD_FALLBACK_REASONS,
49
+ type CardFallbackDetail,
50
+ type CardFallbackReason,
51
+ reportCardFallbackInBrowser,
52
+ reportCardFallbackOnServer,
53
+ } from './card-report.js'
36
54
  import type { BuilderCertChromeData, CertChromeRenderDef } from './cert-chrome-data.js'
37
55
  import { BUILDER_CERT_CHROME_KEYS } from './cert-chrome-data.js'
56
+ import { ContractCardFace } from './contract-card.js'
38
57
  import {
39
58
  type DeliveredFaceHashes,
40
59
  deliveredFaceDigest,
@@ -43,10 +62,9 @@ import {
43
62
  faceHashesFor,
44
63
  showsWholeCard,
45
64
  } from './delivered-face.js'
46
- import { cardDeliveryRuntime } from './delivery-runtime.js'
65
+ import { cardDeliveryRuntime, cardResolveKidFor } from './delivery-runtime.js'
47
66
  import { envelopeCardHtml, envelopeCardInput, isReaderFaceDef } from './envelope-face.js'
48
67
  import {
49
- type FallbackFace,
50
68
  type PageRecord,
51
69
  pageHoldsServerHtml,
52
70
  pageRecordFor,
@@ -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: 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.
108
128
  */
109
129
  clientRead?: boolean
110
130
  }
@@ -123,28 +143,6 @@ function text(value: unknown): string {
123
143
  return typeof value === 'string' ? value : ''
124
144
  }
125
145
 
126
- /** The same rule where ABSENT and EMPTY are different renders (`authorTitle`, `bio`). */
127
- function optionalText(value: unknown): string | undefined {
128
- return text(value) || undefined
129
- }
130
-
131
- /**
132
- * The fused credential pair — accepted ONLY when both halves are present AND both are strings.
133
- *
134
- * The R1 gate ("a credential never displays without its dated verification") is THIS FUNCTION,
135
- * not the type: the pair arrives as untyped CMS JSON, and the old truthiness check keyed the
136
- * whole pair off the OBJECT, so `{credential:'MD'}` forwarded `verifiedAt: undefined` into the
137
- * renderer and crashed. Anything malformed — a half pair, a bare string, an array, a number
138
- * where a date belongs — degrades to the name-only face, which is the honest render of a
139
- * delivery whose verification cannot be read.
140
- */
141
- function fusedCredential(pair: BuilderCertChromeData['credentialVerification'] | undefined) {
142
- if (!pair || typeof pair !== 'object') return null
143
- const credential = text(pair.credential)
144
- const verifiedAt = text(pair.verifiedAt)
145
- return credential && verifiedAt ? { credential, verifiedAt } : null
146
- }
147
-
148
146
  /**
149
147
  * The delivery marker, accepted only as `{ v: 1, baseUrl }` with `baseUrl` an `https:` ORIGIN on
150
148
  * `certrev.com` or one of its subdomains (no path, query, credentials or port). Anything else is
@@ -308,9 +306,13 @@ function EnvelopeFace(props: {
308
306
 
309
307
  type PhotoPerson = 'expert' | 'author'
310
308
 
311
- /** The reader face paints the author's avatar small (`.certrev-r-face.sm`); every other avatar is the expert's. */
309
+ /**
310
+ * Whose avatar a broken photo is. The banner's two bylines take the same face (1.3.1; the author's was the small
311
+ * `.certrev-r-face.sm` before), so the person is read from the byline: the author's carries the plain "Written by"
312
+ * role, the reviewer's "Reviewed by" (`.on`), and the sidebar's and the memo's reviewer has no role at all.
313
+ */
312
314
  function photoPerson(img: HTMLImageElement): PhotoPerson {
313
- return img.parentElement?.classList.contains('sm') ? 'author' : 'expert'
315
+ return img.closest('.certrev-r-by')?.querySelector('.certrev-r-role:not(.on)') ? 'author' : 'expert'
314
316
  }
315
317
 
316
318
  interface EnvelopeCardProps {
@@ -379,11 +381,14 @@ function deliveredFaceFor(
379
381
  renderDef: CertChromeRenderDef | null,
380
382
  face: DeliveredFace | undefined,
381
383
  props: EnvelopeCardProps,
384
+ /** 2.0.0: the hashes the read computed (`VerifiedDelivery.faceHashes`): then nothing suspends here. */
385
+ hashed?: DeliveredFaceHashes | null,
382
386
  ): DeliveredFace | undefined {
383
387
  if (!face || props.design || !showsWholeCard(props.part)) return undefined
384
388
  const digest = deliveredFaceDigest(payload.content, renderDef, face)
385
389
  if (!digest) return undefined
386
- return deliveredFaceMatches(suspendOnFaceHashes(face), digest) ? face : undefined
390
+ const hashes = hashed !== undefined ? hashed : suspendOnFaceHashes(face)
391
+ return deliveredFaceMatches(hashes, digest) ? face : undefined
387
392
  }
388
393
 
389
394
  /**
@@ -433,64 +438,155 @@ function paintEnvelope(
433
438
  return <EnvelopeFace html={renderCertBlock(input)} defVersion={defVersion} seed={seed} />
434
439
  }
435
440
 
436
- const FALLBACK_LAYOUTS: readonly string[] = ['banner', 'sidebar', 'floating']
441
+ /** Where the server painted no face, an empty hidden element carries the record that says so. */
442
+ function NoFace(props: { readonly record: string }) {
443
+ return <div hidden {...{ [SEED_ATTRIBUTE]: props.record }} />
444
+ }
437
445
 
438
446
  /**
439
- * The options face a block with the delivery marker paints in place of the envelope (1.2.0), or null. Its placement is
440
- * the block's own `mode`, else its def's layout, as the envelope face's is, else the banner bridge. A pinned card (the
441
- * canary replica) paints it too, so the replica shows what a brand's page shows when CertREV is not reached; its
442
- * record names the pin and says `'options'`, so a capture can never take it for the pinned design.
447
+ * 2.0.0: reports one fallback (`card-report.ts`) and renders nothing (no element, no marker: it changes no byte of the
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).
443
450
  */
444
- function fallbackFace(props: EnvelopeCardProps): string | null {
445
- const { block } = props
446
- const layout = block.renderDef?.layout
447
- const mode =
448
- typeof block.mode === 'string' && FALLBACK_LAYOUTS.includes(block.mode)
449
- ? block.mode
450
- : typeof layout === 'string' && FALLBACK_LAYOUTS.includes(layout)
451
- ? (layout as CertBlockMode)
452
- : undefined
453
- return optionsFaceHtml(block, mode)
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)
458
+ React.useEffect(() => {
459
+ reportCardFallbackInBrowser(detail, origin)
460
+ }, [detail, origin])
461
+ return null
454
462
  }
455
463
 
456
- /** The block's options face, stamped with a fallback record when the server wrote one (`ssr-seed.ts`). */
457
- function OptionsFace(props: { readonly block: CertReviewCardProps; readonly html: string; readonly record?: string }) {
464
+ /** A face with its fallback report beside it. */
465
+ function withReport(
466
+ face: React.ReactElement | null,
467
+ reason: CardFallbackReason,
468
+ props: EnvelopeCardProps,
469
+ server: boolean,
470
+ certId?: string,
471
+ status?: number,
472
+ ): React.ReactElement {
473
+ const detail: CardFallbackDetail = {
474
+ reason,
475
+ platform: 'builder',
476
+ externalId: props.externalId,
477
+ ...(certId === undefined ? {} : { certId }),
478
+ ...(status === undefined ? {} : { status }),
479
+ }
458
480
  return (
459
- <div
460
- data-certrev-cert-chrome=""
461
- // The two provenance stamps the crawl monitor reads — emitted ONLY when the delivery
462
- // carried the right type, so a mistyped option leaves the attribute off instead of
463
- // stamping "[object Object]" as a version.
464
- data-certrev-strings-version={optionalText(props.block.stringsVersion)}
465
- data-certrev-def-version={typeof props.block.renderDef?.v === 'number' ? props.block.renderDef.v : undefined}
466
- {...(props.record === undefined ? {} : { [SEED_ATTRIBUTE]: props.record })}
467
- // biome-ignore lint/security/noDangerouslySetInnerHtml: renderCertBlock is the escaping-safe SSR string renderer
468
- dangerouslySetInnerHTML={{ __html: props.html }}
469
- />
481
+ <>
482
+ {face}
483
+ <FallbackReport detail={detail} server={server} origin={props.baseUrl} />
484
+ </>
470
485
  )
471
486
  }
472
487
 
473
- /** Where the server painted no face, an empty hidden element carries the record that says so. */
474
- function NoFace(props: { readonly record: string }) {
475
- return <div hidden {...{ [SEED_ATTRIBUTE]: props.record }} />
488
+ /**
489
+ * 2.0.0: what a contract-1 (or unsupported-contract) placement prints, decided by the SIGNED binding (`card-contract.ts`):
490
+ * the card when the served document hashes to the signed digest and names this design version, certificate and
491
+ * placement; else nothing, and the reason. Suspends once while WebCrypto hashes a document it has not seen.
492
+ */
493
+ type ContractOutcome =
494
+ | { readonly kind: 'card'; readonly card: CardDocumentV1; readonly names: CardPartName[] }
495
+ | { readonly kind: 'none'; readonly reason?: CardFallbackReason }
496
+
497
+ function contractOutcome(
498
+ binding: CardBinding | 'unsupported',
499
+ rawCard: unknown,
500
+ payload: CertPayload,
501
+ props: EnvelopeCardProps,
502
+ /** The digest the read computed (`VerifiedDelivery.cardDigest`): then nothing suspends here. */
503
+ digested?: string | null,
504
+ ): ContractOutcome {
505
+ if (binding === 'unsupported') return { kind: 'none', reason: 'card_unsupported_contract' }
506
+ // CertREV's replica pin: the signed card was painted for one design version; another pin gets nothing.
507
+ if (props.design && props.design.version !== binding.defVersion) return { kind: 'none', reason: 'card_pin_mismatch' }
508
+ if (rawCard === undefined || rawCard === null) return { kind: 'none', reason: 'card_missing' }
509
+ const card = parseCardDocument(rawCard)
510
+ if (!card || !cardMatchesPlacement(card, binding, payload, props.externalId)) {
511
+ return { kind: 'none', reason: 'card_verify_failed' }
512
+ }
513
+ let digest: string | null
514
+ if (digested !== undefined) {
515
+ digest = digested
516
+ } else {
517
+ // A page's seed: the one suspension of this render (a render that read waits on nothing but its read).
518
+ const memo = cardDigestFor(card)
519
+ digest = memo.settled ? memo.settled.digest : suspendUntil(memo.pending)
520
+ }
521
+ if (!cardDigestMatches(digest, binding)) return { kind: 'none', reason: 'card_verify_failed' }
522
+ // A block with nothing of this design to print (a memo block of a memo-less card, or of a sidebar design) prints
523
+ // nothing, by design: not a fallback.
524
+ const names = cardPartsFor(card, props.part)
525
+ return names.length === 0 ? { kind: 'none' } : { kind: 'card', card, names }
526
+ }
527
+
528
+ /** The `'none'` record of a contract-1 placement, with its reason when it is a fallback. */
529
+ function contractNone(
530
+ props: EnvelopeCardProps,
531
+ reason: CardFallbackReason | undefined,
532
+ server: boolean,
533
+ certId: string,
534
+ ): React.ReactElement {
535
+ const record = serializeFallback({
536
+ baseUrl: props.baseUrl,
537
+ externalId: props.externalId,
538
+ ...(props.design ? { design: props.design } : {}),
539
+ face: 'none',
540
+ ...(reason ? { reason } : {}),
541
+ })
542
+ const face = <NoFace record={record} />
543
+ return reason ? withReport(face, reason, props, server, certId) : face
476
544
  }
477
545
 
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
481
- const face: FallbackFace = html === null ? 'none' : 'options'
546
+ /**
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.
552
+ */
553
+ function settleWithoutEnvelope(
554
+ props: EnvelopeCardProps,
555
+ verdict: { readonly unreachable: boolean; readonly reason?: string; readonly status?: number },
556
+ server: boolean,
557
+ ): React.ReactElement {
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
482
565
  const record = serializeFallback({
483
566
  baseUrl: props.baseUrl,
484
567
  externalId: props.externalId,
485
568
  ...(props.design ? { design: props.design } : {}),
486
- face,
569
+ face: 'none',
570
+ ...(reason ? { reason } : {}),
487
571
  })
488
- return html === null ? <NoFace record={record} /> : <OptionsFace block={props.block} html={html} record={record} />
572
+ const none = <NoFace record={record} />
573
+ return reason
574
+ ? withReport(none, reason, props, server, undefined, verdict.unreachable ? verdict.status : undefined)
575
+ : none
489
576
  }
490
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
+
491
587
  /**
492
588
  * Paint what the page's record for this placement says: a seed its envelope (judged at the browser's clock first when
493
- * `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.
494
590
  */
495
591
  function paintRecord(
496
592
  record: PageRecord,
@@ -506,9 +602,24 @@ function paintRecord(
506
602
  return null
507
603
  }
508
604
  }
605
+ const { seed } = record
606
+ // 2.0.0: a seed whose signed binding names a contract prints the card it carries, only after the browser has
607
+ // checked it against that binding itself; otherwise nothing. Never a 1.3.1 face for such a placement.
608
+ const binding = acceptedCardBinding(seed.payload.content)
609
+ if (binding !== null) {
610
+ const outcome = contractOutcome(binding, seed.card, seed.payload, props)
611
+ if (outcome.kind === 'card')
612
+ return <ContractCardFace card={outcome.card} names={outcome.names} seed={record.raw} origin={props.baseUrl} />
613
+ // Hydrating over the card the server printed, which the browser could not confirm (no WebCrypto): adopt the
614
+ // server's element; the render right after hydration prints nothing in its place.
615
+ if (!judge && outcome.reason === 'card_verify_failed' && seed.card !== undefined)
616
+ return <ServerFaceUntilHydrated />
617
+ // Nothing of this design for this block (a memo block of a sidebar card): nothing, and no record of its own.
618
+ if (outcome.reason === undefined) return null
619
+ return contractNone(props, outcome.reason, false, seed.payload.certId)
620
+ }
509
621
  // 1.3.0: a seed that names a delivered face repaints it only after the browser hashes it against the seed
510
622
  // payload's own digest; otherwise the seed paints what 1.2.1 paints for it.
511
- const { seed } = record
512
623
  const delivered = deliveredFaceFor(seed.payload, seed.renderDef, seed.face, props)
513
624
  if (delivered && seed.renderDef) {
514
625
  return <DeliveredEnvelopeFace face={delivered} defVersion={seed.renderDef.v} seed={record.raw} />
@@ -517,18 +628,21 @@ function paintRecord(
517
628
  if (seed.face && !judge) return <ServerFaceUntilHydrated />
518
629
  return paintEnvelope(seed.payload, seed.renderDef, props, record.raw)
519
630
  }
520
- 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
- )
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)
634
+ // 2.0.0: a contract-1 placement's record names why it printed nothing; the browser reports it too.
635
+ const reason = record.fallback.reason
636
+ return isFallbackReason(reason) ? withReport(none, reason, props, false) : none
637
+ }
638
+
639
+ function isFallbackReason(value: unknown): value is CardFallbackReason {
640
+ return typeof value === 'string' && (CARD_FALLBACK_REASONS as readonly string[]).includes(value)
526
641
  }
527
642
 
528
- /** What a card the client mounts with no record and no `clientRead` paints: the options face, or nothing. */
529
- function clientMountFace(props: EnvelopeCardProps): React.ReactElement | null {
530
- const html = fallbackFace(props)
531
- 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)
532
646
  }
533
647
 
534
648
  const pendingRecord = () => 'pending' as const
@@ -549,8 +663,9 @@ function StreamedRecordCard(props: EnvelopeCardProps) {
549
663
  pendingRecord,
550
664
  )
551
665
  if (record === 'pending') return null
552
- if (record === 'done') return clientMountFace(props)
553
666
  const context = { platform: 'builder', externalId: props.externalId, now: new Date(rt.now()) }
667
+ // 2.0.0: the page loaded with no record for this placement: with `clientRead` (the default), the one read.
668
+ if (record === 'done') return props.clientRead ? readAndPaint(props, context, true) : clientMountFace(props)
554
669
  return paintRecord(record, props, context, true)
555
670
  }
556
671
 
@@ -581,23 +696,24 @@ function useHydrating(): boolean {
581
696
  * as through 1.1.1, and write a record of what it painted (`ssr-seed.ts`):
582
697
  * - a verified envelope: its face and the envelope seed (less the fields the def hides, and with no signature if
583
698
  * it hid any);
584
- * - CertREV not reached (a timeout, a network error, a 5xx, a body that is not JSON): the block's own options face,
585
- * 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);
586
702
  * - a definitive no (revoked, expired, unverifiable, for another entry, not found): nothing, and a `'none'` record
587
703
  * on an empty hidden element.
588
704
  * - Browser, the page carries this placement's record: paint what it says. A seed paints its envelope; while
589
705
  * hydrating the server's verdict stands (the tree is the server's, byte for byte), and once hydrated the lifecycle
590
- * and subject are judged at the browser's clock, so a card that expired since the server render is taken down. An
591
- * `'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
592
708
  * again on the client after a hydration error elsewhere on the page: the record is still in the document (or in
593
709
  * the page's memory of it), so the browser repaints the server's answer and reads nothing.
594
710
  * - Browser, hydrating with no record: server HTML from before 1.2.0 painted nothing here, so nothing.
595
711
  * - Browser, a card mounted on the client with no record (a client-side navigation to an article this page never
596
- * server-rendered, Builder's editor): the block's options face, pinned or not. With `clientRead`, instead, the
597
- * 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).
598
714
  * - Browser, the same while the document is still loading (1.2.1; React rendered the card on the client before the
599
- * server's streamed record arrived, `ssr-seed.ts`): nothing until the record arrives, then what it says; the
600
- * 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.
601
717
  *
602
718
  * WHICH OF THE TWO FIRST CASES THIS RENDER IS (server or browser) is decided from what the render can observe, with
603
719
  * no switch a test could set. A server render and a hydration are both "hydrating" to React and both see a `document`
@@ -631,8 +747,9 @@ function EnvelopeCertReviewCard(props: EnvelopeCardProps) {
631
747
  // 1.2.1, latched at mount: a client mount with no record and no `clientRead` while the page is still loading waits
632
748
  // for a record the server may still be streaming, for the card's whole life (so it never swaps components when the
633
749
  // record lands).
750
+ // 2.0.0: whatever `clientRead` says: a card that read instead of waiting would miss the record the server streams.
634
751
  const [awaitsStream] = React.useState(
635
- () => inBrowser && !hydrating && fromPage === undefined && !props.clientRead && document.readyState === 'loading',
752
+ () => inBrowser && !hydrating && fromPage === undefined && document.readyState === 'loading',
636
753
  )
637
754
  if (awaitsStream) return <StreamedRecordCard {...props} />
638
755
  if (inBrowser) {
@@ -640,6 +757,19 @@ function EnvelopeCertReviewCard(props: EnvelopeCardProps) {
640
757
  if (hydratedMount) return null
641
758
  if (!props.clientRead) return clientMountFace(props)
642
759
  }
760
+ return readAndPaint(props, context, inBrowser)
761
+ }
762
+
763
+ /**
764
+ * The Delivery read and its paint: a server render's, or a browser's (`clientRead`). Suspends on the read, and (1.3.0)
765
+ * on a delivered face's hashes or (2.0.0) on a card document's digest.
766
+ */
767
+ function readAndPaint(
768
+ props: EnvelopeCardProps,
769
+ context: Parameters<typeof renderVerdict>[1],
770
+ inBrowser: boolean,
771
+ ): React.ReactElement | null {
772
+ const rt = cardDeliveryRuntime()
643
773
  const { design } = props
644
774
  const read = {
645
775
  baseUrl: props.baseUrl,
@@ -648,7 +778,7 @@ function EnvelopeCertReviewCard(props: EnvelopeCardProps) {
648
778
  context,
649
779
  cache: rt.cache,
650
780
  design,
651
- resolveKid: rt.resolveKid,
781
+ resolveKid: cardResolveKidFor(rt, props.baseUrl),
652
782
  fetchImpl: rt.fetchImpl(),
653
783
  timeoutMs: rt.timeoutMs,
654
784
  }
@@ -656,7 +786,17 @@ function EnvelopeCertReviewCard(props: EnvelopeCardProps) {
656
786
  ? `${props.baseUrl}|${props.externalId}|design=${design.version}|${design.brand ?? ''}`
657
787
  : `${props.baseUrl}|${props.externalId}`
658
788
  const settled = readVerifiedDelivery(read) ?? suspendOn(key, () => getVerifiedDelivery(read))
659
- if (settled.verdict.decision !== 'render') return settleWithoutEnvelope(props, settled.unreachable === true)
789
+ if (settled.verdict.decision !== 'render') {
790
+ return settleWithoutEnvelope(
791
+ props,
792
+ {
793
+ unreachable: settled.unreachable === true,
794
+ reason: settled.verdict.reason,
795
+ ...(settled.status === undefined ? {} : { status: settled.status }),
796
+ },
797
+ !inBrowser,
798
+ )
799
+ }
660
800
  // 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
801
  // the browser's, which paints from the seed, are the same bytes and neither holds a hidden field.
662
802
  const seed = seedFor({
@@ -667,9 +807,29 @@ function EnvelopeCertReviewCard(props: EnvelopeCardProps) {
667
807
  signature: settled.signature,
668
808
  renderDef: settled.renderDef,
669
809
  })
810
+ // 2.0.0: a placement whose signed binding names a contract prints CertREV's card, or nothing; never a face of its own.
811
+ const binding = acceptedCardBinding(seed.payload.content)
812
+ if (binding !== null) {
813
+ const outcome = contractOutcome(binding, settled.card, seed.payload, props, settled.cardDigest)
814
+ if (outcome.kind === 'card') {
815
+ return (
816
+ <ContractCardFace
817
+ card={outcome.card}
818
+ names={outcome.names}
819
+ seed={serializeSeed({ ...seed, card: outcome.card as unknown as Readonly<Record<string, unknown>> })}
820
+ origin={props.baseUrl}
821
+ />
822
+ )
823
+ }
824
+ // Nothing of this design for this block, by design: nothing at all, and NO record. A record here would sit under the
825
+ // same placement as the block that printed the card (a split header and memo pair) and, read last on hydration,
826
+ // would take that block's card down (break-it B2).
827
+ if (outcome.reason === undefined) return null
828
+ return contractNone(props, outcome.reason, !inBrowser, seed.payload.certId)
829
+ }
670
830
  // 1.3.0: the server-rendered face the Delivery API served beside the envelope, when its bytes hash to the digest the
671
831
  // 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)
832
+ const delivered = deliveredFaceFor(seed.payload, seed.renderDef, settled.face, props, settled.faceHashes)
673
833
  if (delivered && seed.renderDef) {
674
834
  return (
675
835
  <DeliveredEnvelopeFace
@@ -683,116 +843,40 @@ function EnvelopeCertReviewCard(props: EnvelopeCardProps) {
683
843
  }
684
844
 
685
845
  export function CertReviewCard(props: CertReviewCardProps) {
686
- // 1.1.0: a block that carries the delivery marker paints from the signed envelope and the
687
- // brand's def, never from the options beside it. No marker (every entry exported before 1.1.0)
688
- // takes the options path below, unchanged from 1.0.3.
689
- // 1.2.0-canary.5: a design pin (CertREV's canary replica only) fails closed. One that cannot be read renders
690
- // nothing, and so does one on a block without the delivery marker: the options face is never the pinned design.
691
- // 1.2.0: where the envelope cannot be had (CertREV not reached, or no server render and no `clientRead`), a block
692
- // 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.
693
852
  const design = parseDesignPin(props.builderContext?.context?.certrevDesignPin)
694
853
  if (design === 'invalid') return null
695
- if (props.delivery != null) {
696
- const marker = acceptedDeliveryMarker(props.delivery)
697
- const entryId = text(props.builderContext?.content?.id)
698
- if (!marker || !entryId) return null
699
- return (
700
- <Suspense fallback={null}>
701
- <EnvelopeCertReviewCard
702
- baseUrl={marker.baseUrl}
703
- externalId={entryId}
704
- mode={props.mode}
705
- part={props.part}
706
- design={design}
707
- block={props}
708
- clientRead={props.clientRead === true}
709
- />
710
- </Suspense>
711
- )
712
- }
713
- if (design) return null
714
-
715
- const html = optionsFaceHtml(props, 'banner')
716
- if (html === null) return null // no chrome pre-cert (or an unreadable delivery)
717
- 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
+ )
718
871
  }
719
872
 
720
- /**
721
- * The options face's HTML (1.0.3's, unchanged), or null when the block lacks a reviewer or a certificate link. `bridge`
722
- * is the placement a block with no `mode` gets: `'banner'` for a block without the delivery marker (the bridge below);
723
- * the def's layout for a marked block's fallback (`fallbackFace`).
724
- */
725
- function optionsFaceHtml(props: CertReviewCardProps, bridge: CertBlockMode | undefined): string | null {
726
- const reviewerName = text(props.reviewerName)
727
- const verifyUrl = text(props.verifyUrl)
728
- if (!reviewerName || !verifyUrl) return null
729
-
730
- // Name-only face when the pair is unreadable, or when the brand set showCredentials:false.
731
- const showCred = props.display?.showCredentials !== false
732
- const withCred = showCred ? fusedCredential(props.credentialVerification) : null
733
-
734
- const tokens = props.renderDef?.tokens
735
- const theme: Partial<ResolvedBlockTheme> | undefined = tokens
736
- ? {
737
- accentColor: optionalText(tokens.accentColor),
738
- surface: optionalText(tokens.surface),
739
- cornerRadius: optionalText(tokens.cornerRadius),
740
- fontSlot: optionalText(tokens.fontSlot) as ResolvedBlockTheme['fontSlot'] | undefined,
741
- // v2 brand-ink (post-demo): the bar + free-content ink follow the def.
742
- barInk: optionalText(tokens.barInk),
743
- inkColor: optionalText(tokens.inkColor),
744
- }
745
- : undefined
746
-
747
- return renderCertBlock({
748
- // COMPATIBILITY BRIDGE, NOT THE DESIGN. Delete this fallback once the exporter emits an
749
- // explicit `mode` and existing entries have been backfilled; until then it must stay.
750
- //
751
- // `renderCertBlock` owns the ONE placement default (absent or unrecognised ⇒ sidebar) and
752
- // that decision STANDS: it is what a direct caller gets, and `sidebar` is the registered
753
- // input's `defaultValue`, so every block created in the editor from now on carries an
754
- // explicit `mode` and never reaches this line.
755
- //
756
- // The problem is the entries that already exist. `mode` is a PLACEMENT input, not one of
757
- // the 20 wire keys (`BUILDER_CERT_CHROME_KEYS`), and the exporter emits only the wire
758
- // projection (`options: { ...certData }`), so every entry exported to date has NO `mode`
759
- // key at all. Through 0.5.5 this line read `?? 'banner'` and those entries rendered the
760
- // banner face. Removing it in 1.0.0 did not give them the renderer's default in the
761
- // abstract; it silently repainted live pages from banner to sidebar, with no entry edit
762
- // and no version pin to protect them, because their install line is unpinned.
763
- //
764
- // So this is a bridge for pre-existing entries, NOT a reversal of the sidebar default.
765
- // The three-copies-of-a-default problem that motivated removing it is still real and is
766
- // still fixed: the helper text and the wire type's doc comment no longer name a default,
767
- // and this line names one only for input the renderer can no longer distinguish from a
768
- // deliberate choice. The exporter writing `mode` is what retires it.
769
- mode: props.mode ?? bridge ?? 'banner',
770
- theme,
771
- // First-class banner memo split — renderCertBlock composes the requested
772
- // part directly; no string-splitting. Ignored outside banner mode, and an unrecognised
773
- // value is normalised to 'full' there, same as `mode` — not here.
774
- ...(props.part ? { part: props.part } : {}),
775
- facts: {
776
- authorName: optionalText(props.authorName) ?? 'Editorial',
777
- authorTitle: optionalText(props.authorTitle),
778
- reviewerName,
779
- credential: withCred?.credential ?? '',
780
- credentialVerifiedAt: withCred?.verifiedAt ?? '',
781
- certifiedAt: text(props.certifiedAt),
782
- memo: text(props.memo),
783
- bio: optionalText(props.bio),
784
- profileUrl: text(props.reviewerProfileUrl),
785
- certificateUrl: verifyUrl,
786
- // The compliance disclosures are CONSTANTS the renderer owns — a delivered cue is
787
- // IGNORED (an editor cannot retype the FTC copy into something friendlier), and the
788
- // scope line is not forwarded at all. The ONE thing honored is an explicit `null`:
789
- // the pro-bono reviewer has no compensation to disclose, so the cue is omitted
790
- // rather than replaced by the compensated constant (the modal already gets this
791
- // right — a card claiming "Compensated expert" over a volunteer is an FTC-facing
792
- // falsehood, not a cosmetic default).
793
- compensationCue: props.compensationCue === null ? null : undefined,
794
- },
795
- })
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'} />
796
880
  }
797
881
 
798
882
  // ─────────────────────────────────────────────────────────────────────────────