@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.
Files changed (61) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/README.md +50 -10
  3. package/dist/builder/card-beacon.d.ts +73 -0
  4. package/dist/builder/card-beacon.d.ts.map +1 -0
  5. package/dist/builder/card-beacon.js +178 -0
  6. package/dist/builder/card-beacon.js.map +1 -0
  7. package/dist/builder/card-contract.d.ts +10 -2
  8. package/dist/builder/card-contract.d.ts.map +1 -1
  9. package/dist/builder/card-contract.js +27 -3
  10. package/dist/builder/card-contract.js.map +1 -1
  11. package/dist/builder/card-placement.d.ts +45 -0
  12. package/dist/builder/card-placement.d.ts.map +1 -0
  13. package/dist/builder/card-placement.js +193 -0
  14. package/dist/builder/card-placement.js.map +1 -0
  15. package/dist/builder/card-report.d.ts +26 -14
  16. package/dist/builder/card-report.d.ts.map +1 -1
  17. package/dist/builder/card-report.js +68 -14
  18. package/dist/builder/card-report.js.map +1 -1
  19. package/dist/builder/cert-review-card.d.ts +6 -4
  20. package/dist/builder/cert-review-card.d.ts.map +1 -1
  21. package/dist/builder/cert-review-card.js +172 -199
  22. package/dist/builder/cert-review-card.js.map +1 -1
  23. package/dist/builder/contract-card.d.ts +18 -1
  24. package/dist/builder/contract-card.d.ts.map +1 -1
  25. package/dist/builder/contract-card.js +60 -10
  26. package/dist/builder/contract-card.js.map +1 -1
  27. package/dist/builder/delivery-runtime.d.ts +38 -4
  28. package/dist/builder/delivery-runtime.d.ts.map +1 -1
  29. package/dist/builder/delivery-runtime.js +43 -5
  30. package/dist/builder/delivery-runtime.js.map +1 -1
  31. package/dist/builder/index.d.ts +4 -4
  32. package/dist/builder/index.js +4 -4
  33. package/dist/builder/ssr-seed.d.ts +5 -4
  34. package/dist/builder/ssr-seed.d.ts.map +1 -1
  35. package/dist/builder/ssr-seed.js +4 -3
  36. package/dist/builder/ssr-seed.js.map +1 -1
  37. package/dist/builder/version.d.ts +3 -0
  38. package/dist/builder/version.d.ts.map +1 -0
  39. package/dist/builder/version.js +3 -0
  40. package/dist/builder/version.js.map +1 -0
  41. package/dist/verify/cache.d.ts +24 -0
  42. package/dist/verify/cache.d.ts.map +1 -1
  43. package/dist/verify/cache.js +78 -6
  44. package/dist/verify/cache.js.map +1 -1
  45. package/dist/verify/get-verified-delivery.d.ts +18 -0
  46. package/dist/verify/get-verified-delivery.d.ts.map +1 -1
  47. package/dist/verify/get-verified-delivery.js +30 -3
  48. package/dist/verify/get-verified-delivery.js.map +1 -1
  49. package/package.json +1 -1
  50. package/src/builder/card-beacon.ts +201 -0
  51. package/src/builder/card-contract.ts +25 -3
  52. package/src/builder/card-placement.ts +208 -0
  53. package/src/builder/card-report.ts +79 -14
  54. package/src/builder/cert-review-card.tsx +208 -238
  55. package/src/builder/contract-card.tsx +65 -8
  56. package/src/builder/delivery-runtime.ts +45 -5
  57. package/src/builder/index.tsx +4 -4
  58. package/src/builder/ssr-seed.ts +5 -4
  59. package/src/builder/version.ts +2 -0
  60. package/src/verify/cache.ts +78 -6
  61. package/src/verify/get-verified-delivery.ts +40 -3
@@ -24,10 +24,10 @@ import { memoToggleFor, toggleMemo } from '../components/memo-toggle.js';
24
24
  import { renderCertBlock } from '../components/render-cert-block.js';
25
25
  import { CERT_STRINGS_VERSION } from '../components/render-def.js';
26
26
  import { renderVerdict } from '../contract/kernel.js';
27
- import { getVerifiedDelivery, parseDesignPin, readVerifiedDelivery, } from '../verify/get-verified-delivery.js';
27
+ import { deliveryLoading, getVerifiedDelivery, parseDesignPin, readVerifiedDelivery, rememberDeliveryUnreachable, unreachableDelivery, } from '../verify/get-verified-delivery.js';
28
28
  import { acceptedCardBinding, cardDigestFor, cardDigestMatches, cardMatchesPlacement, cardPartsFor, parseCardDocument, } from './card-contract.js';
29
29
  import { CARD_READER_CSS } from './card-reader-css.js';
30
- import { reportCardFallbackInBrowser, reportCardFallbackOnServer, } from './card-report.js';
30
+ import { CARD_FALLBACK_REASONS, reportCardFallbackInBrowser, reportCardFallbackOnServer, } from './card-report.js';
31
31
  import { BUILDER_CERT_CHROME_KEYS } from './cert-chrome-data.js';
32
32
  import { ContractCardFace } from './contract-card.js';
33
33
  import { deliveredFaceDigest, deliveredFaceHtml, deliveredFaceMatches, faceHashesFor, showsWholeCard, } from './delivered-face.js';
@@ -53,27 +53,6 @@ export const CERT_COMPONENT_NAME = 'CertREV Cert';
53
53
  function text(value) {
54
54
  return typeof value === 'string' ? value : '';
55
55
  }
56
- /** The same rule where ABSENT and EMPTY are different renders (`authorTitle`, `bio`). */
57
- function optionalText(value) {
58
- return text(value) || undefined;
59
- }
60
- /**
61
- * The fused credential pair — accepted ONLY when both halves are present AND both are strings.
62
- *
63
- * The R1 gate ("a credential never displays without its dated verification") is THIS FUNCTION,
64
- * not the type: the pair arrives as untyped CMS JSON, and the old truthiness check keyed the
65
- * whole pair off the OBJECT, so `{credential:'MD'}` forwarded `verifiedAt: undefined` into the
66
- * renderer and crashed. Anything malformed — a half pair, a bare string, an array, a number
67
- * where a date belongs — degrades to the name-only face, which is the honest render of a
68
- * delivery whose verification cannot be read.
69
- */
70
- function fusedCredential(pair) {
71
- if (!pair || typeof pair !== 'object')
72
- return null;
73
- const credential = text(pair.credential);
74
- const verifiedAt = text(pair.verifiedAt);
75
- return credential && verifiedAt ? { credential, verifiedAt } : null;
76
- }
77
56
  /**
78
57
  * The delivery marker, accepted only as `{ v: 1, baseUrl }` with `baseUrl` an `https:` ORIGIN on
79
58
  * `certrev.com` or one of its subdomains (no path, query, credentials or port). Anything else is
@@ -107,16 +86,72 @@ export function acceptedDeliveryMarker(value) {
107
86
  * flight; React 18 (no `use`) gets the promise thrown, the Suspense protocol it supports. Either
108
87
  * way the retry finds the settled read in the cache through `readVerifiedDelivery`, so it never
109
88
  * suspends on a second promise.
89
+ *
90
+ * 2.0.2, on a server (`suspendOnServer`): the render waits at most the runtime's `serverWaitMs` (300 ms). Past it, the
91
+ * placement's failure is remembered in the cache (so every render in the failure window answers at once, even where the
92
+ * runtime abandons the read when the response ends), the render prints nothing (`delivery_unreachable`), and the read
93
+ * runs on: when it lands, the next render paints it. A render that finds a read of the placement already running that it
94
+ * did not start (another request's, a background refresh, or a second card of the placement on the same page: React
95
+ * gives a component no way to tell these apart) never waits on it: it is `'skipped'` and prints a marker only
96
+ * (`SkippedRead`), which the same card instance re-renders while hydrating and replaces with the page's verified seed
97
+ * right after.
110
98
  */
111
- const inflightReads = new Map();
112
- function suspendOn(key, start) {
113
- let pending = inflightReads.get(key);
99
+ /** The reads a render is suspended on, per runtime cache (a fresh runtime, a fresh page in a test, starts with none). */
100
+ const inflightReadsByCache = new WeakMap();
101
+ function suspendOn(cache, key, start) {
102
+ let reads = inflightReadsByCache.get(cache);
103
+ if (!reads) {
104
+ reads = new Map();
105
+ inflightReadsByCache.set(cache, reads);
106
+ }
107
+ let pending = reads.get(key);
114
108
  if (!pending) {
115
- pending = start().finally(() => inflightReads.delete(key));
116
- inflightReads.set(key, pending);
109
+ const own = reads;
110
+ pending = start().finally(() => own.delete(key));
111
+ reads.set(key, pending);
117
112
  }
118
113
  return suspendUntil(pending);
119
114
  }
115
+ function suspendOnServer(key, read, waitMs) {
116
+ if (deliveryLoading(read))
117
+ return 'skipped';
118
+ return suspendOn(read.cache ?? inflightReadsByCache, key, () => {
119
+ const loaded = getVerifiedDelivery(read);
120
+ if (waitMs === undefined || !Number.isFinite(waitMs) || waitMs <= 0)
121
+ return loaded;
122
+ return new Promise((resolve) => {
123
+ const timer = setTimeout(() => {
124
+ rememberDeliveryUnreachable(read);
125
+ resolve(unreachableDelivery());
126
+ }, waitMs);
127
+ loaded.then((settled) => {
128
+ clearTimeout(timer);
129
+ resolve(settled);
130
+ }, () => {
131
+ clearTimeout(timer);
132
+ resolve(unreachableDelivery());
133
+ });
134
+ });
135
+ });
136
+ }
137
+ /** 2.0.2: the attribute a skipped server render's marker carries: the card instance's `useId`. */
138
+ export const SKIPPED_ATTRIBUTE = 'data-certrev-skipped';
139
+ /**
140
+ * 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
141
+ * instance, and no record (it knows nothing about the placement, so it must never stand in for another card's record).
142
+ * Not a failure: nothing is reported or remembered.
143
+ */
144
+ function SkippedRead({ instance }) {
145
+ return _jsx("div", { hidden: true, [SKIPPED_ATTRIBUTE]: instance });
146
+ }
147
+ /** 2.0.2: whether the live document holds this card instance's skipped marker (it is hydrating one). */
148
+ function skippedInDocument(instance) {
149
+ for (const el of Array.from(document.querySelectorAll(`[${SKIPPED_ATTRIBUTE}]`))) {
150
+ if (el.getAttribute(SKIPPED_ATTRIBUTE) === instance)
151
+ return true;
152
+ }
153
+ return false;
154
+ }
120
155
  /** `use` where React has it (19), else the thrown promise (18). */
121
156
  function suspendUntil(pending) {
122
157
  const use = React.use;
@@ -300,55 +335,34 @@ function paintEnvelope(payload, renderDef, props, seed) {
300
335
  return null;
301
336
  return _jsx(EnvelopeFace, { html: renderCertBlock(input), defVersion: defVersion, seed: seed });
302
337
  }
303
- const FALLBACK_LAYOUTS = ['banner', 'sidebar', 'floating'];
304
- /**
305
- * The options face a block with the delivery marker paints in place of the envelope (1.2.0), or null. Its placement is
306
- * the block's own `mode`, else its def's layout, as the envelope face's is, else the banner bridge. A pinned card (the
307
- * canary replica) paints it too, so the replica shows what a brand's page shows when CertREV is not reached; its
308
- * record names the pin and says `'options'`, so a capture can never take it for the pinned design.
309
- */
310
- function fallbackFace(props) {
311
- const { block } = props;
312
- const layout = block.renderDef?.layout;
313
- const mode = typeof block.mode === 'string' && FALLBACK_LAYOUTS.includes(block.mode)
314
- ? block.mode
315
- : typeof layout === 'string' && FALLBACK_LAYOUTS.includes(layout)
316
- ? layout
317
- : undefined;
318
- return optionsFaceHtml(block, mode);
319
- }
320
- /** The block's options face, stamped with a fallback record when the server wrote one (`ssr-seed.ts`). */
321
- function OptionsFace(props) {
322
- return (_jsx("div", { "data-certrev-cert-chrome": "", "data-certrev-strings-version": optionalText(props.block.stringsVersion), "data-certrev-def-version": typeof props.block.renderDef?.v === 'number' ? props.block.renderDef.v : undefined, ...(props.record === undefined ? {} : { [SEED_ATTRIBUTE]: props.record }),
323
- // biome-ignore lint/security/noDangerouslySetInnerHtml: renderCertBlock is the escaping-safe SSR string renderer
324
- dangerouslySetInnerHTML: { __html: props.html } }));
325
- }
326
338
  /** Where the server painted no face, an empty hidden element carries the record that says so. */
327
339
  function NoFace(props) {
328
340
  return _jsx("div", { hidden: true, [SEED_ATTRIBUTE]: props.record });
329
341
  }
330
342
  /**
331
343
  * 2.0.0: reports one fallback (`card-report.ts`) and renders nothing (no element, no marker: it changes no byte of the
332
- * page). On a server it reports as it renders; in a browser, once it has mounted.
344
+ * 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
345
+ * `origin`, the delivery origin the block reads (none: CertREV's production origin).
333
346
  */
334
347
  function FallbackReport(props) {
335
- const { detail, server } = props;
348
+ const { detail, server, origin } = props;
336
349
  if (server)
337
- reportCardFallbackOnServer(detail);
350
+ reportCardFallbackOnServer(detail, origin);
338
351
  React.useEffect(() => {
339
- reportCardFallbackInBrowser(detail);
340
- }, [detail]);
352
+ reportCardFallbackInBrowser(detail, origin);
353
+ }, [detail, origin]);
341
354
  return null;
342
355
  }
343
356
  /** A face with its fallback report beside it. */
344
- function withReport(face, reason, props, server, certId) {
357
+ function withReport(face, reason, props, server, certId, status) {
345
358
  const detail = {
346
359
  reason,
347
360
  platform: 'builder',
348
361
  externalId: props.externalId,
349
362
  ...(certId === undefined ? {} : { certId }),
363
+ ...(status === undefined ? {} : { status }),
350
364
  };
351
- return (_jsxs(_Fragment, { children: [face, _jsx(FallbackReport, { detail: detail, server: server })] }));
365
+ return (_jsxs(_Fragment, { children: [face, _jsx(FallbackReport, { detail: detail, server: server, origin: props.baseUrl })] }));
352
366
  }
353
367
  function contractOutcome(binding, rawCard, payload, props,
354
368
  /** The digest the read computed (`VerifiedDelivery.cardDigest`): then nothing suspends here. */
@@ -393,31 +407,43 @@ function contractNone(props, reason, server, certId) {
393
407
  return reason ? withReport(face, reason, props, server, certId) : face;
394
408
  }
395
409
  /**
396
- * What a card with no verified envelope paints: the options face when CertREV was not reached, else nothing. 2.0.0:
397
- * the same faces and records as 1.3.1, and two of them are now reported: the options face painted because CertREV
398
- * was not reached (`delivery_unreachable`), and nothing painted because the envelope's kid is unknown (`unknown_key`).
410
+ * What a card with no verified envelope paints: nothing, and a `'none'` record. 2.0.1: CertREV not reached prints
411
+ * nothing too (it painted the block's export-time options through 2.0.0), and the record names `delivery_unreachable`
412
+ * so the browser reports it as well (with CertREV's HTTP status when it answered with one); an unknown kid is reported as
413
+ * `unknown_key`, and (2.0.1) an envelope that does not verify as `envelope_verify_failed`. A definitive no (revoked,
414
+ * expired, for another entry, not found) prints nothing, by design, unreported.
399
415
  */
400
416
  function settleWithoutEnvelope(props, verdict, server) {
401
- const html = verdict.unreachable ? fallbackFace(props) : null;
402
- const face = html === null ? 'none' : 'options';
417
+ const reason = verdict.unreachable
418
+ ? 'delivery_unreachable'
419
+ : verdict.reason === 'unknown_key'
420
+ ? 'unknown_key'
421
+ : verdict.reason !== undefined && ENVELOPE_VERIFY_FAILURES.has(verdict.reason)
422
+ ? 'envelope_verify_failed'
423
+ : undefined;
403
424
  const record = serializeFallback({
404
425
  baseUrl: props.baseUrl,
405
426
  externalId: props.externalId,
406
427
  ...(props.design ? { design: props.design } : {}),
407
- face,
428
+ face: 'none',
429
+ ...(reason ? { reason } : {}),
408
430
  });
409
- if (html !== null) {
410
- return withReport(_jsx(OptionsFace, { block: props.block, html: html, record: record }), 'delivery_unreachable', props, server);
411
- }
412
431
  const none = _jsx(NoFace, { record: record });
413
- // A block with no options to paint when CertREV was not reached is reported too (break-it B5).
414
- if (verdict.unreachable)
415
- return withReport(none, 'delivery_unreachable', props, server);
416
- return verdict.reason === 'unknown_key' ? withReport(none, 'unknown_key', props, server) : none;
417
- }
432
+ return reason
433
+ ? withReport(none, reason, props, server, undefined, verdict.unreachable ? verdict.status : undefined)
434
+ : none;
435
+ }
436
+ /** 2.0.1: the kernel's suppress reasons that mean the envelope itself did not verify (reported, never silent). */
437
+ const ENVELOPE_VERIFY_FAILURES = new Set([
438
+ 'invalid_signature',
439
+ 'unsupported_alg',
440
+ 'platform_mismatch',
441
+ 'content_drift',
442
+ 'unsupported_contract_version',
443
+ ]);
418
444
  /**
419
445
  * Paint what the page's record for this placement says: a seed its envelope (judged at the browser's clock first when
420
- * `judge`), an `'options'` record the options face, a `'none'` record nothing.
446
+ * `judge`), a `'none'` record nothing, and (2.0.1) a 2.0.0 server's `'options'` record nothing too.
421
447
  */
422
448
  function paintRecord(record, props, context, judge) {
423
449
  if ('seed' in record) {
@@ -437,7 +463,7 @@ function paintRecord(record, props, context, judge) {
437
463
  if (binding !== null) {
438
464
  const outcome = contractOutcome(binding, seed.card, seed.payload, props);
439
465
  if (outcome.kind === 'card')
440
- return _jsx(ContractCardFace, { card: outcome.card, names: outcome.names, seed: record.raw });
466
+ return _jsx(ContractCardFace, { card: outcome.card, names: outcome.names, seed: record.raw, origin: props.baseUrl });
441
467
  // Hydrating over the card the server printed, which the browser could not confirm (no WebCrypto): adopt the
442
468
  // server's element; the render right after hydration prints nothing in its place.
443
469
  if (!judge && outcome.reason === 'card_verify_failed' && seed.card !== undefined)
@@ -458,30 +484,20 @@ function paintRecord(record, props, context, judge) {
458
484
  return _jsx(ServerFaceUntilHydrated, {});
459
485
  return paintEnvelope(seed.payload, seed.renderDef, props, record.raw);
460
486
  }
461
- const html = record.fallback.face === 'options' ? fallbackFace(props) : null;
462
- if (html !== null) {
463
- return withReport(_jsx(OptionsFace, { block: props.block, html: html, record: record.raw }), 'delivery_unreachable', props, false);
464
- }
465
487
  const none = _jsx(NoFace, { record: record.raw });
488
+ // 2.0.1: an `'options'` record (a page a 2.0.0 server rendered while CertREV was not reached) prints nothing too.
489
+ if (record.fallback.face === 'options')
490
+ return withReport(none, 'delivery_unreachable', props, false);
466
491
  // 2.0.0: a contract-1 placement's record names why it printed nothing; the browser reports it too.
467
492
  const reason = record.fallback.reason;
468
493
  return isFallbackReason(reason) ? withReport(none, reason, props, false) : none;
469
494
  }
470
- const FALLBACK_REASONS = [
471
- 'card_missing',
472
- 'card_verify_failed',
473
- 'card_unsupported_contract',
474
- 'card_pin_mismatch',
475
- 'unknown_key',
476
- 'delivery_unreachable',
477
- ];
478
495
  function isFallbackReason(value) {
479
- return typeof value === 'string' && FALLBACK_REASONS.includes(value);
496
+ return typeof value === 'string' && CARD_FALLBACK_REASONS.includes(value);
480
497
  }
481
- /** What a card the client mounts with no record and no `clientRead` paints: the options face, or nothing. */
498
+ /** 2.0.1: a card the client mounts with no record and `clientRead` off prints nothing (2.0.0: the options face). */
482
499
  function clientMountFace(props) {
483
- const html = fallbackFace(props);
484
- return html === null ? null : _jsx(OptionsFace, { block: props.block, html: html });
500
+ return withReport(null, 'client_read_off', props, false);
485
501
  }
486
502
  const pendingRecord = () => 'pending';
487
503
  /**
@@ -503,6 +519,24 @@ function StreamedRecordCard(props) {
503
519
  return props.clientRead ? readAndPaint(props, context, true) : clientMountFace(props);
504
520
  return paintRecord(record, props, context, true);
505
521
  }
522
+ /**
523
+ * 2.0.2 (final review R2-1): a card whose server render skipped its read. While hydrating, its marker as the server
524
+ * printed it. After, the record the page holds for its placement (the seed the card that did the read printed), also
525
+ * when that record streams in after this card hydrated; nothing while it has not arrived, and nothing if the page loads
526
+ * without one. It reads nothing.
527
+ */
528
+ function SkippedThenRecord(props) {
529
+ const rt = cardDeliveryRuntime();
530
+ // In render, not in an effect, as `StreamedRecordCard` does.
531
+ watchStreamedRecords();
532
+ const record = React.useSyncExternalStore(subscribeStreamedRecords, () => streamedRecordFor(props.baseUrl, props.externalId, props.design), pendingRecord);
533
+ if (props.hydrating)
534
+ return _jsx(SkippedRead, { instance: props.instance });
535
+ if (record === 'pending' || record === 'done')
536
+ return null;
537
+ const context = { platform: 'builder', externalId: props.externalId, now: new Date(rt.now()) };
538
+ return paintRecord(record, props, context, true);
539
+ }
506
540
  const subscribeNever = () => () => { };
507
541
  /**
508
542
  * True on a server and while a browser hydrates server HTML; false once the client has committed, and from the first
@@ -520,27 +554,28 @@ function useHydrating() {
520
554
  * storefront whose CSP `connect-src` does not name CertREV (e.l.f.'s live policy) never sees a request or a violation
521
555
  * from it, and a page never loses its card for want of one.
522
556
  *
523
- * - Server: read (up to `CARD_DELIVERY_TIMEOUT_MS`, a fresh or stale-while-revalidating cache first), verify and paint
557
+ * - 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
524
558
  * as through 1.1.1, and write a record of what it painted (`ssr-seed.ts`):
525
559
  * - a verified envelope: its face and the envelope seed (less the fields the def hides, and with no signature if
526
560
  * it hid any);
527
- * - CertREV not reached (a timeout, a network error, a 5xx, a body that is not JSON): the block's own options face,
528
- * as 0.5.x painted it, and an `'options'` record (a pinned card too: the record names its pin);
561
+ * - CertREV not reached (a timeout, a network error, a 5xx, a body that is not JSON) with no verified answer inside
562
+ * the stale window: nothing, and a `'none'` record naming `delivery_unreachable` (2.0.1; through 2.0.0 the
563
+ * block's own options face and an `'options'` record);
529
564
  * - a definitive no (revoked, expired, unverifiable, for another entry, not found): nothing, and a `'none'` record
530
565
  * on an empty hidden element.
531
566
  * - Browser, the page carries this placement's record: paint what it says. A seed paints its envelope; while
532
567
  * hydrating the server's verdict stands (the tree is the server's, byte for byte), and once hydrated the lifecycle
533
- * and subject are judged at the browser's clock, so a card that expired since the server render is taken down. An
534
- * `'options'` record paints the options face, a `'none'` record nothing. This holds when React renders the card
568
+ * and subject are judged at the browser's clock, so a card that expired since the server render is taken down. A
569
+ * `'none'` record, and (2.0.1) a 2.0.0 server's `'options'` record, print nothing. This holds when React renders the card
535
570
  * again on the client after a hydration error elsewhere on the page: the record is still in the document (or in
536
571
  * the page's memory of it), so the browser repaints the server's answer and reads nothing.
537
572
  * - Browser, hydrating with no record: server HTML from before 1.2.0 painted nothing here, so nothing.
538
573
  * - Browser, a card mounted on the client with no record (a client-side navigation to an article this page never
539
- * server-rendered, Builder's editor): the block's options face, pinned or not. With `clientRead`, instead, the
540
- * one read, settled exactly as on the server.
574
+ * server-rendered, Builder's editor): with `clientRead` (the default), the one read, settled exactly as on the
575
+ * server; with `clientRead={false}`, nothing (`client_read_off`; 2.0.0: the block's options face).
541
576
  * - Browser, the same while the document is still loading (1.2.1; React rendered the card on the client before the
542
- * server's streamed record arrived, `ssr-seed.ts`): nothing until the record arrives, then what it says; the
543
- * options face, or nothing, if the page loads without one (`StreamedRecordCard`). `clientRead` reads as above.
577
+ * server's streamed record arrived, `ssr-seed.ts`): nothing until the record arrives, then what it says; if the page
578
+ * loads without one, what a client mount does (`StreamedRecordCard`). `clientRead` reads as above.
544
579
  *
545
580
  * WHICH OF THE TWO FIRST CASES THIS RENDER IS (server or browser) is decided from what the render can observe, with
546
581
  * no switch a test could set. A server render and a hydration are both "hydrating" to React and both see a `document`
@@ -560,6 +595,8 @@ function useHydrating() {
560
595
  function EnvelopeCertReviewCard(props) {
561
596
  const rt = cardDeliveryRuntime();
562
597
  const hydrating = useHydrating();
598
+ // 2.0.2: this card instance's id, the same on the server and while hydrating (`SkippedRead`).
599
+ const instance = React.useId();
563
600
  // Latched at mount: this instance hydrated server HTML (as opposed to a client mount).
564
601
  const [hydratedMount] = React.useState(hydrating);
565
602
  // The clock is read per render, so the lifecycle is judged NOW, not when the envelope was fetched.
@@ -575,6 +612,10 @@ function EnvelopeCertReviewCard(props) {
575
612
  // record lands).
576
613
  // 2.0.0: whatever `clientRead` says: a card that read instead of waiting would miss the record the server streams.
577
614
  const [awaitsStream] = React.useState(() => inBrowser && !hydrating && fromPage === undefined && document.readyState === 'loading');
615
+ // 2.0.2, latched at mount: this instance is hydrating a server render that skipped its read (`SkippedRead`).
616
+ const [skippedMount] = React.useState(() => inBrowser && hydrating && skippedInDocument(instance));
617
+ if (skippedMount)
618
+ return _jsx(SkippedThenRecord, { ...props, instance: instance, hydrating: hydrating });
578
619
  if (awaitsStream)
579
620
  return _jsx(StreamedRecordCard, { ...props });
580
621
  if (inBrowser) {
@@ -585,13 +626,13 @@ function EnvelopeCertReviewCard(props) {
585
626
  if (!props.clientRead)
586
627
  return clientMountFace(props);
587
628
  }
588
- return readAndPaint(props, context, inBrowser);
629
+ return readAndPaint(props, context, inBrowser, instance);
589
630
  }
590
631
  /**
591
632
  * The Delivery read and its paint: a server render's, or a browser's (`clientRead`). Suspends on the read, and (1.3.0)
592
633
  * on a delivered face's hashes or (2.0.0) on a card document's digest.
593
634
  */
594
- function readAndPaint(props, context, inBrowser) {
635
+ function readAndPaint(props, context, inBrowser, instance = '') {
595
636
  const rt = cardDeliveryRuntime();
596
637
  const { design } = props;
597
638
  const read = {
@@ -608,9 +649,18 @@ function readAndPaint(props, context, inBrowser) {
608
649
  const key = design
609
650
  ? `${props.baseUrl}|${props.externalId}|design=${design.version}|${design.brand ?? ''}`
610
651
  : `${props.baseUrl}|${props.externalId}`;
611
- const settled = readVerifiedDelivery(read) ?? suspendOn(key, () => getVerifiedDelivery(read));
652
+ const settled = readVerifiedDelivery(read) ??
653
+ (inBrowser
654
+ ? suspendOn(rt.cache, key, () => getVerifiedDelivery(read))
655
+ : suspendOnServer(key, read, rt.serverWaitMs));
656
+ if (settled === 'skipped')
657
+ return _jsx(SkippedRead, { instance: instance });
612
658
  if (settled.verdict.decision !== 'render') {
613
- return settleWithoutEnvelope(props, { unreachable: settled.unreachable === true, reason: settled.verdict.reason }, !inBrowser);
659
+ return settleWithoutEnvelope(props, {
660
+ unreachable: settled.unreachable === true,
661
+ reason: settled.verdict.reason,
662
+ ...(settled.status === undefined ? {} : { status: settled.status }),
663
+ }, !inBrowser);
614
664
  }
615
665
  // The face is painted from the seed's payload (what the def hides is already out of it), so the server's HTML and
616
666
  // the browser's, which paints from the seed, are the same bytes and neither holds a hidden field.
@@ -627,7 +677,7 @@ function readAndPaint(props, context, inBrowser) {
627
677
  if (binding !== null) {
628
678
  const outcome = contractOutcome(binding, settled.card, seed.payload, props, settled.cardDigest);
629
679
  if (outcome.kind === 'card') {
630
- return (_jsx(ContractCardFace, { card: outcome.card, names: outcome.names, seed: serializeSeed({ ...seed, card: outcome.card }) }));
680
+ return (_jsx(ContractCardFace, { card: outcome.card, names: outcome.names, seed: serializeSeed({ ...seed, card: outcome.card }), origin: props.baseUrl }));
631
681
  }
632
682
  // Nothing of this design for this block, by design: nothing at all, and NO record. A record here would sit under the
633
683
  // same placement as the block that printed the card (a split header and memo pair) and, read last on hydration,
@@ -645,104 +695,27 @@ function readAndPaint(props, context, inBrowser) {
645
695
  return paintEnvelope(seed.payload, seed.renderDef, props, serializeSeed(seed));
646
696
  }
647
697
  export function CertReviewCard(props) {
648
- // 1.1.0: a block that carries the delivery marker paints from the signed envelope and the
649
- // brand's def, never from the options beside it. No marker (every entry exported before 1.1.0)
650
- // takes the options path below, unchanged from 1.0.3.
651
- // 1.2.0-canary.5: a design pin (CertREV's canary replica only) fails closed. One that cannot be read renders
652
- // nothing, and so does one on a block without the delivery marker: the options face is never the pinned design.
653
- // 1.2.0: where the envelope cannot be had (CertREV not reached, or no server render and no `clientRead`), a block
654
- // with the marker paints this options face too, pinned or not: see `EnvelopeCertReviewCard`.
698
+ // 1.1.0: a block that carries the delivery marker paints from the signed envelope and the brand's def, never from
699
+ // the options beside it. 2.0.1: a block WITHOUT the marker (an entry exported before 1.1.0, a block dropped in by
700
+ // hand) prints nothing at all and reports `no_marker`; through 2.0.0 it painted its export-time options (a reviewer
701
+ // name, a credential, a memo, a verify link nobody had signed). A marker this client does not accept, or a block
702
+ // with no Builder entry to read for, prints nothing and reports `marker_invalid`.
703
+ // 1.2.0-canary.5: a design pin (CertREV's canary replica only) that cannot be read renders nothing.
655
704
  const design = parseDesignPin(props.builderContext?.context?.certrevDesignPin);
656
705
  if (design === 'invalid')
657
706
  return null;
658
- if (props.delivery != null) {
659
- const marker = acceptedDeliveryMarker(props.delivery);
660
- const entryId = text(props.builderContext?.content?.id);
661
- if (!marker || !entryId)
662
- return null;
663
- return (_jsx(Suspense, { fallback: null, children: _jsx(EnvelopeCertReviewCard, { baseUrl: marker.baseUrl, externalId: entryId, mode: props.mode, part: props.part, design: design, block: props, clientRead: props.clientRead !== false }) }));
664
- }
665
- if (design)
666
- return null;
667
- const html = optionsFaceHtml(props, 'banner');
668
- if (html === null)
669
- return null; // no chrome pre-cert (or an unreadable delivery)
670
- return _jsx(OptionsFace, { block: props, html: html });
671
- }
672
- /**
673
- * The options face's HTML (1.0.3's, unchanged), or null when the block lacks a reviewer or a certificate link. `bridge`
674
- * is the placement a block with no `mode` gets: `'banner'` for a block without the delivery marker (the bridge below);
675
- * the def's layout for a marked block's fallback (`fallbackFace`).
676
- */
677
- function optionsFaceHtml(props, bridge) {
678
- const reviewerName = text(props.reviewerName);
679
- const verifyUrl = text(props.verifyUrl);
680
- if (!reviewerName || !verifyUrl)
681
- return null;
682
- // Name-only face when the pair is unreadable, or when the brand set showCredentials:false.
683
- const showCred = props.display?.showCredentials !== false;
684
- const withCred = showCred ? fusedCredential(props.credentialVerification) : null;
685
- const tokens = props.renderDef?.tokens;
686
- const theme = tokens
687
- ? {
688
- accentColor: optionalText(tokens.accentColor),
689
- surface: optionalText(tokens.surface),
690
- cornerRadius: optionalText(tokens.cornerRadius),
691
- fontSlot: optionalText(tokens.fontSlot),
692
- // v2 brand-ink (post-demo): the bar + free-content ink follow the def.
693
- barInk: optionalText(tokens.barInk),
694
- inkColor: optionalText(tokens.inkColor),
695
- }
696
- : undefined;
697
- return renderCertBlock({
698
- // COMPATIBILITY BRIDGE, NOT THE DESIGN. Delete this fallback once the exporter emits an
699
- // explicit `mode` and existing entries have been backfilled; until then it must stay.
700
- //
701
- // `renderCertBlock` owns the ONE placement default (absent or unrecognised ⇒ sidebar) and
702
- // that decision STANDS: it is what a direct caller gets, and `sidebar` is the registered
703
- // input's `defaultValue`, so every block created in the editor from now on carries an
704
- // explicit `mode` and never reaches this line.
705
- //
706
- // The problem is the entries that already exist. `mode` is a PLACEMENT input, not one of
707
- // the 20 wire keys (`BUILDER_CERT_CHROME_KEYS`), and the exporter emits only the wire
708
- // projection (`options: { ...certData }`), so every entry exported to date has NO `mode`
709
- // key at all. Through 0.5.5 this line read `?? 'banner'` and those entries rendered the
710
- // banner face. Removing it in 1.0.0 did not give them the renderer's default in the
711
- // abstract; it silently repainted live pages from banner to sidebar, with no entry edit
712
- // and no version pin to protect them, because their install line is unpinned.
713
- //
714
- // So this is a bridge for pre-existing entries, NOT a reversal of the sidebar default.
715
- // The three-copies-of-a-default problem that motivated removing it is still real and is
716
- // still fixed: the helper text and the wire type's doc comment no longer name a default,
717
- // and this line names one only for input the renderer can no longer distinguish from a
718
- // deliberate choice. The exporter writing `mode` is what retires it.
719
- mode: props.mode ?? bridge ?? 'banner',
720
- theme,
721
- // First-class banner memo split — renderCertBlock composes the requested
722
- // part directly; no string-splitting. Ignored outside banner mode, and an unrecognised
723
- // value is normalised to 'full' there, same as `mode` — not here.
724
- ...(props.part ? { part: props.part } : {}),
725
- facts: {
726
- authorName: optionalText(props.authorName) ?? 'Editorial',
727
- authorTitle: optionalText(props.authorTitle),
728
- reviewerName,
729
- credential: withCred?.credential ?? '',
730
- credentialVerifiedAt: withCred?.verifiedAt ?? '',
731
- certifiedAt: text(props.certifiedAt),
732
- memo: text(props.memo),
733
- bio: optionalText(props.bio),
734
- profileUrl: text(props.reviewerProfileUrl),
735
- certificateUrl: verifyUrl,
736
- // The compliance disclosures are CONSTANTS the renderer owns — a delivered cue is
737
- // IGNORED (an editor cannot retype the FTC copy into something friendlier), and the
738
- // scope line is not forwarded at all. The ONE thing honored is an explicit `null`:
739
- // the pro-bono reviewer has no compensation to disclose, so the cue is omitted
740
- // rather than replaced by the compensated constant (the modal already gets this
741
- // right — a card claiming "Compensated expert" over a volunteer is an FTC-facing
742
- // falsehood, not a cosmetic default).
743
- compensationCue: props.compensationCue === null ? null : undefined,
744
- },
745
- });
707
+ const entryId = text(props.builderContext?.content?.id);
708
+ if (props.delivery == null)
709
+ return _jsx(BlockReport, { reason: "no_marker", externalId: entryId });
710
+ const marker = acceptedDeliveryMarker(props.delivery);
711
+ if (!marker || !entryId)
712
+ return _jsx(BlockReport, { reason: "marker_invalid", externalId: entryId });
713
+ return (_jsx(Suspense, { fallback: null, children: _jsx(EnvelopeCertReviewCard, { baseUrl: marker.baseUrl, externalId: entryId, mode: props.mode, part: props.part, design: design, block: props, clientRead: props.clientRead !== false }) }));
714
+ }
715
+ /** 2.0.1: a block with nothing to read reports why, to CertREV's production origin (it has no delivery origin). */
716
+ function BlockReport(props) {
717
+ const detail = React.useMemo(() => ({ reason: props.reason, platform: 'builder', externalId: props.externalId }), [props.reason, props.externalId]);
718
+ return _jsx(FallbackReport, { detail: detail, server: typeof document === 'undefined' });
746
719
  }
747
720
  /** Builder input TYPE per wire-type key — the exporter serializes these option values. */
748
721
  const WIRE_INPUT_TYPE = {