@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.
- package/CHANGELOG.md +68 -0
- package/README.md +45 -8
- package/dist/builder/card-beacon.d.ts +73 -0
- package/dist/builder/card-beacon.d.ts.map +1 -0
- package/dist/builder/card-beacon.js +178 -0
- package/dist/builder/card-beacon.js.map +1 -0
- package/dist/builder/card-contract.d.ts +10 -2
- package/dist/builder/card-contract.d.ts.map +1 -1
- package/dist/builder/card-contract.js +27 -3
- package/dist/builder/card-contract.js.map +1 -1
- package/dist/builder/card-placement.d.ts +45 -0
- package/dist/builder/card-placement.d.ts.map +1 -0
- package/dist/builder/card-placement.js +193 -0
- package/dist/builder/card-placement.js.map +1 -0
- package/dist/builder/card-report.d.ts +26 -14
- package/dist/builder/card-report.d.ts.map +1 -1
- package/dist/builder/card-report.js +68 -14
- package/dist/builder/card-report.js.map +1 -1
- package/dist/builder/cert-review-card.d.ts +4 -4
- package/dist/builder/cert-review-card.d.ts.map +1 -1
- package/dist/builder/cert-review-card.js +77 -189
- package/dist/builder/cert-review-card.js.map +1 -1
- package/dist/builder/contract-card.d.ts +18 -1
- package/dist/builder/contract-card.d.ts.map +1 -1
- package/dist/builder/contract-card.js +60 -10
- package/dist/builder/contract-card.js.map +1 -1
- package/dist/builder/delivery-runtime.d.ts +13 -3
- package/dist/builder/delivery-runtime.d.ts.map +1 -1
- package/dist/builder/delivery-runtime.js +17 -4
- package/dist/builder/delivery-runtime.js.map +1 -1
- package/dist/builder/index.d.ts +4 -4
- package/dist/builder/index.js +4 -4
- package/dist/builder/ssr-seed.d.ts +5 -4
- package/dist/builder/ssr-seed.d.ts.map +1 -1
- package/dist/builder/ssr-seed.js +4 -3
- package/dist/builder/ssr-seed.js.map +1 -1
- package/dist/builder/version.d.ts +3 -0
- package/dist/builder/version.d.ts.map +1 -0
- package/dist/builder/version.js +3 -0
- package/dist/builder/version.js.map +1 -0
- package/dist/verify/get-verified-delivery.d.ts +4 -0
- package/dist/verify/get-verified-delivery.d.ts.map +1 -1
- package/dist/verify/get-verified-delivery.js +9 -3
- package/dist/verify/get-verified-delivery.js.map +1 -1
- package/package.json +1 -1
- package/src/builder/card-beacon.ts +201 -0
- package/src/builder/card-contract.ts +25 -3
- package/src/builder/card-placement.ts +208 -0
- package/src/builder/card-report.ts +79 -14
- package/src/builder/cert-review-card.tsx +100 -230
- package/src/builder/contract-card.tsx +65 -8
- package/src/builder/delivery-runtime.ts +17 -4
- package/src/builder/index.tsx +4 -4
- package/src/builder/ssr-seed.ts +5 -4
- package/src/builder/version.ts +2 -0
- 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
|
|
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:
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
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: {
|
|
510
|
-
|
|
511
|
-
|
|
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:
|
|
600
|
-
* the
|
|
601
|
-
*
|
|
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
|
|
609
|
-
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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`),
|
|
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' &&
|
|
640
|
+
return typeof value === 'string' && (CARD_FALLBACK_REASONS as readonly string[]).includes(value)
|
|
700
641
|
}
|
|
701
642
|
|
|
702
|
-
/**
|
|
703
|
-
function clientMountFace(props: EnvelopeCardProps): React.ReactElement
|
|
704
|
-
|
|
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)
|
|
760
|
-
*
|
|
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.
|
|
766
|
-
* `'
|
|
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):
|
|
772
|
-
*
|
|
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
|
-
*
|
|
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
|
-
{
|
|
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
|
-
//
|
|
902
|
-
//
|
|
903
|
-
//
|
|
904
|
-
//
|
|
905
|
-
// 1.2.0:
|
|
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
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
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
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
136
|
-
|
|
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={
|
|
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={
|
|
213
|
+
<style data-certrev-builder-flow="" dangerouslySetInnerHTML={BUILDER_CARD_FLOW_HTML} />
|
|
157
214
|
</>
|
|
158
215
|
)
|
|
159
216
|
}
|