@certrev/cert-block 2.0.0 → 2.0.2
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 +110 -0
- package/README.md +50 -10
- 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 +6 -4
- package/dist/builder/cert-review-card.d.ts.map +1 -1
- package/dist/builder/cert-review-card.js +172 -199
- 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 +38 -4
- package/dist/builder/delivery-runtime.d.ts.map +1 -1
- package/dist/builder/delivery-runtime.js +43 -5
- 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/cache.d.ts +24 -0
- package/dist/verify/cache.d.ts.map +1 -1
- package/dist/verify/cache.js +78 -6
- package/dist/verify/cache.js.map +1 -1
- package/dist/verify/get-verified-delivery.d.ts +18 -0
- package/dist/verify/get-verified-delivery.d.ts.map +1 -1
- package/dist/verify/get-verified-delivery.js +30 -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 +208 -238
- package/src/builder/contract-card.tsx +65 -8
- package/src/builder/delivery-runtime.ts +45 -5
- 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/cache.ts +78 -6
- package/src/verify/get-verified-delivery.ts +40 -3
|
@@ -22,14 +22,17 @@ 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,
|
|
29
29
|
type DesignPin,
|
|
30
|
+
deliveryLoading,
|
|
30
31
|
getVerifiedDelivery,
|
|
31
32
|
parseDesignPin,
|
|
32
33
|
readVerifiedDelivery,
|
|
34
|
+
rememberDeliveryUnreachable,
|
|
35
|
+
unreachableDelivery,
|
|
33
36
|
type VerifiedDelivery,
|
|
34
37
|
} from '../verify/get-verified-delivery.js'
|
|
35
38
|
import {
|
|
@@ -45,6 +48,7 @@ import {
|
|
|
45
48
|
} from './card-contract.js'
|
|
46
49
|
import { CARD_READER_CSS } from './card-reader-css.js'
|
|
47
50
|
import {
|
|
51
|
+
CARD_FALLBACK_REASONS,
|
|
48
52
|
type CardFallbackDetail,
|
|
49
53
|
type CardFallbackReason,
|
|
50
54
|
reportCardFallbackInBrowser,
|
|
@@ -64,7 +68,6 @@ import {
|
|
|
64
68
|
import { cardDeliveryRuntime, cardResolveKidFor } from './delivery-runtime.js'
|
|
65
69
|
import { envelopeCardHtml, envelopeCardInput, isReaderFaceDef } from './envelope-face.js'
|
|
66
70
|
import {
|
|
67
|
-
type FallbackFace,
|
|
68
71
|
type PageRecord,
|
|
69
72
|
pageHoldsServerHtml,
|
|
70
73
|
pageRecordFor,
|
|
@@ -121,10 +124,10 @@ export interface CertReviewCardProps extends Partial<BuilderCertChromeData> {
|
|
|
121
124
|
* A Delivery read FROM THE BROWSER for a card the server did not render (a client-side navigation, Builder's editor).
|
|
122
125
|
* 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
126
|
* 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
|
-
*
|
|
127
|
+
* 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
|
|
128
|
+
* reported `delivery_unreachable` (2.0.1; 2.0.0 painted the block's options face). `false`: no request from the
|
|
129
|
+
* browser, and such a card prints nothing and reports `client_read_off`. Not a Builder input: a brand sets it in code,
|
|
130
|
+
* on a wrapper of this component.
|
|
128
131
|
*/
|
|
129
132
|
clientRead?: boolean
|
|
130
133
|
}
|
|
@@ -143,28 +146,6 @@ function text(value: unknown): string {
|
|
|
143
146
|
return typeof value === 'string' ? value : ''
|
|
144
147
|
}
|
|
145
148
|
|
|
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
149
|
/**
|
|
169
150
|
* The delivery marker, accepted only as `{ v: 1, baseUrl }` with `baseUrl` an `https:` ORIGIN on
|
|
170
151
|
* `certrev.com` or one of its subdomains (no path, query, credentials or port). Anything else is
|
|
@@ -193,17 +174,82 @@ export function acceptedDeliveryMarker(value: unknown): { readonly baseUrl: stri
|
|
|
193
174
|
* flight; React 18 (no `use`) gets the promise thrown, the Suspense protocol it supports. Either
|
|
194
175
|
* way the retry finds the settled read in the cache through `readVerifiedDelivery`, so it never
|
|
195
176
|
* suspends on a second promise.
|
|
177
|
+
*
|
|
178
|
+
* 2.0.2, on a server (`suspendOnServer`): the render waits at most the runtime's `serverWaitMs` (300 ms). Past it, the
|
|
179
|
+
* placement's failure is remembered in the cache (so every render in the failure window answers at once, even where the
|
|
180
|
+
* runtime abandons the read when the response ends), the render prints nothing (`delivery_unreachable`), and the read
|
|
181
|
+
* runs on: when it lands, the next render paints it. A render that finds a read of the placement already running that it
|
|
182
|
+
* did not start (another request's, a background refresh, or a second card of the placement on the same page: React
|
|
183
|
+
* gives a component no way to tell these apart) never waits on it: it is `'skipped'` and prints a marker only
|
|
184
|
+
* (`SkippedRead`), which the same card instance re-renders while hydrating and replaces with the page's verified seed
|
|
185
|
+
* right after.
|
|
196
186
|
*/
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
187
|
+
/** The reads a render is suspended on, per runtime cache (a fresh runtime, a fresh page in a test, starts with none). */
|
|
188
|
+
const inflightReadsByCache = new WeakMap<object, Map<string, Promise<VerifiedDelivery>>>()
|
|
189
|
+
|
|
190
|
+
function suspendOn(cache: object, key: string, start: () => Promise<VerifiedDelivery>): VerifiedDelivery {
|
|
191
|
+
let reads = inflightReadsByCache.get(cache)
|
|
192
|
+
if (!reads) {
|
|
193
|
+
reads = new Map()
|
|
194
|
+
inflightReadsByCache.set(cache, reads)
|
|
195
|
+
}
|
|
196
|
+
let pending = reads.get(key)
|
|
200
197
|
if (!pending) {
|
|
201
|
-
|
|
202
|
-
|
|
198
|
+
const own = reads
|
|
199
|
+
pending = start().finally(() => own.delete(key))
|
|
200
|
+
reads.set(key, pending)
|
|
203
201
|
}
|
|
204
202
|
return suspendUntil(pending)
|
|
205
203
|
}
|
|
206
204
|
|
|
205
|
+
function suspendOnServer(
|
|
206
|
+
key: string,
|
|
207
|
+
read: Parameters<typeof getVerifiedDelivery>[0],
|
|
208
|
+
waitMs: number | undefined,
|
|
209
|
+
): VerifiedDelivery | 'skipped' {
|
|
210
|
+
if (deliveryLoading(read)) return 'skipped'
|
|
211
|
+
return suspendOn(read.cache ?? inflightReadsByCache, key, () => {
|
|
212
|
+
const loaded = getVerifiedDelivery(read)
|
|
213
|
+
if (waitMs === undefined || !Number.isFinite(waitMs) || waitMs <= 0) return loaded
|
|
214
|
+
return new Promise<VerifiedDelivery>((resolve) => {
|
|
215
|
+
const timer = setTimeout(() => {
|
|
216
|
+
rememberDeliveryUnreachable(read)
|
|
217
|
+
resolve(unreachableDelivery())
|
|
218
|
+
}, waitMs)
|
|
219
|
+
loaded.then(
|
|
220
|
+
(settled) => {
|
|
221
|
+
clearTimeout(timer)
|
|
222
|
+
resolve(settled)
|
|
223
|
+
},
|
|
224
|
+
() => {
|
|
225
|
+
clearTimeout(timer)
|
|
226
|
+
resolve(unreachableDelivery())
|
|
227
|
+
},
|
|
228
|
+
)
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** 2.0.2: the attribute a skipped server render's marker carries: the card instance's `useId`. */
|
|
234
|
+
export const SKIPPED_ATTRIBUTE = 'data-certrev-skipped'
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* 2.0.2: what a server render prints when it did not wait for a read it did not start: a hidden marker naming the card
|
|
238
|
+
* instance, and no record (it knows nothing about the placement, so it must never stand in for another card's record).
|
|
239
|
+
* Not a failure: nothing is reported or remembered.
|
|
240
|
+
*/
|
|
241
|
+
function SkippedRead({ instance }: { readonly instance: string }) {
|
|
242
|
+
return <div hidden {...{ [SKIPPED_ATTRIBUTE]: instance }} />
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** 2.0.2: whether the live document holds this card instance's skipped marker (it is hydrating one). */
|
|
246
|
+
function skippedInDocument(instance: string): boolean {
|
|
247
|
+
for (const el of Array.from(document.querySelectorAll(`[${SKIPPED_ATTRIBUTE}]`))) {
|
|
248
|
+
if (el.getAttribute(SKIPPED_ATTRIBUTE) === instance) return true
|
|
249
|
+
}
|
|
250
|
+
return false
|
|
251
|
+
}
|
|
252
|
+
|
|
207
253
|
/** `use` where React has it (19), else the thrown promise (18). */
|
|
208
254
|
function suspendUntil<T>(pending: Promise<T>): T {
|
|
209
255
|
const use = (React as unknown as { use?: <U>(p: Promise<U>) => U }).use
|
|
@@ -460,43 +506,6 @@ function paintEnvelope(
|
|
|
460
506
|
return <EnvelopeFace html={renderCertBlock(input)} defVersion={defVersion} seed={seed} />
|
|
461
507
|
}
|
|
462
508
|
|
|
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
509
|
/** Where the server painted no face, an empty hidden element carries the record that says so. */
|
|
501
510
|
function NoFace(props: { readonly record: string }) {
|
|
502
511
|
return <div hidden {...{ [SEED_ATTRIBUTE]: props.record }} />
|
|
@@ -504,14 +513,19 @@ function NoFace(props: { readonly record: string }) {
|
|
|
504
513
|
|
|
505
514
|
/**
|
|
506
515
|
* 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.
|
|
516
|
+
* 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
|
|
517
|
+
* `origin`, the delivery origin the block reads (none: CertREV's production origin).
|
|
508
518
|
*/
|
|
509
|
-
function FallbackReport(props: {
|
|
510
|
-
|
|
511
|
-
|
|
519
|
+
function FallbackReport(props: {
|
|
520
|
+
readonly detail: CardFallbackDetail
|
|
521
|
+
readonly server: boolean
|
|
522
|
+
readonly origin?: string
|
|
523
|
+
}) {
|
|
524
|
+
const { detail, server, origin } = props
|
|
525
|
+
if (server) reportCardFallbackOnServer(detail, origin)
|
|
512
526
|
React.useEffect(() => {
|
|
513
|
-
reportCardFallbackInBrowser(detail)
|
|
514
|
-
}, [detail])
|
|
527
|
+
reportCardFallbackInBrowser(detail, origin)
|
|
528
|
+
}, [detail, origin])
|
|
515
529
|
return null
|
|
516
530
|
}
|
|
517
531
|
|
|
@@ -522,17 +536,19 @@ function withReport(
|
|
|
522
536
|
props: EnvelopeCardProps,
|
|
523
537
|
server: boolean,
|
|
524
538
|
certId?: string,
|
|
539
|
+
status?: number,
|
|
525
540
|
): React.ReactElement {
|
|
526
541
|
const detail: CardFallbackDetail = {
|
|
527
542
|
reason,
|
|
528
543
|
platform: 'builder',
|
|
529
544
|
externalId: props.externalId,
|
|
530
545
|
...(certId === undefined ? {} : { certId }),
|
|
546
|
+
...(status === undefined ? {} : { status }),
|
|
531
547
|
}
|
|
532
548
|
return (
|
|
533
549
|
<>
|
|
534
550
|
{face}
|
|
535
|
-
<FallbackReport detail={detail} server={server} />
|
|
551
|
+
<FallbackReport detail={detail} server={server} origin={props.baseUrl} />
|
|
536
552
|
</>
|
|
537
553
|
)
|
|
538
554
|
}
|
|
@@ -596,40 +612,49 @@ function contractNone(
|
|
|
596
612
|
}
|
|
597
613
|
|
|
598
614
|
/**
|
|
599
|
-
* What a card with no verified envelope paints:
|
|
600
|
-
* the
|
|
601
|
-
*
|
|
615
|
+
* What a card with no verified envelope paints: nothing, and a `'none'` record. 2.0.1: CertREV not reached prints
|
|
616
|
+
* nothing too (it painted the block's export-time options through 2.0.0), and the record names `delivery_unreachable`
|
|
617
|
+
* so the browser reports it as well (with CertREV's HTTP status when it answered with one); an unknown kid is reported as
|
|
618
|
+
* `unknown_key`, and (2.0.1) an envelope that does not verify as `envelope_verify_failed`. A definitive no (revoked,
|
|
619
|
+
* expired, for another entry, not found) prints nothing, by design, unreported.
|
|
602
620
|
*/
|
|
603
621
|
function settleWithoutEnvelope(
|
|
604
622
|
props: EnvelopeCardProps,
|
|
605
|
-
verdict: { readonly unreachable: boolean; readonly reason?: string },
|
|
623
|
+
verdict: { readonly unreachable: boolean; readonly reason?: string; readonly status?: number },
|
|
606
624
|
server: boolean,
|
|
607
625
|
): React.ReactElement {
|
|
608
|
-
const
|
|
609
|
-
|
|
626
|
+
const reason: CardFallbackReason | undefined = verdict.unreachable
|
|
627
|
+
? 'delivery_unreachable'
|
|
628
|
+
: verdict.reason === 'unknown_key'
|
|
629
|
+
? 'unknown_key'
|
|
630
|
+
: verdict.reason !== undefined && ENVELOPE_VERIFY_FAILURES.has(verdict.reason)
|
|
631
|
+
? 'envelope_verify_failed'
|
|
632
|
+
: undefined
|
|
610
633
|
const record = serializeFallback({
|
|
611
634
|
baseUrl: props.baseUrl,
|
|
612
635
|
externalId: props.externalId,
|
|
613
636
|
...(props.design ? { design: props.design } : {}),
|
|
614
|
-
face,
|
|
637
|
+
face: 'none',
|
|
638
|
+
...(reason ? { reason } : {}),
|
|
615
639
|
})
|
|
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
640
|
const none = <NoFace record={record} />
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
641
|
+
return reason
|
|
642
|
+
? withReport(none, reason, props, server, undefined, verdict.unreachable ? verdict.status : undefined)
|
|
643
|
+
: none
|
|
628
644
|
}
|
|
629
645
|
|
|
646
|
+
/** 2.0.1: the kernel's suppress reasons that mean the envelope itself did not verify (reported, never silent). */
|
|
647
|
+
const ENVELOPE_VERIFY_FAILURES: ReadonlySet<string> = new Set([
|
|
648
|
+
'invalid_signature',
|
|
649
|
+
'unsupported_alg',
|
|
650
|
+
'platform_mismatch',
|
|
651
|
+
'content_drift',
|
|
652
|
+
'unsupported_contract_version',
|
|
653
|
+
])
|
|
654
|
+
|
|
630
655
|
/**
|
|
631
656
|
* Paint what the page's record for this placement says: a seed its envelope (judged at the browser's clock first when
|
|
632
|
-
* `judge`),
|
|
657
|
+
* `judge`), a `'none'` record nothing, and (2.0.1) a 2.0.0 server's `'options'` record nothing too.
|
|
633
658
|
*/
|
|
634
659
|
function paintRecord(
|
|
635
660
|
record: PageRecord,
|
|
@@ -652,7 +677,7 @@ function paintRecord(
|
|
|
652
677
|
if (binding !== null) {
|
|
653
678
|
const outcome = contractOutcome(binding, seed.card, seed.payload, props)
|
|
654
679
|
if (outcome.kind === 'card')
|
|
655
|
-
return <ContractCardFace card={outcome.card} names={outcome.names} seed={record.raw} />
|
|
680
|
+
return <ContractCardFace card={outcome.card} names={outcome.names} seed={record.raw} origin={props.baseUrl} />
|
|
656
681
|
// Hydrating over the card the server printed, which the browser could not confirm (no WebCrypto): adopt the
|
|
657
682
|
// server's element; the render right after hydration prints nothing in its place.
|
|
658
683
|
if (!judge && outcome.reason === 'card_verify_failed' && seed.card !== undefined)
|
|
@@ -671,38 +696,21 @@ function paintRecord(
|
|
|
671
696
|
if (seed.face && !judge) return <ServerFaceUntilHydrated />
|
|
672
697
|
return paintEnvelope(seed.payload, seed.renderDef, props, record.raw)
|
|
673
698
|
}
|
|
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
699
|
const none = <NoFace record={record.raw} />
|
|
700
|
+
// 2.0.1: an `'options'` record (a page a 2.0.0 server rendered while CertREV was not reached) prints nothing too.
|
|
701
|
+
if (record.fallback.face === 'options') return withReport(none, 'delivery_unreachable', props, false)
|
|
684
702
|
// 2.0.0: a contract-1 placement's record names why it printed nothing; the browser reports it too.
|
|
685
703
|
const reason = record.fallback.reason
|
|
686
704
|
return isFallbackReason(reason) ? withReport(none, reason, props, false) : none
|
|
687
705
|
}
|
|
688
706
|
|
|
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
707
|
function isFallbackReason(value: unknown): value is CardFallbackReason {
|
|
699
|
-
return typeof value === 'string' &&
|
|
708
|
+
return typeof value === 'string' && (CARD_FALLBACK_REASONS as readonly string[]).includes(value)
|
|
700
709
|
}
|
|
701
710
|
|
|
702
|
-
/**
|
|
703
|
-
function clientMountFace(props: EnvelopeCardProps): React.ReactElement
|
|
704
|
-
|
|
705
|
-
return html === null ? null : <OptionsFace block={props.block} html={html} />
|
|
711
|
+
/** 2.0.1: a card the client mounts with no record and `clientRead` off prints nothing (2.0.0: the options face). */
|
|
712
|
+
function clientMountFace(props: EnvelopeCardProps): React.ReactElement {
|
|
713
|
+
return withReport(null, 'client_read_off', props, false)
|
|
706
714
|
}
|
|
707
715
|
|
|
708
716
|
const pendingRecord = () => 'pending' as const
|
|
@@ -729,6 +737,27 @@ function StreamedRecordCard(props: EnvelopeCardProps) {
|
|
|
729
737
|
return paintRecord(record, props, context, true)
|
|
730
738
|
}
|
|
731
739
|
|
|
740
|
+
/**
|
|
741
|
+
* 2.0.2 (final review R2-1): a card whose server render skipped its read. While hydrating, its marker as the server
|
|
742
|
+
* printed it. After, the record the page holds for its placement (the seed the card that did the read printed), also
|
|
743
|
+
* when that record streams in after this card hydrated; nothing while it has not arrived, and nothing if the page loads
|
|
744
|
+
* without one. It reads nothing.
|
|
745
|
+
*/
|
|
746
|
+
function SkippedThenRecord(props: EnvelopeCardProps & { readonly instance: string; readonly hydrating: boolean }) {
|
|
747
|
+
const rt = cardDeliveryRuntime()
|
|
748
|
+
// In render, not in an effect, as `StreamedRecordCard` does.
|
|
749
|
+
watchStreamedRecords()
|
|
750
|
+
const record = React.useSyncExternalStore(
|
|
751
|
+
subscribeStreamedRecords,
|
|
752
|
+
() => streamedRecordFor(props.baseUrl, props.externalId, props.design),
|
|
753
|
+
pendingRecord,
|
|
754
|
+
)
|
|
755
|
+
if (props.hydrating) return <SkippedRead instance={props.instance} />
|
|
756
|
+
if (record === 'pending' || record === 'done') return null
|
|
757
|
+
const context = { platform: 'builder', externalId: props.externalId, now: new Date(rt.now()) }
|
|
758
|
+
return paintRecord(record, props, context, true)
|
|
759
|
+
}
|
|
760
|
+
|
|
732
761
|
const subscribeNever = () => () => {}
|
|
733
762
|
|
|
734
763
|
/**
|
|
@@ -752,27 +781,28 @@ function useHydrating(): boolean {
|
|
|
752
781
|
* storefront whose CSP `connect-src` does not name CertREV (e.l.f.'s live policy) never sees a request or a violation
|
|
753
782
|
* from it, and a page never loses its card for want of one.
|
|
754
783
|
*
|
|
755
|
-
* - Server: read (
|
|
784
|
+
* - Server: read (2.0.2: waiting at most `CARD_SERVER_WAIT_MS`, never behind another request's read; a fresh or stale-while-revalidating cache, or a remembered failure, first), verify and paint
|
|
756
785
|
* as through 1.1.1, and write a record of what it painted (`ssr-seed.ts`):
|
|
757
786
|
* - a verified envelope: its face and the envelope seed (less the fields the def hides, and with no signature if
|
|
758
787
|
* it hid any);
|
|
759
|
-
* - CertREV not reached (a timeout, a network error, a 5xx, a body that is not JSON)
|
|
760
|
-
*
|
|
788
|
+
* - CertREV not reached (a timeout, a network error, a 5xx, a body that is not JSON) with no verified answer inside
|
|
789
|
+
* the stale window: nothing, and a `'none'` record naming `delivery_unreachable` (2.0.1; through 2.0.0 the
|
|
790
|
+
* block's own options face and an `'options'` record);
|
|
761
791
|
* - a definitive no (revoked, expired, unverifiable, for another entry, not found): nothing, and a `'none'` record
|
|
762
792
|
* on an empty hidden element.
|
|
763
793
|
* - Browser, the page carries this placement's record: paint what it says. A seed paints its envelope; while
|
|
764
794
|
* 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
|
-
* `'
|
|
795
|
+
* and subject are judged at the browser's clock, so a card that expired since the server render is taken down. A
|
|
796
|
+
* `'none'` record, and (2.0.1) a 2.0.0 server's `'options'` record, print nothing. This holds when React renders the card
|
|
767
797
|
* again on the client after a hydration error elsewhere on the page: the record is still in the document (or in
|
|
768
798
|
* the page's memory of it), so the browser repaints the server's answer and reads nothing.
|
|
769
799
|
* - Browser, hydrating with no record: server HTML from before 1.2.0 painted nothing here, so nothing.
|
|
770
800
|
* - 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
|
-
*
|
|
801
|
+
* server-rendered, Builder's editor): with `clientRead` (the default), the one read, settled exactly as on the
|
|
802
|
+
* server; with `clientRead={false}`, nothing (`client_read_off`; 2.0.0: the block's options face).
|
|
773
803
|
* - 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
|
-
*
|
|
804
|
+
* server's streamed record arrived, `ssr-seed.ts`): nothing until the record arrives, then what it says; if the page
|
|
805
|
+
* loads without one, what a client mount does (`StreamedRecordCard`). `clientRead` reads as above.
|
|
776
806
|
*
|
|
777
807
|
* WHICH OF THE TWO FIRST CASES THIS RENDER IS (server or browser) is decided from what the render can observe, with
|
|
778
808
|
* no switch a test could set. A server render and a hydration are both "hydrating" to React and both see a `document`
|
|
@@ -792,6 +822,8 @@ function useHydrating(): boolean {
|
|
|
792
822
|
function EnvelopeCertReviewCard(props: EnvelopeCardProps) {
|
|
793
823
|
const rt = cardDeliveryRuntime()
|
|
794
824
|
const hydrating = useHydrating()
|
|
825
|
+
// 2.0.2: this card instance's id, the same on the server and while hydrating (`SkippedRead`).
|
|
826
|
+
const instance = React.useId()
|
|
795
827
|
// Latched at mount: this instance hydrated server HTML (as opposed to a client mount).
|
|
796
828
|
const [hydratedMount] = React.useState(hydrating)
|
|
797
829
|
// The clock is read per render, so the lifecycle is judged NOW, not when the envelope was fetched.
|
|
@@ -810,13 +842,16 @@ function EnvelopeCertReviewCard(props: EnvelopeCardProps) {
|
|
|
810
842
|
const [awaitsStream] = React.useState(
|
|
811
843
|
() => inBrowser && !hydrating && fromPage === undefined && document.readyState === 'loading',
|
|
812
844
|
)
|
|
845
|
+
// 2.0.2, latched at mount: this instance is hydrating a server render that skipped its read (`SkippedRead`).
|
|
846
|
+
const [skippedMount] = React.useState(() => inBrowser && hydrating && skippedInDocument(instance))
|
|
847
|
+
if (skippedMount) return <SkippedThenRecord {...props} instance={instance} hydrating={hydrating} />
|
|
813
848
|
if (awaitsStream) return <StreamedRecordCard {...props} />
|
|
814
849
|
if (inBrowser) {
|
|
815
850
|
if (fromPage) return paintRecord(fromPage, props, context, !hydrating)
|
|
816
851
|
if (hydratedMount) return null
|
|
817
852
|
if (!props.clientRead) return clientMountFace(props)
|
|
818
853
|
}
|
|
819
|
-
return readAndPaint(props, context, inBrowser)
|
|
854
|
+
return readAndPaint(props, context, inBrowser, instance)
|
|
820
855
|
}
|
|
821
856
|
|
|
822
857
|
/**
|
|
@@ -827,6 +862,7 @@ function readAndPaint(
|
|
|
827
862
|
props: EnvelopeCardProps,
|
|
828
863
|
context: Parameters<typeof renderVerdict>[1],
|
|
829
864
|
inBrowser: boolean,
|
|
865
|
+
instance = '',
|
|
830
866
|
): React.ReactElement | null {
|
|
831
867
|
const rt = cardDeliveryRuntime()
|
|
832
868
|
const { design } = props
|
|
@@ -844,11 +880,20 @@ function readAndPaint(
|
|
|
844
880
|
const key = design
|
|
845
881
|
? `${props.baseUrl}|${props.externalId}|design=${design.version}|${design.brand ?? ''}`
|
|
846
882
|
: `${props.baseUrl}|${props.externalId}`
|
|
847
|
-
const settled =
|
|
883
|
+
const settled =
|
|
884
|
+
readVerifiedDelivery(read) ??
|
|
885
|
+
(inBrowser
|
|
886
|
+
? suspendOn(rt.cache, key, () => getVerifiedDelivery(read))
|
|
887
|
+
: suspendOnServer(key, read, rt.serverWaitMs))
|
|
888
|
+
if (settled === 'skipped') return <SkippedRead instance={instance} />
|
|
848
889
|
if (settled.verdict.decision !== 'render') {
|
|
849
890
|
return settleWithoutEnvelope(
|
|
850
891
|
props,
|
|
851
|
-
{
|
|
892
|
+
{
|
|
893
|
+
unreachable: settled.unreachable === true,
|
|
894
|
+
reason: settled.verdict.reason,
|
|
895
|
+
...(settled.status === undefined ? {} : { status: settled.status }),
|
|
896
|
+
},
|
|
852
897
|
!inBrowser,
|
|
853
898
|
)
|
|
854
899
|
}
|
|
@@ -872,6 +917,7 @@ function readAndPaint(
|
|
|
872
917
|
card={outcome.card}
|
|
873
918
|
names={outcome.names}
|
|
874
919
|
seed={serializeSeed({ ...seed, card: outcome.card as unknown as Readonly<Record<string, unknown>> })}
|
|
920
|
+
origin={props.baseUrl}
|
|
875
921
|
/>
|
|
876
922
|
)
|
|
877
923
|
}
|
|
@@ -897,116 +943,40 @@ function readAndPaint(
|
|
|
897
943
|
}
|
|
898
944
|
|
|
899
945
|
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`.
|
|
946
|
+
// 1.1.0: a block that carries the delivery marker paints from the signed envelope and the brand's def, never from
|
|
947
|
+
// the options beside it. 2.0.1: a block WITHOUT the marker (an entry exported before 1.1.0, a block dropped in by
|
|
948
|
+
// hand) prints nothing at all and reports `no_marker`; through 2.0.0 it painted its export-time options (a reviewer
|
|
949
|
+
// name, a credential, a memo, a verify link nobody had signed). A marker this client does not accept, or a block
|
|
950
|
+
// with no Builder entry to read for, prints nothing and reports `marker_invalid`.
|
|
951
|
+
// 1.2.0-canary.5: a design pin (CertREV's canary replica only) that cannot be read renders nothing.
|
|
907
952
|
const design = parseDesignPin(props.builderContext?.context?.certrevDesignPin)
|
|
908
953
|
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} />
|
|
954
|
+
const entryId = text(props.builderContext?.content?.id)
|
|
955
|
+
if (props.delivery == null) return <BlockReport reason="no_marker" externalId={entryId} />
|
|
956
|
+
const marker = acceptedDeliveryMarker(props.delivery)
|
|
957
|
+
if (!marker || !entryId) return <BlockReport reason="marker_invalid" externalId={entryId} />
|
|
958
|
+
return (
|
|
959
|
+
<Suspense fallback={null}>
|
|
960
|
+
<EnvelopeCertReviewCard
|
|
961
|
+
baseUrl={marker.baseUrl}
|
|
962
|
+
externalId={entryId}
|
|
963
|
+
mode={props.mode}
|
|
964
|
+
part={props.part}
|
|
965
|
+
design={design}
|
|
966
|
+
block={props}
|
|
967
|
+
clientRead={props.clientRead !== false}
|
|
968
|
+
/>
|
|
969
|
+
</Suspense>
|
|
970
|
+
)
|
|
932
971
|
}
|
|
933
972
|
|
|
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
|
-
})
|
|
973
|
+
/** 2.0.1: a block with nothing to read reports why, to CertREV's production origin (it has no delivery origin). */
|
|
974
|
+
function BlockReport(props: { readonly reason: CardFallbackReason; readonly externalId: string }) {
|
|
975
|
+
const detail = React.useMemo<CardFallbackDetail>(
|
|
976
|
+
() => ({ reason: props.reason, platform: 'builder', externalId: props.externalId }),
|
|
977
|
+
[props.reason, props.externalId],
|
|
978
|
+
)
|
|
979
|
+
return <FallbackReport detail={detail} server={typeof document === 'undefined'} />
|
|
1010
980
|
}
|
|
1011
981
|
|
|
1012
982
|
// ─────────────────────────────────────────────────────────────────────────────
|