@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
|
@@ -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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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:
|
|
397
|
-
* the
|
|
398
|
-
*
|
|
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
|
|
402
|
-
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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`),
|
|
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' &&
|
|
496
|
+
return typeof value === 'string' && CARD_FALLBACK_REASONS.includes(value);
|
|
480
497
|
}
|
|
481
|
-
/**
|
|
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
|
-
|
|
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 (
|
|
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)
|
|
528
|
-
*
|
|
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.
|
|
534
|
-
* `'
|
|
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):
|
|
540
|
-
*
|
|
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
|
-
*
|
|
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) ??
|
|
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, {
|
|
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
|
-
//
|
|
650
|
-
//
|
|
651
|
-
//
|
|
652
|
-
//
|
|
653
|
-
// 1.2.0:
|
|
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
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
return
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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 = {
|