@certrev/cert-block 1.2.0-canary.7 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +120 -63
- package/README.md +116 -21
- package/dist/builder/card-reader-css.d.ts +14 -0
- package/dist/builder/card-reader-css.d.ts.map +1 -0
- package/dist/builder/card-reader-css.js +17 -0
- package/dist/builder/card-reader-css.js.map +1 -0
- package/dist/builder/cert-review-card.d.ts +8 -0
- package/dist/builder/cert-review-card.d.ts.map +1 -1
- package/dist/builder/cert-review-card.js +234 -41
- package/dist/builder/cert-review-card.js.map +1 -1
- package/dist/builder/delivery-runtime.d.ts +15 -1
- package/dist/builder/delivery-runtime.d.ts.map +1 -1
- package/dist/builder/delivery-runtime.js +31 -3
- package/dist/builder/delivery-runtime.js.map +1 -1
- package/dist/builder/ssr-seed.d.ts +155 -0
- package/dist/builder/ssr-seed.d.ts.map +1 -0
- package/dist/builder/ssr-seed.js +278 -0
- package/dist/builder/ssr-seed.js.map +1 -0
- package/dist/verify/cache.d.ts +39 -2
- package/dist/verify/cache.d.ts.map +1 -1
- package/dist/verify/cache.js +61 -12
- package/dist/verify/cache.js.map +1 -1
- package/dist/verify/get-verified-delivery.d.ts +28 -3
- package/dist/verify/get-verified-delivery.d.ts.map +1 -1
- package/dist/verify/get-verified-delivery.js +46 -9
- package/dist/verify/get-verified-delivery.js.map +1 -1
- package/package.json +112 -112
- package/src/builder/card-reader-css.ts +19 -0
- package/src/builder/cert-review-card.tsx +308 -56
- package/src/builder/delivery-runtime.ts +33 -3
- package/src/builder/ssr-seed.ts +343 -0
- package/src/verify/cache.ts +87 -11
- package/src/verify/get-verified-delivery.ts +77 -11
|
@@ -23,18 +23,29 @@ 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
25
|
import { CERT_STRINGS_VERSION, type ResolvedBlockTheme } from '../components/render-def.js'
|
|
26
|
-
import {
|
|
26
|
+
import { type CertPayload, renderVerdict } from '../contract/kernel.js'
|
|
27
27
|
import {
|
|
28
28
|
type DesignPin,
|
|
29
29
|
getVerifiedDelivery,
|
|
30
30
|
parseDesignPin,
|
|
31
|
-
|
|
31
|
+
readVerifiedDelivery,
|
|
32
32
|
type VerifiedDelivery,
|
|
33
33
|
} from '../verify/get-verified-delivery.js'
|
|
34
|
-
import
|
|
34
|
+
import { CARD_READER_CSS } from './card-reader-css.js'
|
|
35
|
+
import type { BuilderCertChromeData, CertChromeRenderDef } from './cert-chrome-data.js'
|
|
35
36
|
import { BUILDER_CERT_CHROME_KEYS } from './cert-chrome-data.js'
|
|
36
37
|
import { cardDeliveryRuntime } from './delivery-runtime.js'
|
|
37
38
|
import { envelopeCardHtml, envelopeCardInput, isReaderFaceDef } from './envelope-face.js'
|
|
39
|
+
import {
|
|
40
|
+
type FallbackFace,
|
|
41
|
+
pageHoldsServerHtml,
|
|
42
|
+
pageRecordFor,
|
|
43
|
+
SEED_ATTRIBUTE,
|
|
44
|
+
seedFor,
|
|
45
|
+
serializeFallback,
|
|
46
|
+
serializeSeed,
|
|
47
|
+
withoutPhotos,
|
|
48
|
+
} from './ssr-seed.js'
|
|
38
49
|
|
|
39
50
|
/**
|
|
40
51
|
* The canonical Builder block name. A brand's registration + the portal
|
|
@@ -75,6 +86,14 @@ export interface CertReviewCardProps extends Partial<BuilderCertChromeData> {
|
|
|
75
86
|
readonly content?: { readonly id?: unknown } | null
|
|
76
87
|
readonly context?: { readonly certrevDesignPin?: unknown } | null
|
|
77
88
|
} | null
|
|
89
|
+
/**
|
|
90
|
+
* 1.2.0: opt in to a Delivery read FROM THE BROWSER for a card the server did not render (a client-side navigation,
|
|
91
|
+
* Builder's editor). Off by default: the card then never makes a request from the browser, so a brand's
|
|
92
|
+
* Content-Security-Policy needs no CertREV origin, and such a card paints the block's own options face. Turning it
|
|
93
|
+
* on needs the Delivery API's origin in the page's `connect-src`. Not a Builder input: a brand sets it in code, on
|
|
94
|
+
* a wrapper of this component.
|
|
95
|
+
*/
|
|
96
|
+
clientRead?: boolean
|
|
78
97
|
}
|
|
79
98
|
|
|
80
99
|
/**
|
|
@@ -139,7 +158,7 @@ export function acceptedDeliveryMarker(value: unknown): { readonly baseUrl: stri
|
|
|
139
158
|
/**
|
|
140
159
|
* Suspend on a Delivery read. React 19 gets `use` with ONE promise per placement while it is in
|
|
141
160
|
* flight; React 18 (no `use`) gets the promise thrown, the Suspense protocol it supports. Either
|
|
142
|
-
* way the retry finds the settled read in the cache through `
|
|
161
|
+
* way the retry finds the settled read in the cache through `readVerifiedDelivery`, so it never
|
|
143
162
|
* suspends on a second promise.
|
|
144
163
|
*/
|
|
145
164
|
const inflightReads = new Map<string, Promise<VerifiedDelivery>>()
|
|
@@ -202,8 +221,13 @@ function EnvelopeFace(props: {
|
|
|
202
221
|
readonly html: string
|
|
203
222
|
readonly defVersion: number | undefined
|
|
204
223
|
readonly reader?: boolean
|
|
224
|
+
/** The envelope seed (`ssr-seed.ts`): what a browser hydrating this face paints from, with no read of its own. */
|
|
225
|
+
readonly seed: string
|
|
226
|
+
/** 1.2.0, reader faces: told which person's photo the browser could not load (`ReaderEnvelopeFace`). */
|
|
227
|
+
readonly onBrokenPhoto?: (person: PhotoPerson) => void
|
|
205
228
|
}) {
|
|
206
229
|
const ref = React.useRef<HTMLDivElement>(null)
|
|
230
|
+
const { onBrokenPhoto } = props
|
|
207
231
|
React.useEffect(() => {
|
|
208
232
|
const el = ref.current
|
|
209
233
|
if (!el) return
|
|
@@ -214,6 +238,23 @@ function EnvelopeFace(props: {
|
|
|
214
238
|
el.addEventListener('click', onClick)
|
|
215
239
|
return () => el.removeEventListener('click', onClick)
|
|
216
240
|
}, [])
|
|
241
|
+
React.useEffect(() => {
|
|
242
|
+
const el = ref.current
|
|
243
|
+
if (!el || !onBrokenPhoto) return
|
|
244
|
+
// A photo that failed before this effect ran (during hydration) has no error event left to hear.
|
|
245
|
+
for (const img of Array.from(el.querySelectorAll('.certrev-r-face > img'))) {
|
|
246
|
+
if (img instanceof HTMLImageElement && img.complete && img.naturalWidth === 0) onBrokenPhoto(photoPerson(img))
|
|
247
|
+
}
|
|
248
|
+
// An image's `error` does not bubble, so it is heard in the capture phase.
|
|
249
|
+
const onError = (e: Event) => {
|
|
250
|
+
const img = e.target
|
|
251
|
+
if (img instanceof HTMLImageElement && img.parentElement?.classList.contains('certrev-r-face')) {
|
|
252
|
+
onBrokenPhoto(photoPerson(img))
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
el.addEventListener('error', onError, true)
|
|
256
|
+
return () => el.removeEventListener('error', onError, true)
|
|
257
|
+
}, [onBrokenPhoto])
|
|
217
258
|
return withSidebarFlow(
|
|
218
259
|
props.reader,
|
|
219
260
|
<div
|
|
@@ -224,6 +265,7 @@ function EnvelopeFace(props: {
|
|
|
224
265
|
data-certrev-delivery=""
|
|
225
266
|
data-certrev-strings-version={CERT_STRINGS_VERSION}
|
|
226
267
|
data-certrev-def-version={props.defVersion}
|
|
268
|
+
{...{ [SEED_ATTRIBUTE]: props.seed }}
|
|
227
269
|
{...(props.reader ? { 'data-certrev-face': 'reader', style: { display: 'flow-root' } } : {})}
|
|
228
270
|
// biome-ignore lint/security/noDangerouslySetInnerHtml: renderCertBlock is the escaping-safe SSR string renderer
|
|
229
271
|
dangerouslySetInnerHTML={{ __html: props.html }}
|
|
@@ -231,17 +273,243 @@ function EnvelopeFace(props: {
|
|
|
231
273
|
)
|
|
232
274
|
}
|
|
233
275
|
|
|
234
|
-
|
|
276
|
+
type PhotoPerson = 'expert' | 'author'
|
|
277
|
+
|
|
278
|
+
/** The reader face paints the author's avatar small (`.certrev-r-face.sm`); every other avatar is the expert's. */
|
|
279
|
+
function photoPerson(img: HTMLImageElement): PhotoPerson {
|
|
280
|
+
return img.parentElement?.classList.contains('sm') ? 'author' : 'expert'
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
interface EnvelopeCardProps {
|
|
235
284
|
readonly baseUrl: string
|
|
236
285
|
readonly externalId: string
|
|
237
286
|
readonly mode: unknown
|
|
238
287
|
readonly part: unknown
|
|
239
288
|
readonly design?: DesignPin
|
|
289
|
+
/** The block's own options: the face painted when CertREV could not be reached, or with no server render. */
|
|
290
|
+
readonly block: CertReviewCardProps
|
|
291
|
+
readonly clientRead: boolean
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* 1.2.0-canary.5: a pinned read paints only the design it pinned, a reader def at exactly that version. A def served
|
|
296
|
+
* for anything else (none, another face, another version) paints nothing, never the live design in its place.
|
|
297
|
+
*/
|
|
298
|
+
function pinnedDefHolds(design: DesignPin | undefined, renderDef: CertChromeRenderDef | null): boolean {
|
|
299
|
+
return !design || (isReaderFaceDef(renderDef) && renderDef?.v === design.version)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* 1.2.0: the reader face, repainted with the reviewer's (or the author's) initials when the browser cannot load their
|
|
304
|
+
* photo: a brand's CSP `img-src` that does not name the photo's host, a 404. The first paint is the server's, photo
|
|
305
|
+
* included, so hydration matches; a photo that fails is dropped from the payload this face paints
|
|
306
|
+
* (`withoutPhotos`), and the face's own avatar rule paints the initials in its place. The seed is not touched.
|
|
307
|
+
*/
|
|
308
|
+
function ReaderEnvelopeFace(props: {
|
|
309
|
+
readonly payload: CertPayload
|
|
310
|
+
readonly renderDef: CertChromeRenderDef
|
|
311
|
+
readonly card: EnvelopeCardProps
|
|
312
|
+
readonly seed: string
|
|
240
313
|
}) {
|
|
314
|
+
const [broken, setBroken] = React.useState<readonly PhotoPerson[]>([])
|
|
315
|
+
const onBrokenPhoto = React.useCallback(
|
|
316
|
+
(person: PhotoPerson) => setBroken((was) => (was.includes(person) ? was : [...was, person])),
|
|
317
|
+
[],
|
|
318
|
+
)
|
|
319
|
+
const payload = broken.length > 0 ? withoutPhotos(props.payload, broken) : props.payload
|
|
320
|
+
const reader = envelopeCardHtml(payload.content, props.renderDef, {
|
|
321
|
+
mode: props.card.mode,
|
|
322
|
+
part: props.card.part,
|
|
323
|
+
certId: payload.certId,
|
|
324
|
+
})
|
|
325
|
+
if (!reader) return null
|
|
326
|
+
return (
|
|
327
|
+
<EnvelopeFace
|
|
328
|
+
reader
|
|
329
|
+
// The stylesheet rides inside the face, once per card: the portal's reader CSS less its remote font
|
|
330
|
+
// faces (card-reader-css.ts), so the card asks the page's CSP for nothing.
|
|
331
|
+
html={`<style data-certrev-reader-css>${CARD_READER_CSS}</style>${reader}`}
|
|
332
|
+
defVersion={props.renderDef.v}
|
|
333
|
+
seed={props.seed}
|
|
334
|
+
onBrokenPhoto={onBrokenPhoto}
|
|
335
|
+
/>
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Paint a verified payload with its def; null when the def and content give no face. */
|
|
340
|
+
function paintEnvelope(
|
|
341
|
+
payload: CertPayload,
|
|
342
|
+
renderDef: CertChromeRenderDef | null,
|
|
343
|
+
props: EnvelopeCardProps,
|
|
344
|
+
seed: string,
|
|
345
|
+
): React.ReactElement | null {
|
|
346
|
+
if (!pinnedDefHolds(props.design, renderDef)) return null
|
|
347
|
+
const defVersion = renderDef ? renderDef.v : undefined
|
|
348
|
+
// The reader face is opt-in by the def's own `face` and is chosen HERE and nowhere else on this path; every other
|
|
349
|
+
// def (no face, an unknown face) falls through to the legacy lines below, untouched since 1.1.1.
|
|
350
|
+
if (isReaderFaceDef(renderDef) && renderDef) {
|
|
351
|
+
// Without a face there is nothing to repaint; checked here so the null stays this function's answer.
|
|
352
|
+
if (!envelopeCardHtml(payload.content, renderDef, { mode: props.mode, part: props.part, certId: payload.certId }))
|
|
353
|
+
return null
|
|
354
|
+
return <ReaderEnvelopeFace payload={payload} renderDef={renderDef} card={props} seed={seed} />
|
|
355
|
+
}
|
|
356
|
+
const input = envelopeCardInput(payload.content, renderDef, { mode: props.mode, part: props.part })
|
|
357
|
+
if (!input) return null
|
|
358
|
+
return <EnvelopeFace html={renderCertBlock(input)} defVersion={defVersion} seed={seed} />
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const FALLBACK_LAYOUTS: readonly string[] = ['banner', 'sidebar', 'floating']
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* The options face a block with the delivery marker paints in place of the envelope (1.2.0), or null. Its placement is
|
|
365
|
+
* the block's own `mode`, else its def's layout, as the envelope face's is, else the banner bridge. A pinned card (the
|
|
366
|
+
* canary replica) paints it too, so the replica shows what a brand's page shows when CertREV is not reached; its
|
|
367
|
+
* record names the pin and says `'options'`, so a capture can never take it for the pinned design.
|
|
368
|
+
*/
|
|
369
|
+
function fallbackFace(props: EnvelopeCardProps): string | null {
|
|
370
|
+
const { block } = props
|
|
371
|
+
const layout = block.renderDef?.layout
|
|
372
|
+
const mode =
|
|
373
|
+
typeof block.mode === 'string' && FALLBACK_LAYOUTS.includes(block.mode)
|
|
374
|
+
? block.mode
|
|
375
|
+
: typeof layout === 'string' && FALLBACK_LAYOUTS.includes(layout)
|
|
376
|
+
? (layout as CertBlockMode)
|
|
377
|
+
: undefined
|
|
378
|
+
return optionsFaceHtml(block, mode)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** The block's options face, stamped with a fallback record when the server wrote one (`ssr-seed.ts`). */
|
|
382
|
+
function OptionsFace(props: { readonly block: CertReviewCardProps; readonly html: string; readonly record?: string }) {
|
|
383
|
+
return (
|
|
384
|
+
<div
|
|
385
|
+
data-certrev-cert-chrome=""
|
|
386
|
+
// The two provenance stamps the crawl monitor reads — emitted ONLY when the delivery
|
|
387
|
+
// carried the right type, so a mistyped option leaves the attribute off instead of
|
|
388
|
+
// stamping "[object Object]" as a version.
|
|
389
|
+
data-certrev-strings-version={optionalText(props.block.stringsVersion)}
|
|
390
|
+
data-certrev-def-version={typeof props.block.renderDef?.v === 'number' ? props.block.renderDef.v : undefined}
|
|
391
|
+
{...(props.record === undefined ? {} : { [SEED_ATTRIBUTE]: props.record })}
|
|
392
|
+
// biome-ignore lint/security/noDangerouslySetInnerHtml: renderCertBlock is the escaping-safe SSR string renderer
|
|
393
|
+
dangerouslySetInnerHTML={{ __html: props.html }}
|
|
394
|
+
/>
|
|
395
|
+
)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Where the server painted no face, an empty hidden element carries the record that says so. */
|
|
399
|
+
function NoFace(props: { readonly record: string }) {
|
|
400
|
+
return <div hidden {...{ [SEED_ATTRIBUTE]: props.record }} />
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** What a card with no verified envelope paints: the options face when CertREV was not reached, else nothing. */
|
|
404
|
+
function settleWithoutEnvelope(props: EnvelopeCardProps, unreachable: boolean): React.ReactElement {
|
|
405
|
+
const html = unreachable ? fallbackFace(props) : null
|
|
406
|
+
const face: FallbackFace = html === null ? 'none' : 'options'
|
|
407
|
+
const record = serializeFallback({
|
|
408
|
+
baseUrl: props.baseUrl,
|
|
409
|
+
externalId: props.externalId,
|
|
410
|
+
...(props.design ? { design: props.design } : {}),
|
|
411
|
+
face,
|
|
412
|
+
})
|
|
413
|
+
return html === null ? <NoFace record={record} /> : <OptionsFace block={props.block} html={html} record={record} />
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const subscribeNever = () => () => {}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* True on a server and while a browser hydrates server HTML; false once the client has committed, and from the first
|
|
420
|
+
* render of a card mounted on the client. React re-renders a hydrated card when this flips, so the card can paint
|
|
421
|
+
* the server's tree first (no mismatch) and judge it at the browser's clock right after.
|
|
422
|
+
*
|
|
423
|
+
* It is true in BOTH of the first two cases and React gives a component no public way to tell them apart, so the card
|
|
424
|
+
* does not ask React: see `EnvelopeCertReviewCard`.
|
|
425
|
+
*/
|
|
426
|
+
function useHydrating(): boolean {
|
|
427
|
+
return React.useSyncExternalStore(
|
|
428
|
+
subscribeNever,
|
|
429
|
+
() => false,
|
|
430
|
+
() => true,
|
|
431
|
+
)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* 1.2.0: the card never reads the Delivery API from the browser unless the brand opts in (`clientRead`), so a
|
|
436
|
+
* storefront whose CSP `connect-src` does not name CertREV (e.l.f.'s live policy) never sees a request or a violation
|
|
437
|
+
* from it, and a page never loses its card for want of one.
|
|
438
|
+
*
|
|
439
|
+
* - Server: read (up to `CARD_DELIVERY_TIMEOUT_MS`, a fresh or stale-while-revalidating cache first), verify and paint
|
|
440
|
+
* as through 1.1.1, and write a record of what it painted (`ssr-seed.ts`):
|
|
441
|
+
* - a verified envelope: its face and the envelope seed (less the fields the def hides, and with no signature if
|
|
442
|
+
* it hid any);
|
|
443
|
+
* - CertREV not reached (a timeout, a network error, a 5xx, a body that is not JSON): the block's own options face,
|
|
444
|
+
* as 0.5.x painted it, and an `'options'` record (a pinned card too: the record names its pin);
|
|
445
|
+
* - a definitive no (revoked, expired, unverifiable, for another entry, not found): nothing, and a `'none'` record
|
|
446
|
+
* on an empty hidden element.
|
|
447
|
+
* - Browser, the page carries this placement's record: paint what it says. A seed paints its envelope; while
|
|
448
|
+
* hydrating the server's verdict stands (the tree is the server's, byte for byte), and once hydrated the lifecycle
|
|
449
|
+
* and subject are judged at the browser's clock, so a card that expired since the server render is taken down. An
|
|
450
|
+
* `'options'` record paints the options face, a `'none'` record nothing. This holds when React renders the card
|
|
451
|
+
* again on the client after a hydration error elsewhere on the page: the record is still in the document (or in
|
|
452
|
+
* the page's memory of it), so the browser repaints the server's answer and reads nothing.
|
|
453
|
+
* - Browser, hydrating with no record: server HTML from before 1.2.0 painted nothing here, so nothing.
|
|
454
|
+
* - Browser, a card mounted on the client with no record (a client-side navigation to an article this page never
|
|
455
|
+
* server-rendered, Builder's editor): the block's options face, pinned or not. With `clientRead`, instead, the
|
|
456
|
+
* one read, settled exactly as on the server.
|
|
457
|
+
*
|
|
458
|
+
* WHICH OF THE TWO FIRST CASES THIS RENDER IS (server or browser) is decided from what the render can observe, with
|
|
459
|
+
* no switch a test could set. A server render and a hydration are both "hydrating" to React and both see a `document`
|
|
460
|
+
* under jsdom, so `typeof document` alone calls a jsdom server render a browser, which is what 1.2.0-canary.5 got
|
|
461
|
+
* wrong (its SSR painted `<!--$--><!--/$-->`). What differs between them is the one thing a hydration has and a server
|
|
462
|
+
* render cannot: server-rendered HTML IN the document, in front of it. So:
|
|
463
|
+
* 1. no `document`: a server (every real server render: Node, Workers, Oxygen, Bun);
|
|
464
|
+
* 2. not hydrating: a browser (a client mount, or a card past its hydration);
|
|
465
|
+
* 3. hydrating with a `document`: a browser only when the live document holds this placement's record, or any React
|
|
466
|
+
* Suspense boundary marker (`pageHoldsServerHtml`; the card's own Suspense leaves one). Otherwise it is a server
|
|
467
|
+
* render running beside a `document` (jsdom, a DOM shim): it reads and records as a server does, and never consults
|
|
468
|
+
* a record or the page's memory, so a long-lived shim process cannot paint one page's record into another's render.
|
|
469
|
+
* The one case this cannot see is a server render that runs while the document still holds server-rendered React HTML
|
|
470
|
+
* (a test or shim that hydrates a page and then renders another into the same document without clearing it): that
|
|
471
|
+
* render is taken for a hydration. Clear the document between pages in such a harness.
|
|
472
|
+
*/
|
|
473
|
+
function EnvelopeCertReviewCard(props: EnvelopeCardProps) {
|
|
241
474
|
const rt = cardDeliveryRuntime()
|
|
242
|
-
const
|
|
475
|
+
const hydrating = useHydrating()
|
|
476
|
+
// Latched at mount: this instance hydrated server HTML (as opposed to a client mount).
|
|
477
|
+
const [hydratedMount] = React.useState(hydrating)
|
|
243
478
|
// The clock is read per render, so the lifecycle is judged NOW, not when the envelope was fetched.
|
|
244
479
|
const context = { platform: 'builder', externalId: props.externalId, now: new Date(rt.now()) }
|
|
480
|
+
// Hydrating: the live document decides (the memory of an earlier page must not). Past hydration: the memory is the
|
|
481
|
+
// record the hydration used, which the document still carries as well.
|
|
482
|
+
const fromPage =
|
|
483
|
+
typeof document === 'undefined'
|
|
484
|
+
? undefined
|
|
485
|
+
: pageRecordFor(props.baseUrl, props.externalId, props.design, { live: hydrating })
|
|
486
|
+
const inBrowser = typeof document !== 'undefined' && (!hydrating || fromPage !== undefined || pageHoldsServerHtml())
|
|
487
|
+
if (inBrowser) {
|
|
488
|
+
if (fromPage && 'seed' in fromPage) {
|
|
489
|
+
if (!hydrating) {
|
|
490
|
+
try {
|
|
491
|
+
if (renderVerdict(fromPage.seed.payload, context).decision !== 'render') return null
|
|
492
|
+
} catch {
|
|
493
|
+
return null
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return paintEnvelope(fromPage.seed.payload, fromPage.seed.renderDef, props, fromPage.raw)
|
|
497
|
+
}
|
|
498
|
+
if (fromPage) {
|
|
499
|
+
const html = fromPage.fallback.face === 'options' ? fallbackFace(props) : null
|
|
500
|
+
return html === null ? (
|
|
501
|
+
<NoFace record={fromPage.raw} />
|
|
502
|
+
) : (
|
|
503
|
+
<OptionsFace block={props.block} html={html} record={fromPage.raw} />
|
|
504
|
+
)
|
|
505
|
+
}
|
|
506
|
+
if (hydratedMount) return null
|
|
507
|
+
if (!props.clientRead) {
|
|
508
|
+
const html = fallbackFace(props)
|
|
509
|
+
return html === null ? null : <OptionsFace block={props.block} html={html} />
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
const { design } = props
|
|
245
513
|
const read = {
|
|
246
514
|
baseUrl: props.baseUrl,
|
|
247
515
|
platform: 'builder',
|
|
@@ -249,44 +517,26 @@ function EnvelopeCertReviewCard(props: {
|
|
|
249
517
|
context,
|
|
250
518
|
cache: rt.cache,
|
|
251
519
|
design,
|
|
520
|
+
resolveKid: rt.resolveKid,
|
|
521
|
+
fetchImpl: rt.fetchImpl(),
|
|
522
|
+
timeoutMs: rt.timeoutMs,
|
|
252
523
|
}
|
|
253
524
|
const key = design
|
|
254
525
|
? `${props.baseUrl}|${props.externalId}|design=${design.version}|${design.brand ?? ''}`
|
|
255
526
|
: `${props.baseUrl}|${props.externalId}`
|
|
256
|
-
const settled =
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
// def (no face, an unknown face) falls through to the legacy lines below, untouched since 1.1.1.
|
|
268
|
-
if (isReaderFaceDef(settled.renderDef)) {
|
|
269
|
-
const reader = envelopeCardHtml(settled.verdict.payload.content, settled.renderDef, {
|
|
270
|
-
mode: props.mode,
|
|
271
|
-
part: props.part,
|
|
272
|
-
certId: settled.verdict.payload.certId,
|
|
273
|
-
})
|
|
274
|
-
if (!reader) return null
|
|
275
|
-
return (
|
|
276
|
-
<EnvelopeFace
|
|
277
|
-
reader
|
|
278
|
-
// The stylesheet rides inside the face, once per card; it is the portal's reader CSS byte for byte.
|
|
279
|
-
html={`<style data-certrev-reader-css>${READER_CSS}</style>${reader}`}
|
|
280
|
-
defVersion={settled.renderDef ? settled.renderDef.v : undefined}
|
|
281
|
-
/>
|
|
282
|
-
)
|
|
283
|
-
}
|
|
284
|
-
const input = envelopeCardInput(settled.verdict.payload.content, settled.renderDef, {
|
|
285
|
-
mode: props.mode,
|
|
286
|
-
part: props.part,
|
|
527
|
+
const settled = readVerifiedDelivery(read) ?? suspendOn(key, () => getVerifiedDelivery(read))
|
|
528
|
+
if (settled.verdict.decision !== 'render') return settleWithoutEnvelope(props, settled.unreachable === true)
|
|
529
|
+
// The face is painted from the seed's payload (what the def hides is already out of it), so the server's HTML and
|
|
530
|
+
// the browser's, which paints from the seed, are the same bytes and neither holds a hidden field.
|
|
531
|
+
const seed = seedFor({
|
|
532
|
+
baseUrl: props.baseUrl,
|
|
533
|
+
externalId: props.externalId,
|
|
534
|
+
design,
|
|
535
|
+
payload: settled.verdict.payload,
|
|
536
|
+
signature: settled.signature,
|
|
537
|
+
renderDef: settled.renderDef,
|
|
287
538
|
})
|
|
288
|
-
|
|
289
|
-
return <EnvelopeFace html={renderCertBlock(input)} defVersion={settled.renderDef ? settled.renderDef.v : undefined} />
|
|
539
|
+
return paintEnvelope(seed.payload, seed.renderDef, props, serializeSeed(seed))
|
|
290
540
|
}
|
|
291
541
|
|
|
292
542
|
export function CertReviewCard(props: CertReviewCardProps) {
|
|
@@ -295,6 +545,8 @@ export function CertReviewCard(props: CertReviewCardProps) {
|
|
|
295
545
|
// takes the options path below, unchanged from 1.0.3.
|
|
296
546
|
// 1.2.0-canary.5: a design pin (CertREV's canary replica only) fails closed. One that cannot be read renders
|
|
297
547
|
// nothing, and so does one on a block without the delivery marker: the options face is never the pinned design.
|
|
548
|
+
// 1.2.0: where the envelope cannot be had (CertREV not reached, or no server render and no `clientRead`), a block
|
|
549
|
+
// with the marker paints this options face too, pinned or not: see `EnvelopeCertReviewCard`.
|
|
298
550
|
const design = parseDesignPin(props.builderContext?.context?.certrevDesignPin)
|
|
299
551
|
if (design === 'invalid') return null
|
|
300
552
|
if (props.delivery != null) {
|
|
@@ -309,15 +561,28 @@ export function CertReviewCard(props: CertReviewCardProps) {
|
|
|
309
561
|
mode={props.mode}
|
|
310
562
|
part={props.part}
|
|
311
563
|
design={design}
|
|
564
|
+
block={props}
|
|
565
|
+
clientRead={props.clientRead === true}
|
|
312
566
|
/>
|
|
313
567
|
</Suspense>
|
|
314
568
|
)
|
|
315
569
|
}
|
|
316
570
|
if (design) return null
|
|
317
571
|
|
|
572
|
+
const html = optionsFaceHtml(props, 'banner')
|
|
573
|
+
if (html === null) return null // no chrome pre-cert (or an unreadable delivery)
|
|
574
|
+
return <OptionsFace block={props} html={html} />
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* The options face's HTML (1.0.3's, unchanged), or null when the block lacks a reviewer or a certificate link. `bridge`
|
|
579
|
+
* is the placement a block with no `mode` gets: `'banner'` for a block without the delivery marker (the bridge below);
|
|
580
|
+
* the def's layout for a marked block's fallback (`fallbackFace`).
|
|
581
|
+
*/
|
|
582
|
+
function optionsFaceHtml(props: CertReviewCardProps, bridge: CertBlockMode | undefined): string | null {
|
|
318
583
|
const reviewerName = text(props.reviewerName)
|
|
319
584
|
const verifyUrl = text(props.verifyUrl)
|
|
320
|
-
if (!reviewerName || !verifyUrl) return null
|
|
585
|
+
if (!reviewerName || !verifyUrl) return null
|
|
321
586
|
|
|
322
587
|
// Name-only face when the pair is unreadable, or when the brand set showCredentials:false.
|
|
323
588
|
const showCred = props.display?.showCredentials !== false
|
|
@@ -336,7 +601,7 @@ export function CertReviewCard(props: CertReviewCardProps) {
|
|
|
336
601
|
}
|
|
337
602
|
: undefined
|
|
338
603
|
|
|
339
|
-
|
|
604
|
+
return renderCertBlock({
|
|
340
605
|
// COMPATIBILITY BRIDGE, NOT THE DESIGN. Delete this fallback once the exporter emits an
|
|
341
606
|
// explicit `mode` and existing entries have been backfilled; until then it must stay.
|
|
342
607
|
//
|
|
@@ -358,7 +623,7 @@ export function CertReviewCard(props: CertReviewCardProps) {
|
|
|
358
623
|
// still fixed: the helper text and the wire type's doc comment no longer name a default,
|
|
359
624
|
// and this line names one only for input the renderer can no longer distinguish from a
|
|
360
625
|
// deliberate choice. The exporter writing `mode` is what retires it.
|
|
361
|
-
mode: props.mode ?? 'banner',
|
|
626
|
+
mode: props.mode ?? bridge ?? 'banner',
|
|
362
627
|
theme,
|
|
363
628
|
// First-class banner memo split — renderCertBlock composes the requested
|
|
364
629
|
// part directly; no string-splitting. Ignored outside banner mode, and an unrecognised
|
|
@@ -385,19 +650,6 @@ export function CertReviewCard(props: CertReviewCardProps) {
|
|
|
385
650
|
compensationCue: props.compensationCue === null ? null : undefined,
|
|
386
651
|
},
|
|
387
652
|
})
|
|
388
|
-
|
|
389
|
-
return (
|
|
390
|
-
<div
|
|
391
|
-
data-certrev-cert-chrome=""
|
|
392
|
-
// The two provenance stamps the crawl monitor reads — emitted ONLY when the delivery
|
|
393
|
-
// carried the right type, so a mistyped option leaves the attribute off instead of
|
|
394
|
-
// stamping "[object Object]" as a version.
|
|
395
|
-
data-certrev-strings-version={optionalText(props.stringsVersion)}
|
|
396
|
-
data-certrev-def-version={typeof props.renderDef?.v === 'number' ? props.renderDef.v : undefined}
|
|
397
|
-
// biome-ignore lint/security/noDangerouslySetInnerHtml: renderCertBlock is the escaping-safe SSR string renderer
|
|
398
|
-
dangerouslySetInnerHTML={{ __html: html }}
|
|
399
|
-
/>
|
|
400
|
-
)
|
|
401
653
|
}
|
|
402
654
|
|
|
403
655
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -470,7 +722,7 @@ const WIRE_INPUT_HELPER_TEXT: Partial<Record<(typeof BUILDER_CERT_CHROME_KEYS)[n
|
|
|
470
722
|
scopeLine:
|
|
471
723
|
'System-populated by CertREV. The FTC scope line renders byte-verbatim from a locked constant: editing this text changes nothing on the page.',
|
|
472
724
|
delivery:
|
|
473
|
-
'System-populated by CertREV. When set, the card shows the certificate CertREV serves for this entry, verified,
|
|
725
|
+
'System-populated by CertREV. When set, the card shows the certificate CertREV serves for this entry, verified, in place of the other fields; if CertREV revokes it or it expires, the card shows nothing. If CertREV cannot be reached, or the page was not rendered on the server, the card shows the other fields. Do not edit.',
|
|
474
726
|
}
|
|
475
727
|
|
|
476
728
|
/**
|
|
@@ -6,12 +6,25 @@
|
|
|
6
6
|
* swap the trust root through the published API: the card verifies against the baked
|
|
7
7
|
* `cert-issuer-1` key, the same key the storefront badge bundle ships. The package's own tests
|
|
8
8
|
* reach the setter by relative import to run the real kernel against fixture-signed envelopes.
|
|
9
|
+
*
|
|
10
|
+
* 1.2.0: the card's server read waits up to `CARD_DELIVERY_TIMEOUT_MS` (8s, against the
|
|
11
|
+
* verifier's 3s default), and its cache serves a certificate up to `CARD_DELIVERY_STALE_MS` past
|
|
12
|
+
* its TTL while one background refresh runs, or while CertREV cannot be reached. A cold or slow
|
|
13
|
+
* read on a brand's server then misses at most once per placement, and a warm one never waits.
|
|
14
|
+
* A revocation still lands on the first render after its refresh does. The tests' runtime gets
|
|
15
|
+
* the same cache shape, so they exercise what production runs.
|
|
9
16
|
*/
|
|
10
17
|
|
|
11
18
|
import type { ResolvePublicKeyByKid } from '../contract/kernel.js'
|
|
12
19
|
import { TtlCache } from '../verify/cache.js'
|
|
13
20
|
import type { CachedDelivery } from '../verify/get-verified-delivery.js'
|
|
14
21
|
import { CERT_ISSUER_KID, CERT_ISSUER_PUBLIC_KEY_PEM } from './issuer-key.js'
|
|
22
|
+
import { forgetPageSeedsForTests } from './ssr-seed.js'
|
|
23
|
+
|
|
24
|
+
/** The card's server read deadline. A brand's server waits this long once per cold placement. */
|
|
25
|
+
export const CARD_DELIVERY_TIMEOUT_MS = 8_000
|
|
26
|
+
/** How long past its 60s TTL a certificate may still paint while it is refreshed or CertREV is down. */
|
|
27
|
+
export const CARD_DELIVERY_STALE_MS = 300_000
|
|
15
28
|
|
|
16
29
|
export interface CardDeliveryRuntime {
|
|
17
30
|
readonly resolveKid: ResolvePublicKeyByKid
|
|
@@ -24,12 +37,25 @@ export interface CardDeliveryRuntime {
|
|
|
24
37
|
const bakedResolveKid: ResolvePublicKeyByKid = (kid) =>
|
|
25
38
|
kid === CERT_ISSUER_KID ? { format: 'pem', pem: CERT_ISSUER_PUBLIC_KEY_PEM } : null
|
|
26
39
|
|
|
40
|
+
function newCardCache(now?: () => number): TtlCache<CachedDelivery> {
|
|
41
|
+
return new TtlCache<CachedDelivery>({
|
|
42
|
+
ttlMs: 60_000,
|
|
43
|
+
negativeTtlMs: 5_000,
|
|
44
|
+
staleMs: CARD_DELIVERY_STALE_MS,
|
|
45
|
+
// A refresh the host runtime abandoned (workerd cancels a finished request's I/O) never
|
|
46
|
+
// settles; after the deadline plus a margin, the next render starts a new one.
|
|
47
|
+
inflightMaxMs: CARD_DELIVERY_TIMEOUT_MS + 2_000,
|
|
48
|
+
...(now ? { now } : {}),
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
27
52
|
function defaultRuntime(): CardDeliveryRuntime {
|
|
28
53
|
return {
|
|
29
54
|
resolveKid: bakedResolveKid,
|
|
30
55
|
fetchImpl: () => globalThis.fetch,
|
|
31
|
-
cache:
|
|
56
|
+
cache: newCardCache(),
|
|
32
57
|
now: Date.now,
|
|
58
|
+
timeoutMs: CARD_DELIVERY_TIMEOUT_MS,
|
|
33
59
|
}
|
|
34
60
|
}
|
|
35
61
|
|
|
@@ -39,13 +65,17 @@ export function cardDeliveryRuntime(): CardDeliveryRuntime {
|
|
|
39
65
|
return runtime
|
|
40
66
|
}
|
|
41
67
|
|
|
42
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* Test seam: replace parts of the runtime (a fresh cache unless one is given) and forget the page's envelope seeds
|
|
70
|
+
* (a fresh runtime is a fresh page). Returns a restore.
|
|
71
|
+
*/
|
|
43
72
|
export function setCardDeliveryRuntimeForTests(overrides: Partial<CardDeliveryRuntime>): () => void {
|
|
73
|
+
forgetPageSeedsForTests()
|
|
44
74
|
const previous = runtime
|
|
45
75
|
const now = overrides.now ?? previous.now
|
|
46
76
|
runtime = {
|
|
47
77
|
...previous,
|
|
48
|
-
cache:
|
|
78
|
+
cache: newCardCache(now),
|
|
49
79
|
...overrides,
|
|
50
80
|
}
|
|
51
81
|
return () => {
|